diff --git a/.claude/rules/lana.md b/.claude/rules/lana.md index 85bae3747..fb2da352a 100644 --- a/.claude/rules/lana.md +++ b/.claude/rules/lana.md @@ -16,6 +16,9 @@ VS Code extension. Applies when working under `lana/`. ## Settings - **Give every user preference a `lana.*` setting.** Do not hide it in `globalState`. +- **Remembered UI state is not a preference.** The view a table was last left in, the inspector's + section layout, column overrides: these persist privately in `globalState` — see + `PRIVATE_SECTIONS` in `AppConfig.ts` — and are not registered settings. - **Push `configChanged` to an open panel when a setting changes.** The panel sets `retainContextWhenHidden`, so it is never re-created and never re-reads the config itself. diff --git a/.claude/rules/log-viewer.md b/.claude/rules/log-viewer.md index 2575429f9..337465c72 100644 --- a/.claude/rules/log-viewer.md +++ b/.claude/rules/log-viewer.md @@ -39,8 +39,9 @@ Webview UI. - Never define or override a `--vscode-*` name — an override is global to the webview. Exception: skinning a `vscode-elements` component; scope it to that element, never `:host` or `:root`. - Write no literal font size or family. Take a step from the ramp in `styles/tokens.css` - (`--lana-text-*`, `--lana-text-mono` for editor-sized text, `--lana-text-meta` for header - metadata) and a family from `--lana-font-mono` or `--lana-font-ui`. + (`--lana-text-*`, `--lana-text-meta` for header metadata) and a family from `--lana-font-mono` or + `--lana-font-ui`. Code-shaped text takes no size of its own: it inherits the surface that holds + it, so nothing in the webview follows the reader's `editor.fontSize`. - Mono is for text whose alignment carries meaning — stacks, code, log text. Prose takes the UI font. - An all-caps run takes `--lana-text-caps` and `--lana-text-caps-tracking`, one step down: every glyph reaches cap height, so caps read a size larger. diff --git a/.claude/skills/changelog-entry/SKILL.md b/.claude/skills/changelog-entry/SKILL.md new file mode 100644 index 000000000..48714acc1 --- /dev/null +++ b/.claude/skills/changelog-entry/SKILL.md @@ -0,0 +1,128 @@ +--- +name: changelog-entry +description: Write, review or trim CHANGELOG.md entries. Use when editing CHANGELOG.md, when a PR needs a changelog line, when a section is too long or a change may be breaking, or on a changelog conflict. +--- + +# Changelog entry + +A changelog is written for the person who upgrades. How the change was made, what it cost, and which +files moved belong in the issue and the pull request. + +## The shape of an entry + +``` +- [**Breaking:** ] ([#]) +``` + +```markdown +### Changed + +- **Breaking:** drop `file` from `get_apex_log_summary` in favour of the scalar `topMethodsSelfPercentage` ([#86]) +- Reduce every tool response with no fact lost: `execute_anonymous` by 30%, `get_apex_log_summary` by 27% ([#86]) + + + +[#86]: https://github.com/owner/repo/issues/86 +``` + +- **One sentence.** No semicolon joining two facts. Two wrapped lines is the ceiling. +- **Sub-bullets only where one feature has distinct parts** — three at most, each naming one + capability. Never how it was built. +- **Present tense.** "reports", "shows", "flags" — never "reported", "showed". +- **Breaking entries first** in their section, prefixed `**Breaking:**`. +- **Then most impactful first.** The entry that changes the most readers' day leads its + section. Not commit order, not issue number, not the order you wrote them. +- **Sections in this order:** Added, Changed, Fixed. +- **A reference link on every substantial entry**, defined under `` at the end of + the file. Never an inline URL. + +## This repo's house style + +``` +- **Label**: ([#issue]) +``` + +Say what the user gets. No leading verb — the label names the feature. Use an emoji no other entry +in the section uses. + +The root `CHANGELOG.md` is the source. The `lana/` copy is generated by the build — never edit it. + +**The file outranks this skill on style.** Match the released sections. Length and jargon are not +style — those rules always hold. + +## Write for the reader, not the author + +The reader upgrades the package; they did not write it. Name the outcome they can see. + +- **No internal jargon.** No module, class, library or algorithm names. If the reader cannot find + the word in the product, cut it. +- **A fix names the symptom, not the cause.** +- **A big feature gets one headline entry**, plus up to three sub-bullets for its parts. Detail + belongs in the docs. + +## What earns an entry + +One entry per user-visible change, not one per commit. If a user of the released package cannot see +it, it gets no entry: a refactor, a renamed internal helper, a test, the mechanism behind a fix. + +Give the result, not the method. A number earns its place when the size **is** the result; how it +was measured does not. + +**A performance entry always carries its number** — a multiple or a percentage, and what it is of. +"Faster" on its own is not an entry, because the reader cannot tell whether to care. + +Already-unreleased work: edit the existing entry, and drop a fix for a bug that only ever existed +in it. A change nobody has received is not a change, and nobody met the bug. + +No issue fits? File one, then reference it. + +## Wrong, then right + +| Wrong | Right | +| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `- Removed destructiveHint from three tools, since the spec says it is meaningless when readOnlyHint is true` | no entry — the user sees no difference | +| `- Replaced ten per-category properties with one z.partialRecord, cutting ~844 to ~428 tokens` | fold the result into the one user-facing entry | +| `- Reduced the cost by 31% ([#87](https://.../87))` | `- Reduce the cost by 31% ([#87])`, plus a reference definition | +| `- Refactor CSV parsing to process dataset arrays asynchronously` | `- Fix the freeze on a large CSV export` | +| `- Replace webview-ui-toolkit with vscode-elements` | `- Match the host's controls more closely` | +| a feature with six nested sub-bullets | a headline sentence, then three sub-bullets at most, one capability each | +| `- Improve search performance` | `- Search a 100MB log 10× faster` | +| `- Optimise the parser` | `- Cut parse time on a large log by 31%` | + +## Trim a section nobody will read + +Screens long, or nested three deep. Rewrite the section whole — entry-by-entry edits never merge +anything, and merging is most of the win. + +1. Find the bounds: `grep -n '^## \[' CHANGELOG.md`. +2. Read the whole section before changing a word. +3. Draft the replacement in one pass. Three sub-bullets at most under a headline; fold or drop + the rest. +4. Merge entries that name the same surface or the same fix. Three styling entries are one entry. +5. Drop what the reader cannot see, by the rules above. +6. Re-order each section by impact. A trimmed section in the old order still buries the lead. +7. Keep every issue reference. Losing one loses the trail to the detail you cut. +8. Splice it in, then check the references — left column is used but undefined, right is defined but + unused: + + ```bash + comm -3 <(grep -v '^\[#' CHANGELOG.md | grep -o '\[#[0-9]*\]' | sort -u) \ + <(grep -o '^\[#[0-9]*\]' CHANGELOG.md | sort -u) + ``` + +9. Run the repo's formatter. + +Report the before/after line count and every entry you merged or dropped. A cut the author disagrees +with is invisible to them otherwise. + +## Versions and migration + +- A version heading is added when the release is tagged, with an absolute date: `## [1.0.0] - 2026-03-20`. + Until then everything sits under `## [Unreleased]`. +- **Major** is forced by behaviour that changes for someone who upgrades and changes nothing else. +- When upgrading needs an action, add a migration note under `## [Unreleased]` that points at it. + +## Stacked branches + +Every branch in a stack writes into the same `## [Unreleased]` section, so a rebase conflicts there. +Keep both sides. Losing the other branch's entry is silent, and review will not catch it. diff --git a/.gitattributes b/.gitattributes index 9d4b5349c..9bf1e5b69 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.log filter=lfs diff=lfs merge=lfs -text + +# e2e fixture: small enough to keep in git, and LFS pointers are not Apex logs +lana/test/playwright/fixtures/*.log -filter diff merge text diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 71b66007b..d419957c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,10 +12,6 @@ updates: # majors are deliberately left out of the minor/patch groups so each one still # arrives as its own PR - they need reading and are reverted individually groups: - # exception: @salesforce/apex-node majors require the matching - # @salesforce/core major, so these move in lockstep incl. majors - salesforce: - patterns: ['@salesforce/*'] production-dependencies: dependency-type: 'production' update-types: ['minor', 'patch'] diff --git a/.github/workflows/cd-prerelease.yml b/.github/workflows/cd-prerelease.yml index a064bfafb..a408cc46f 100644 --- a/.github/workflows/cd-prerelease.yml +++ b/.github/workflows/cd-prerelease.yml @@ -63,7 +63,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -71,10 +71,6 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce + ovsx - run: | - pnpm add --global @vscode/vsce - pnpm add --global ovsx - name: Dependencies run: pnpm run ci:install - name: update pre-release version @@ -82,9 +78,7 @@ jobs: echo "Updating pre-release version" pnpm run bump-prerelease; - name: Package the extension - run: | - cd lana - vsce package --pre-release --no-dependencies + run: pnpm --filter lana exec vsce package --pre-release --no-dependencies - name: Publish to VS Code Marketplace + Open VSX Registry # Tokens via env (not -p) so they don't appear in the process list. env: @@ -93,22 +87,22 @@ jobs: run: | cd lana echo "Verify vsce token has not expired" - vsce verify-pat + pnpm exec vsce verify-pat echo " Verify ovsx token has not expired" - ovsx verify-pat + pnpm dlx ovsx@1.1.1 verify-pat versionNum=$(jq -r '.version' package.json) pkgPath="lana-${versionNum}.vsix" echo "Publish to vsce vsix name: $pkgPath" - vsce publish --packagePath "${pkgPath}" --no-dependencies --pre-release --skip-duplicate + pnpm exec vsce publish --packagePath "${pkgPath}" --no-dependencies --pre-release --skip-duplicate echo " Publish to ovsx" - ovsx publish "${pkgPath}" --no-dependencies --pre-release --skip-duplicate + pnpm dlx ovsx@1.1.1 publish "${pkgPath}" --no-dependencies --pre-release --skip-duplicate - name: Update pre-release tag run: | echo "Updating pre release tag" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c57a3b386..3004b501a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, release/**] pull_request: - branches: [main, release/**] workflow_dispatch: # Least-privilege default: CI only needs to read the repository. @@ -19,7 +18,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -45,7 +44,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -58,6 +57,44 @@ jobs: - name: Tests run: pnpm exec jest --selectProjects ${{ matrix.project }} --runInBand + e2e: + name: Test (web e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6.1.0 + with: + version: 10 + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'pnpm' + - name: Install Packages + run: pnpm run ci:install + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Tests + run: pnpm run test:e2e:web + - name: Upload Playwright HTML report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report-web + path: lana/playwright-report/web + if-no-files-found: ignore + retention-days: 7 + - name: Upload Playwright test results + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-test-results-web + path: lana/test-results/web + if-no-files-found: ignore + retention-days: 7 + build: name: Verify VSCode Package Build runs-on: ubuntu-latest @@ -65,7 +102,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -73,11 +110,20 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce - run: pnpm add --global @vscode/vsce - name: Install Dependencies run: pnpm run ci:install - name: Build VSCode Package + run: pnpm --filter lana run build:vsix + + gate: + # The single required status check: the ruleset never needs updating when jobs change. + name: CI Gate + needs: [verify_files, tests, build] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check job results + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') run: | - cd lana - vsce package --no-dependencies + echo "A required job did not succeed." + exit 1 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f51fa864d..a1b3b78dc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,12 +59,12 @@ jobs: # JavaScript/TypeScript is analyzed straight from source, so no build is needed # (build-mode: none skips the Autobuild dependency-install/build attempt). - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} build-mode: none - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.9 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/publish-gh-pages.yml b/.github/workflows/publish-gh-pages.yml index 792bd9545..b36369bcb 100644 --- a/.github/workflows/publish-gh-pages.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false # 👇 Build steps - name: pnpm setup - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c43806188..64550c8c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -24,16 +24,10 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce + ovsx - run: | - pnpm add --global @vscode/vsce - pnpm add --global ovsx - name: Dependencies run: pnpm run ci:install - name: Build extension - run: | - cd lana - vsce package --no-dependencies + run: pnpm --filter lana run build:vsix - name: Publish to VS Code Marketplace + Open VSX Registry # Secrets and the release tag are passed via env (not interpolated into the # shell) to avoid leaking tokens in the process list and shell injection. @@ -44,11 +38,11 @@ jobs: run: | cd lana echo "Verify vsce token has not expired" - vsce verify-pat + pnpm exec vsce verify-pat echo "Verify ovsx token has not expired" - ovsx verify-pat + pnpm dlx ovsx@1.1.1 verify-pat echo "Publish to vsce" - vsce publish --packagePath "lana-${TAG}.vsix" --no-dependencies + pnpm exec vsce publish --packagePath "lana-${TAG}.vsix" --no-dependencies echo "Publish to ovsx" - ovsx publish "lana-${TAG}.vsix" --no-dependencies + pnpm dlx ovsx@1.1.1 publish "lana-${TAG}.vsix" --no-dependencies diff --git a/.gitignore b/.gitignore index cf6bcf740..626818bb5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ *.tsbuildinfo /.vscode-test-web scripts/measure/out/ +/lana/test-results +/lana/playwright-report diff --git a/.vscode/lana-dev.code-profile b/.vscode/lana-dev.code-profile new file mode 100644 index 000000000..5f9febb48 --- /dev/null +++ b/.vscode/lana-dev.code-profile @@ -0,0 +1,4 @@ +{ + "name": "lana-dev", + "extensions": "[{\"identifier\":{\"id\":\"salesforce.salesforcedx-vscode-services\"}}]" +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 1de64e263..2b0a751c9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,11 @@ "name": "Run Extension", "type": "extensionHost", "request": "launch", - "args": ["--extensionDevelopmentPath=${workspaceFolder}/lana"], + "args": [ + "--profile=lana-dev", + "${workspaceFolder}/sample-app", + "--extensionDevelopmentPath=${workspaceFolder}/lana" + ], "outFiles": ["${workspaceFolder}/lana/out/**/*.js"], "localRoot": "${workspaceFolder}/lana" }, @@ -21,6 +25,8 @@ "type": "extensionHost", "request": "launch", "args": [ + "--profile=lana-dev", + "${workspaceFolder}/${input:worktree}/sample-app", "--extensionDevelopmentPath=${workspaceFolder}/${input:worktree}/lana" ], "outFiles": ["${workspaceFolder}/${input:worktree}/lana/out/**/*.js"], @@ -31,6 +37,7 @@ "type": "extensionHost", "request": "launch", "args": [ + "--profile=lana-dev", "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/lana/out/test/suite/index" ], diff --git a/AGENTS.md b/AGENTS.md index a6bf633fb..7bc208978 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,10 @@ Always use pnpm. - `pnpm lint` — type + lint check - `pnpm prettier-format` — auto-format +**Dev host** — launch with +`code-insiders --profile lana-dev $PWD/sample-app --extensionDevelopmentPath=$PWD/lana`, use the +CLI of the launched editor, `code-insiders` or `code` + **Compilers** — `typecheck` = native TS7 (`tsc`); `typecheck:tsc6` = classic 6.0 (`tsc6`). Keep the `@typescript/typescript6` alias + `tsc6`: `typescript-eslint` and Docusaurus need the TS ≤6.0 API (lands in TS 7.1). Don't remove until typescript-eslint supports TS7. @@ -34,7 +38,9 @@ the TS ≤6.0 API (lands in TS 7.1). Don't remove until typescript-eslint suppor - **Performance** — handle large logs (50MB+, 500k+ lines) without blocking the UI. - **UX** — discoverable, accessible, actionable errors. - **Testing** — features and bug fixes ship with tests; CI blocks failures. -- **Comments** — only what the code cannot say, one short line, and only where needed. +- **Comments** — only the non-obvious: a why, a gotcha, an invariant. One terse line. + Never restate code or narrate an edit — in doubt, no comment. JSDoc exported + functions, not private helpers. ## Critical boundary @@ -47,6 +53,7 @@ communicate via message passing only. `refactor:`, `perf:`, `test:`). Don't auto-commit. - Branches: `feat-*` for features, `bug-*` for defects. - Releases follow SemVer; update CHANGELOG; breaking changes need a migration guide. +- CHANGELOG entries: see the `changelog-entry` skill in `.claude/skills/`. - Never reference Anthropic or Claude in commit messages, PRs, etc. ## Rules manifest diff --git a/CHANGELOG.md b/CHANGELOG.md index 58441b77e..913938c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,67 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- 🧠 **Heap analysis**: heap is no longer a single number. Every method and call path now carries three heap metrics, so you can tell a real leak from harmless allocate-then-free churn. ([#32]) - - **Net** – bytes retained (allocated minus freed); the lasting footprint. Can be negative where a path frees more than it allocates. - - **Gross** – bytes allocated, ignoring frees; allocation churn and GC pressure. - - **Peak** – highest live heap reached on the path; the number comparable to the heap governor limit. - - Shown together in the **Memory** view (total + self); peak also appears in the **Governor Limits** view and feeds the Gov Avg/Peak columns. Method tooltips show net heap retained. - - The Timeline governor strip plots heap as it's allocated, so you can see where it spikes. -- 🧭 **Inspector**: select anything — a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement — and inspect it without leaving the tab you're on. ([#113]) - - **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it — the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it. - - **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, how few statements hold the time, and every call path that ends in a query, DML or search with total and self time. ([#373]) - - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view — hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions. - - **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called. - - **Detail | Summary** switches between what you picked and the tab's summary of the whole log, keeping the selection to come back to. - - Dock it left, right or bottom, drag to resize any section — double-click a divider to restore the defaults — and collapse the sections you don't need; the layout is remembered. `Escape` clears the selection and returns the whole-log view. ([#63]) -- 🗄️ **Database Analysis**: governor-limit visibility and SOSL usage. ([#162]) - - 📏 **Governor-limit overview**: SOQL, SOSL, DML and query/DML rows shown as `used / limit`, colored as they approach the limit. - - 🧮 **Found vs Counted**: each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata, which is free unless it selects a long text area field or runs in a Flow). - - 🔎 **SOSL table**: a dedicated, searchable Database table for SOSL. - - 🧭 **Show in Call Tree**: right-click any SOQL, DML or SOSL statement to jump to it in the full Call Tree. -- 🗂️ **Configurable table columns** (Call Tree, Analysis, Database). ([#298]) - - 🗂️ **Column views**: switch preset column sets, show/hide columns from the **Columns** button or the header right-click menu, inline **reset** to restore defaults; choices persist per view. - - 🏷️ **New columns**: **Object** (queried/target SObject, with group-by) on SOQL/DML; **SOSL Count/Rows**, **Avg Self Time** and optional **Self** variants for every governor metric; and a SOQL **Query Plan** view (Relative Cost, Leading Operation, SObject Type, Cardinality). -- 🧰 **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table. - - Filter by **Namespace**, **Object** or **Caller Namespace**, or by a **Row Count** / **Time Taken** min–max range; active filters are highlighted. - - Collapse behind a **Filter** button on narrow window. ([#873]) -- 🔴 **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) -- 🪪 **Header**: the header now includes entry point, user, and start time with hover for more details. +- 🧭 **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom, and each tab keeps its own arrangement. ([#113] [#373] [#63]) + - **Reorder**: drag a section header, or press `Alt+Up` / `Alt+Down`. + - **Choose sections**: right-click a header to tick sections on and off, or reset the list. + - **Resize**: drag a divider; double-click one to restore the default sizes. +- 🔬 **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Pick a merged row and it compares its calls instead — which names varied and every value they held, with how many calls held each; hover a value to light those calls in the timeline and grids. Needs Apex Code at **FINEST**. ([#373]) +- 🧠 **Heap analysis**: every method and call path reports heap three ways - **Net** (retained), **Gross** (allocated) and **Peak** (highest live) - so allocate-then-free churn no longer looks like a leak. ([#32]) +- 🗄️ **Database governor limits**: SOQL, SOSL, DML and row counts show as `used / limit`, flagging queries that did not consume the limit, plus a dedicated SOSL table. ([#162]) +- 🔴 **Timeline exception markers**: exceptions show as red lines, with a Throws count in method tooltips. ([#828]) +- 🧰 **Filter bar**: Call Tree and Database filters sit in one toolbar - filter by namespace, object or caller namespace, or by a row count or time range. ([#873]) +- 🗂️ **Choose your columns** in Call Tree, Analysis and Database: switch between presets, show or hide any column, and your choice is remembered. ([#298]) +- 🏷️ **New columns**: Object on SOQL and DML, SOSL Count and Rows, Avg Self Time, a Self variant of every governor metric, and a SOQL Query Plan view. ([#298]) +- 🪪 **Header**: shows the entry point, user and start time, with more detail on hover. ### Changed - ⬆️ **Requires VS Code 1.102 or newer**. -- 📏 **Governor figures**: every whole-log readout — the overview gauges, the governor trends, the Database overview and the Analysis findings — reports a metric at its peak, the level the governor charges the transaction at. The Timeline governor strip still plots the log as recorded. -- 📊 **Timeline** - - **Governor limits strip**: tooltip rows keep a stable order and always show the `used / limit` value, so figures no longer jump around as you move the pointer. ([#827]) - - **Timeline zooming**: consistent, smooth zoom across platforms and input devices — a Windows mouse wheel no longer over-zooms in large jumps, fast scrolls stay bounded, and zooming in then back out returns to the same level. - - **Truncation markers** now end where the log recovers, so trusted sections are no longer flagged. ([#828]) - - **Frame details**: the hover panel now sits against the frame — above it, or below it when there is no room — and slides along the frame with the pointer. It fades in after a short pause and keeps one size, which grows with the window. A SOQL query is fitted to that size clause by clause, so the `WHERE` is always visible however long the field list is, and each clause says what it left out — `+35 fields`, `… +6 conditions`, `IN (… 200 ids)`. A footer row points to the inspector for the rest. The panel never takes the pointer, so you can hover and click the frames underneath it. Turn it off from the toolbar button or with `lana.timeline.showTooltip`. - - **Legend**: moved from below the chart to the toolbar above it, restyled as colour-dot chips, and each chip now shows the log's self time in that category. Event tooltips name the category next to its colour swatch. -- 🏷️ **Call Tree names**: rows no longer carry a raw `EVENT_TYPE:` prefix in front of text that already identifies them, so `WF_CRITERIA_BEGIN: WF_CRITERIA : ON_ALL_CHANGES` reads as `WF_CRITERIA : ON_ALL_CHANGES`. Frames whose text can't stand alone keep the type, and the ones that needed naming now say what they are — `(code unit)`, `(constructor)`, `(managed package)`, `(flow)`. A **Type** column is available in every view from the **Columns** menu if you want the raw types back. -- 🗂️ **Call Tree + Database styling**: VS Code style tree icons, and rows indent under their group headings. ([#832]). -- 🎛️ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]). -- 🗄️ **Database table columns** (DML, SOQL, SOSL): consolidated onto the shared Call Tree column/sort styling for a consistent look across all tables. ([#873]) -- 🧱 **Data grids**: a crisper header/content separator and tidied grid styling across all tables. ([#873]) -- 📐 **Column widths**: sized to fit their header and values, so nothing clips. -- 🔤 **Text sizes** now follow your VS Code font size instead of fixed pixel sizes, and code — table name columns, tooltips, group rows — always uses your editor font. -- 🎨 **Header bar** - - **Log problems** and **Notifications** redesigned cards, show two lines of summary and message (click the message for the rest), and go to the Call Tree when clicked. An **Unsupported log event** card opens a prefilled bug report. - - **Log problems** icon shows the most severe problem found, with a count, the card shows the issue kind (a `Fatal error` / `Exception` pill) and the time in the log under the summary . - - **Log problems** card say what kind of problem they are: a `Fatal error` / `Exception` pill and the time in the log sit under the summary. - - **Help & documentation** and **Report an issue** move into a `•••` menu, which also holds the values and controls the header drops as the window narrows. -- ♻️ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). -- ⚡ **Go to Code**: Faster in large projects — ~6× to ~10× faster ([#834]). +- 📏 **Governor limits come only from the log**: no limit is assumed where the log reports none, so a figure is never measured against a maximum the transaction never had. Without one the gauges read as levels, the Timeline strip and the governor trends scale each metric to its own peak, and the governor cost columns read `—`. +- 📊 **Governor columns** in the Call Tree and Analysis fill against what the transaction consumed, like the time columns beside them, so the path responsible for a metric stands out. **Gov Avg %** and **Gov Peak %** still measure headroom, and each tooltip still names the limit. +- 📏 **Governor figures**: the Inspector overview and the Database tab report each metric at its peak, the level the governor charges the transaction at. The Timeline strip still plots the log as recorded. +- 🎨 **Header bar**: Log problems and Notifications are redesigned cards that name the problem and its time and jump to the Call Tree; Help and Report an issue move into a `•••` menu. +- 🎨 **Timeline legend**: moved into the toolbar as colour chips, each showing the log's self time in that category. +- 📊 **Timeline frame details**: the hover panel sits against the frame, follows the pointer, never blocks clicks, and fits long SOQL so the `WHERE` stays visible. Turn it off with `lana.timeline.showTooltip`. +- 🔍 **Timeline zooming**: smooth and consistent on every platform - a Windows mouse wheel no longer over-zooms, and zooming back out returns you to where you started. +- ✂️ **Truncation markers** end where the log recovers, so sound sections are no longer flagged. ([#828]) +- ⚡ **Timeline minimap** is ~25× faster and uses less memory. +- 📈 **Governor strip tooltips** keep a stable row order and always show `used / limit`, so figures no longer jump as you move the pointer. ([#827]) +- ⚡ **Go to Code** is 6× to 10× faster in large projects. ([#834]) +- 🏷️ **Call Tree names** drop the redundant `EVENT_TYPE:` prefix and say what a frame is - `(code unit)`, `(flow)`. Add the **Type** column back from the **Columns** menu. +- 🧱 **Table styling**: VS Code tree icons, rows indented under their group headings, a crisper header separator, columns sized to fit so nothing clips, and one consistent look across every table. ([#832] [#873]) +- 🎛️ **Dropdowns** are searchable and compact, and carry the field and value together (e.g. `Group: Namespace`). ([#848]) +- 💅 **UI** More closely matches VS Code styling in several areas. ([#576]) ### Fixed -- 🎨 **Timeline theme switch**: parts of the Timeline did not update on theme switch until the log view was reopened; they now do. -- 📊 **Database usage bars** (Row Count, Time Taken): the usage bar was hidden whenever the rounded percentage was 0% (the common case for small row counts against large governor limits), so it rarely appeared; it now fills relative to the grid's own column total rather than a governor limit, shows on grouped summary rows, and Time Taken (ms) now shows a bar too. ([#873]) -- 🎨 **Theme colours**: some colours did not update on theme switch; they now do. -- 🧭 **Inspector call stack**: cumulative limit and profiling frames appeared in the stack, so the path to a selection read wrong; the stack now excludes them, like the call tree already did. -- 🐛 **Go to Code**: Match methods with namespace/`System`-qualified parameter types. ([#834]) -- 📐 **Timeline height**: the Flame Chart stopped short of the bottom of its panel, leaving a strip of empty space; it now fills the panel and follows the Inspector as you resize or re-dock it. -- 🗄️ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element went uncounted, because the log never reports it as a statement; the element's own usage is now counted and rolls up like any other. Needs `WORKFLOW` at `FINER` or above. ([#871]) +- 📏 **Timeline length**: the chart stopped at the last recorded frame instead of spanning the log - 10.8s of a 27.1s log - and now shades the part the log never recorded. ([#828]) +- 📊 **Governor limits strip**: where the log recorded nothing, the strip drew its last reading across the gap as though it had been measured; the gap is now blank, and the tooltip names the reason and range. ([#828]) +- 🗄️ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element are now counted. Needs `WORKFLOW` at `FINER` or above. ([#871]). +- ⚡ **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge. +- 🖱️ **Governor limits strip**: reading across the 15px collapsed strip lost the tooltip on the smallest wobble; the hover now holds until the pointer is clear of it. +- 🐛 **Go to Code** matches methods with namespace or `System` qualified parameter types. ([#834]) +- 🎨 **Theme switch**: Timeline and view colours update straight away instead of needing the log reopened. ## [1.20.1] 2026-07-23 diff --git a/DEVELOPING.md b/DEVELOPING.md index cdba1057e..5a9d2852d 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -123,8 +123,7 @@ pnpm install 2. Package the extension: ```zsh -cd lana -vsce package --no-dependencies +pnpm --filter lana run build:vsix ``` This command will create a `.vsix` file that you can distribute or install locally. diff --git a/docs/pr-951-952-review-findings.md b/docs/pr-951-952-review-findings.md deleted file mode 100644 index b1dbe2289..000000000 --- a/docs/pr-951-952-review-findings.md +++ /dev/null @@ -1,299 +0,0 @@ -# PR 951 and 952 review findings - -## Scope - -Review of upstream PRs: - -- [#951 — `refactor(lana): use Salesforce Services`](https://github.com/certinia/debug-log-analyzer/pull/951) -- [#952 — `refactor(lana): use URI-safe file access`](https://github.com/certinia/debug-log-analyzer/pull/952) - -Checked 2026-08-26. Review state at discovery time: - -- PR 951: 14 open review threads; changes requested. -- PR 952: 5 open review threads; review required. -- No general PR comments or review-body findings; all findings are inline threads. - -## Summary - -Do not merge either PR unchanged. - -Most lifecycle, caching, URI, menu, race, and display fixes belong in Log Analyzer and can start immediately. Correct multi-root org selection, complete log listing, reliable published types, and eliminating the consumer-owned Effect runtime require Salesforce Services changes. - -## Remediation status - -Checkpoint: 2026-08-26, commit `5e70601e` on `ph/W-23939830-services-upstream`. - -| Area | Status | Result or next step | -| --------------------------------------- | ----------- | ------------------------------------------------------------------------- | -| PR 951 lazy Services activation | Complete | Retrieve Log initializes Services; local analysis activation does not. | -| PR 951 missing/incompatible Services UX | Complete | Install/update action; unexpected activation failures preserved. | -| PR 951 runtime API validation | Complete | Checks Apex log, filesystem, and prebuilt-context exports. | -| PR 951 cached-log reuse | Complete | Cache hit skips body retrieval and write. | -| PR 951 access-denied matching | Complete | Handles joined, spaced, repeated-space, case, and surrounding whitespace. | -| PR 951 declaration dependency placement | Complete | `@salesforce/vscode-services` moved to `devDependencies`. | -| PR 951 activation bundle split | Complete | Salesforce bridge and Effect runtime emitted as lazy chunks. | -| Log Analyzer filesystem ownership | Deferred | Keep Salesforce `FsService` for now. | -| PR 951 workspace-scoped org retrieval | Blocked | Requires Services workspace/org targeting. | -| PR 951 complete log listing | Blocked | Requires Services pagination or optional limit. | -| PR 951 self-contained declarations | Blocked | Requires a corrected Services npm package. | -| PR 951 shared runtime/Promise boundary | Blocked | Requires a Services export. | -| PR 952 remediation | Not started | Begin only after PR 951 review/branch update. | - -Verification at checkpoint: - -- `pnpm test:ci`: 139 suites, 1,845 tests passed. -- `pnpm build`: passed, including typecheck and production bundles. -- Changed files: ESLint, Prettier, and `git diff --check` passed. -- Full `pnpm lint`: still obstructed because `eslint .` traverses generated `.vscode-test-web` sources despite the ignore entry. - -## Dependency matrix - -`Requires Services` means the complete Log Analyzer fix depends on a new or corrected Salesforce Services release. - -| Log Analyzer change | Requires Services | Reason | -| ------------------------------------------------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------- | -| Lazy-load Services from Retrieve Log | No | Log Analyzer controls activation and command flow. | -| Remove hard `extensionDependencies` and show install/update prompt | No | Log Analyzer manifest and UX. | -| Use `vscode.workspace.fs` for Log Analyzer files | No | VS Code API; no Services dependency needed. | -| Dynamically import the Services bridge | No | Log Analyzer build and module boundary. | -| Move `@salesforce/vscode-services` to `devDependencies` | No | Package contains compile-time declarations. | -| Guard missing/outdated API by checking required exports | No | Structural guard can ship now. | -| Use an explicit Services API version/capability contract | Yes | Services does not publish one. | -| Restore cached-log reuse | No | Log Analyzer retrieval flow. | -| Fix access-denied matching | No | Log Analyzer response validation. | -| Keep cache path tied to the selected workspace | No | Log Analyzer already owns workspace selection and the cache URI. | -| Retrieve logs from the selected workspace's org | Yes | `ApexLogService` resolves the Services-selected/default org. | -| Use a workspace-aware Services debug-log directory | Yes | `ProjectService.getDebugLogsFolder()` has no workspace argument. | -| Replace the 25-log cap with full listing or Load More | Yes | `ApexLogService.listLogs()` needs pagination or an optional limit. | -| Compile against trustworthy Services types | Yes | Published declarations reference files absent from the npm package. | -| Remove Log Analyzer's bundled Effect runtime | Yes | Services must expose its runtime or Promise-returning wrappers. | -| Remove URI-scheme allowlist | No | Log Analyzer detection policy. | -| Restore command-palette visibility condition | No | Log Analyzer manifest. | -| Suppress stale async language-detection results | No | Log Analyzer request coordination. | -| Read only the first 4 KB of large local files | No | Desktop `file:` implementation can perform a bounded read. | -| Read a bounded prefix from virtual/web files | Partial | Generic `workspace.fs.readFile()` returns the entire file; an efficient provider or Services API is needed. | -| Make `logPath` display-only and retain `logUri` for behavior | No | Log Analyzer/webview contract. | -| Use the `WebWorker` TypeScript library | No | Log Analyzer compiler configuration. | -| Restore regression tests | No | Log Analyzer test suite. | - -## Log Analyzer changes - -### 1. Isolate Salesforce-only behavior - -- Remove eager `initServices()` from extension activation. -- Initialize Services from Retrieve Log only. -- Cache the initialization promise to deduplicate concurrent calls. -- Remove the hard extension dependency. -- On missing or incompatible Services, offer an install/update action. -- Keep deactivation safe when Services was never initialized. -- Dynamically import the bridge so local analysis does not load Effect. - -Local log analysis, parsing, decorations, navigation, and webview display must work without Salesforce extensions installed or active. - -### 2. Own local file I/O - -Status: deferred. PR 951 continues to use Salesforce `FsService`. - -Use `vscode.workspace.fs` for Log Analyzer files: - -- read and decode text; -- create parent directories and write encoded text; -- check existence with `stat`; -- save exported files; -- open and navigate using the original `Uri`. - -Do not route local analysis through Salesforce `FsService`. This couples all file analysis to Services initialization and defeats lazy activation. - -### 3. Restore retrieval behavior - -- Build the cache URI from the workspace selected by `QuickPickWorkspace`. -- Check the cache before calling `getLogBody()`. -- Cache hit: open the local URI without downloading or writing. -- Cache miss: retrieve, validate, write, then open. -- Cache-write failure: report to the output channel but analyze the retrieved body. -- Match access-denied bodies with `/^access\s*denied$/i` after trimming. -- Test `AccessDenied`, `Access denied`, cache hit, cache miss, and write failure. - -Do not replace the 25-log default with an arbitrary huge number. That hides truncation and remains incomplete. - -### 4. Preserve multi-root consistency - -Log Analyzer can immediately ensure the selected workspace controls the cache location. It cannot make Services query that workspace's org with the current API. - -Until Services supports workspace/org targeting, choose one explicit interim behavior: - -1. Block merge to preserve existing multi-root behavior; preferred. -2. Document and enforce first-workspace-only retrieval; behavior regression. - -Never query workspace B's org and cache the result under workspace A. - -### 5. Guard the Services boundary - -- Treat extension exports as `unknown` until validated. -- Check `services`, `prebuiltServicesDependencies`, Apex log methods, and required `FsService` methods before use. -- Show an actionable incompatible-version message. -- Move the npm declaration package to `devDependencies`. -- After corrected types are published, pin or constrain to the first compatible release. - -Structural checks are an interim compatibility mechanism, not a substitute for a Services-owned API version. - -### 6. Fix URI and language detection - -- Remove the fixed `file`/`vscode-vfs`/`memfs` scheme allowlist. -- Detect active documents by content and extension. -- Let registered filesystem providers determine whether fallback reads succeed. -- Restore `resourceLangId == apexlog || lana.isApexLog` on the command-palette contribution. -- Increment a generation counter for every context update. -- Apply an async result only when its generation and URI are still current. -- Add a deferred-promise test for switching from a slow log to a fast non-log file. - -Debouncing alone does not prevent stale results. - -### 7. Preserve large-file performance - -The proposed `workspace.fs.readFile(uri)` followed by `bytes.subarray(0, 4096)` avoids decoding the full file but still reads the full file. - -Recommended behavior: - -- Desktop `file:` URI: true 4 KB read. -- Virtual/web URI: provider read with 4 KB decode; avoid repeated reads through result caching where safe. -- Future: use a bounded Services/provider read when available. - -Large-file tests should verify stale-result suppression and bounded local reads, not only content matching. - -### 8. Keep URI and display path separate - -- `logUri`: authoritative identity for fetch, open, navigation, and webview resource conversion. -- `logPath`: display text only; prefer `workspace.asRelativePath(logUri, true)`. -- Ignore webview-supplied paths for opening files; use the captured trusted URI. -- Test file and non-file URIs. - -### 9. Compiler configuration - -- Remove Node types after Node-only imports leave the shared source. -- Replace `DOM` with `WebWorker` for the web extension host. -- Keep strict TypeScript settings. - -The current source typechecks with `ES2022,WebWorker`. - -## Salesforce Services changes - -### 1. Workspace-scoped services - -Current `WorkspaceService`, `ConfigService`, and `ProjectService` resolve `workspaceFolders[0]`. Add explicit workspace inputs where behavior can vary by root. - -Required APIs: - -- workspace-aware config/default-org resolution; -- `ProjectService.getDebugLogsFolder(workspaceUri)`; -- Apex-log operations targeting a workspace, username, org, or connection. - -`ApexLogService.listLogs()` and `getLogBody()` must use the same explicit target for one retrieval flow. - -### 2. Complete log listing - -Current v67.12 behavior defaults `listLogs()` to 25 records and always emits `LIMIT`. - -Provide one of: - -- optional limit with no `LIMIT` when omitted; -- paged results with continuation; -- cursor/load-more API. - -Pagination is preferred for predictable memory and UI behavior. - -### 3. Correct published declarations - -The installed `@salesforce/vscode-services` 67.13.3 `out/index.d.ts` exports from `../../salesforcedx-vscode-services/out/src/index`, which is absent from the published package. `skipLibCheck` masks the break and weakens the consumer contract. - -Publish self-contained declarations and add a package smoke test that installs the tarball in an isolated TypeScript consumer. - -### 4. Version and capability contract - -Export an API version or capability object. Consumers need to distinguish: - -- extension missing; -- extension too old; -- required service absent; -- compatible API. - -VS Code extension dependencies do not enforce the npm declaration version or a minimum runtime API version. - -### 5. Shared runtime or Promise boundary - -Services already owns the built service context and an internal runtime. Export either: - -- the prebuilt runtime; or -- stable Promise-returning wrappers for public operations. - -This avoids every consumer bundling Effect and reconstructing a `ManagedRuntime` over the exported context. - -### 6. Optional bounded-read API - -For large virtual/web resources, consider `FsService.readFilePrefix(uri, maxBytes)` or an equivalent provider capability. This is not required for Log Analyzer's local-file fix, but it is required for efficient bounded detection across all supported schemes. - -## Review-thread disposition - -### PR 951 - -| Review topic | Disposition | Owner | -| ------------------------------------------- | ----------------------------------------------------------------------- | --------------------------- | -| Launch configuration isolation | Resolve; no code change | None | -| Hard-coded `.sfdx/tools/debug/logs` | Keep selected-workspace construction until Services accepts a workspace | Services, then Log Analyzer | -| Types package in runtime dependencies | Move to `devDependencies` | Log Analyzer | -| Runtime/declaration version drift | Structural guard now; version contract later | Both | -| Broken declaration package | Fix published package | Services | -| 25-log regression | Add pagination/optional limit, then consume it | Services, then Log Analyzer | -| Access-denied regex | Fix regex and tests | Log Analyzer | -| Cached log always downloaded | Restore existence check | Log Analyzer | -| Multi-root org mismatch | Add workspace/org target API | Services, then Log Analyzer | -| Eager activation failure | Lazy initialization and install/update UX | Log Analyzer | -| Use `workspace.fs` | Accept for Log Analyzer-owned files | Log Analyzer | -| Consumer-owned Effect runtime/bundle growth | Export runtime or Promise wrappers | Services | -| Temporary Node types | Remove in PR 952 when Node imports leave | Log Analyzer | -| `DOM` versus `WebWorker` | Use `WebWorker` | Log Analyzer | - -### PR 952 - -| Review topic | Disposition | Owner | -| ------------------------- | ------------------------------------------------------------------------ | ---------------------------------------- | -| URI-scheme checks | Remove allowlist; detect by content | Log Analyzer | -| Command always visible | Restore `when` clause | Log Analyzer | -| Full large-file read | True bounded local read; virtual fallback; Services enhancement optional | Log Analyzer; Services for full coverage | -| Async context-key race | Generation/URI guard and regression test | Log Analyzer | -| URI shown as display path | Separate display path from authoritative URI | Log Analyzer | - -## Delivery order - -Parallel tracks: - -1. Log Analyzer-only fixes: lifecycle, file I/O, cache, regex, URI detection, race, menu, display, compiler config, tests. -2. Services fixes: declarations, workspace/org targeting, pagination, capability version, shared runtime. - -Integration after a Services release: - -1. Update the Log Analyzer declaration dependency. -2. Set the minimum API capability/version. -3. Pass the selected workspace/org through every Apex-log operation. -4. Add Load More or complete listing. -5. Replace the local Effect runtime with the exported runtime/Promise boundary. -6. Run typecheck, lint, unit tests, production build, desktop extension tests, and web extension tests. - -## Acceptance criteria - -- [x] PR 951 local log analysis activates without Salesforce extensions. -- [x] Retrieve Log offers actionable install/update errors. -- [ ] Selected workspace controls both org and cache location. -- [ ] More than 25 logs are reachable without an arbitrary cap. -- [x] Cached logs are not downloaded again. -- [ ] 100 MB+ local logs are not fully read for detection. -- [ ] Switching tabs cannot publish stale `lana.isApexLog` state. -- [ ] URI-backed logs retain correct open/navigation behavior and readable titles. -- [ ] Published Services declarations typecheck in an isolated consumer. -- [ ] Log Analyzer does not bundle a second Effect runtime after the Services runtime API lands. - -## Source references - -- [Salesforce Services v67.12 `ApexLogService`](https://github.com/forcedotcom/salesforcedx-vscode/blob/v67.12.0/packages/salesforcedx-vscode-services/src/core/apexLogService.ts) -- [Salesforce Services v67.12 `ProjectService`](https://github.com/forcedotcom/salesforcedx-vscode/blob/v67.12.0/packages/salesforcedx-vscode-services/src/core/projectService.ts) -- [Salesforce Services repository](https://github.com/forcedotcom/salesforcedx-vscode) diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b58c42cb..5e44574f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,9 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import prettierConfig from 'eslint-config-prettier/flat'; import tseslint from 'typescript-eslint'; +const NO_NODE_BUILTINS = + 'lana also runs in the VS Code web extension host, where the bundler stubs these to empty modules and the failure only shows at runtime. Use the vscode API or vscode-uri Utils.'; + export default defineConfig( globalIgnores([ // agent worktrees/scratch: nested repo copies that would otherwise be @@ -15,6 +18,8 @@ export default defineConfig( '**/out/', '**/coverage/', '**/.docusaurus/', + '**/.vscode-test-web/', + '**/playwright-report/', // only TypeScript is linted; without this, `eslint .` selects js/mjs/cjs // by default and scans them with no rules '**/*.js', @@ -96,4 +101,32 @@ export default defineConfig( eqeqeq: 'warn', }, }, + { + files: ['lana/src/**/*.ts'], + ignores: [ + 'lana/src/commands/RetrieveLogFile.ts', + 'lana/src/commands/__tests__/RetrieveLogFile.test.ts', + 'lana/src/services/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + // Bare names only: a `patterns` glob would also catch our own lana/src/fs/. + paths: ['fs', 'fs/promises', 'os', 'path', 'crypto', 'child_process'].map((name) => ({ + name, + message: NO_NODE_BUILTINS, + })), + patterns: [ + { group: ['node:*'], message: NO_NODE_BUILTINS }, + { + group: ['**/services/salesforceServices*'], + message: + 'Salesforce Services is for org operations and throws until ensureServicesAvailable() has run. Use lana/src/fs/workspaceFs.ts for file I/O.', + }, + ], + }, + ], + }, + }, ); diff --git a/jest.config.js b/jest.config.js index 82d65196a..434bb33ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,11 @@ const defaultConfig = { }, ], }, - testPathIgnorePatterns: ['/node_modules/', '/out/'], + testPathIgnorePatterns: [ + '/node_modules/', + '/out/', + '/test/playwright/', + ], testMatch: ['**/?(*.)+(spec|test).ts'], extensionsToTreatAsEsm: ['.ts', '.tsx'], }; diff --git a/lana-docs/docs/docs/features/calltree.mdx b/lana-docs/docs/docs/features/calltree.mdx index ff6205d02..251fdebaa 100644 --- a/lana-docs/docs/docs/features/calltree.mdx +++ b/lana-docs/docs/docs/features/calltree.mdx @@ -62,7 +62,7 @@ Switch column sets from the **Columns** button in the toolbar (or the header rig Show or hide individual columns from the same menu; an edited view shows a **reset** icon. Choices persist per view. **Type** (the raw log event type) is hidden by default in every view and can be turned on here — names read better without it, but it sorts, groups and exports like any other column. -The view you switch to is remembered as the `lana.callTree.columnView` setting — see [Settings → Default column view](../settings.mdx#default-column-view). +The view you switch to is remembered, so the tables reopen the way you left them. ### Heap analysis diff --git a/lana-docs/docs/docs/features/governor-limits-heap.md b/lana-docs/docs/docs/features/governor-limits-heap.md index d7f4b3e2a..e267dda4f 100644 --- a/lana-docs/docs/docs/features/governor-limits-heap.md +++ b/lana-docs/docs/docs/features/governor-limits-heap.md @@ -32,12 +32,16 @@ Every limit reported by the log is tracked: SOQL queries and query rows, SOSL qu | Which call path is responsible? | The [Call Tree](./calltree.mdx#column-views) **Governor Limits** column view (average + tightest peak) | | What did this one statement cost? | The [inspector](./inspector.md)'s **Details** section | +Two denominators answer two questions, and the wording says which is in play. A `/` and "of limit" measure headroom against a limit the log reported. An "of" and "of log" measure contribution — a path's share of what the transaction consumed — which the Call Tree's governor columns and the inspector's Details use, so they read the same on every log. + ### Found vs counted The Database tab reconciles the statements found in the log against the governor-counted total. When those disagree, the difference is usually work that doesn't consume the limit — custom metadata SOQL, for example, is free unless it selects a long text area field or runs inside a Flow. Seeing both numbers means you can trust the gap instead of wondering which one is wrong. :::note -Some logs contain no `CUMULATIVE_LIMIT_USAGE` block at all. Where the log never reports a total, no limit is shown rather than a guessed one. +The log is the only source of a limit. Salesforce's own maximum varies by context — asynchronous Apex gets 60,000 ms of CPU against a synchronous transaction's 10,000, and other entry points differ again — so where the log reports no limit, none is shown rather than a guessed one. No debug level guarantees a limit block: complete logs at `APEX_PROFILING` `FINE` and `INFO` alike can carry none. + +Figures still show without one. Gauges read as levels with no bar, the Timeline strip and the inspector's trend charts scale each metric to its own peak with no 80% band or 100% line, and the governor cost columns read `—`. ::: ### Flow and Process Builder usage @@ -70,3 +74,5 @@ Because a path can consume several limits at once, the Call Tree and Analysis ta - **Gov Peak %** – the single tightest governor on that path. Hidden by default; the tooltip names which limit is the peak. Sorting by **Gov Peak** is the fastest way to find the call path that is closest to breaching something, regardless of _which_ limit it is. + +These two are where headroom is answered, so they are the columns measured against the limits. The DML, SOQL, SOSL and row columns beside them answer contribution instead: each bar is that path's share of what the whole transaction consumed, so it is comparable with the time bars in the same row and reads the same whether or not the log reported a limit. The limit still names itself in each cell's tooltip. Where the log reported none, **Gov Avg %** and **Gov Peak %** read `—` and sort last. diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index 471e8e13a..a0cf8b5e0 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -1,12 +1,13 @@ --- id: inspector title: Inspector -description: Inspect any selected Salesforce Apex log frame or statement in a dockable side bar - vitals, governor metrics, call stack, a scoped call tree and SOQL optimization tips, available from the Timeline, Call Tree, Analysis and Database tabs. +description: Inspect any selected Salesforce Apex log frame or statement in a dockable side bar - vitals, governor metrics, variables in scope, call stack, a scoped call tree and SOQL optimization tips, available from the Timeline, Call Tree, Analysis and Database tabs. keywords: [ apex log detail panel, salesforce debug log inspector, apex call stack viewer, + apex variables in scope, scoped call tree, soql optimization tips, apex log analyzer, @@ -25,13 +26,18 @@ It docks to the **right**, **left** or **bottom**, resizes by dragging its edge, ### Sections - **Details** – timing, plus every governor metric the selection consumed as `used / limit`. For SOQL also selectivity, query plan and cardinality, with the query text highlighted and copyable. +- **Variables** – what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens into the fields the log recorded for it, wherever they were set, and a field that is itself an object opens again. Every row that opens previews what is inside it with a count beside it, and the hover says whether those parts were written on one line or assembled from writes of their own. An object the log recorded nothing for reads `{}` with nothing to open. Where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Pick a row that merges calls - Aggregated, Bottom-Up or any Analysis row - and the section compares those calls instead: the names that varied lead, each opening into every value it held, how many calls held it, and whether that was one unbroken run of calls or a value that came and went. A name every call agreed on reads as it does for one frame. Hover a value to light the calls that held it in the timeline and the grids, and click to keep them lit. An object opens into its fields as it does for one frame, read as the first call that held it recorded it. Statics are left out: a static lives for the whole transaction, so it moves for reasons the row does not own. Needs the log captured with Apex Code at **FINEST**. - **Self time by namespace** – Timeline only: the self time under the selection split by the namespace whose code ran it, so you can see whose package burned it. Every namespace bar colours the six biggest and gathers the rest into one **others** segment, which names them on hover. - **Findings** – Analysis only: which of the log's findings name the selected method or anything it called, so you can tell whether the row you picked is one of the log's problems. - **Call stack** – the parent frames that led to the selection, outermost first, with total and self time. - **Call tree** – **Time Order** and **Aggregated** run from the log root through the selection's callers into what ran inside it; **Bottom-Up** ranks what ran inside it by self time. A caller holds only the time that reached the selection, so the tree reads 100% down to it and everything below is a share of it. - **SOQL issues** – SOQL only: optimization tips for the query. -Collapse a section by clicking its header, drag a divider to resize two of them, double-click a divider to restore the default sizes. It's one panel, so your layout follows you from tab to tab. +Collapse a section by clicking its header. Drag a header to reorder the stack, or press `Alt+Up` / `Alt+Down` on it. Right-click a header to choose which sections show, or to reset the list. Each list keeps its own choices — every tab, and **Detail** apart from **Summary** — because one section answers a different question in each. + +Sections that read the whole log size themselves to their content, and to no more than an equal share of the panel — they grow back towards their content only as far as the other sections leave room, and scroll inside past that, so no one section can crowd the rest down to its minimum. The ones that answer about a selection do not: **Details** and **Self time by namespace** keep a steady height, and **Variables**, **Call stack**, **Call tree** and **Findings** share what is left. Every one of those is a share of the panel, so docking wider or taller gives each section more room. So stepping from one frame to the next never resizes the stack — a section with more to say scrolls inside instead. + +Drag a divider to resize: the sections on the other side give up room in turn, each down to the same small minimum, and the divider follows the pointer until they are all there. A drag sets the size of every section, so the stack holds where you left it. Double-click a divider to hand back the sizes of the two sections beside it, or use **Reset Sections** for the whole stack. Sizes are not remembered: a size set for one log is the wrong one for the next. ### Summary diff --git a/lana-docs/docs/docs/features/timeline.mdx b/lana-docs/docs/docs/features/timeline.mdx index 47dceaf7b..2b4c32d0b 100644 --- a/lana-docs/docs/docs/features/timeline.mdx +++ b/lana-docs/docs/docs/features/timeline.mdx @@ -271,6 +271,10 @@ Click the chevron icon (◀/▼) in the top-left corner or `Shift+Click` anywher - **100% Limit Line**: Red dashed line at the limit threshold - **Breach Areas**: Purple shading for values above 100% +#### Where the log reports no limits + +A percentage needs a limit, and the log is the only source of one. Where it reports none, each metric is scaled to its own highest point instead, so the shape of usage over time still reads. The band, the 100% line, the breach shading and the traffic-light colours all mark a distance from a limit, so they come off; the collapsed strip shades by level in a single neutral grey, and the tooltip reads `770 of 1,240` rather than `770 / 1,240`. + ### Mouse Interactions | Action | Mouse | Result | diff --git a/lana-docs/docs/docs/settings.mdx b/lana-docs/docs/docs/settings.mdx index 121418d72..3816d0dee 100644 --- a/lana-docs/docs/docs/settings.mdx +++ b/lana-docs/docs/docs/settings.mdx @@ -86,10 +86,6 @@ Define your own with `lana.timeline.customThemes`. Each key is a theme name and The Call Tree colors its Name column by event category using the active timeline theme. A color chip is shown by default; enable **Colorize Call Tree category names** (`lana.callTree.categoryColorize`) under `preferences -> extensions -> Apex Log Analyzer` to tint the whole cell instead. See [Call Tree → Category Coloring](./features/calltree.mdx#category-coloring). -## Default column view - -`lana.callTree.columnView` sets the column view the Call Tree and Analysis tables open with: `General` (default), `Time`, `Governor Limits`, `Database` or `Memory`. Switching views from the **Columns** menu updates this setting, so the tables reopen the way you left them. See [Call Tree → Column Views](./features/calltree.mdx#column-views). - ## Inspector The [inspector](./features/inspector.md) remembers where you dock it and how big it is: diff --git a/lana/package.json b/lana/package.json index 2a8ff26ad..4505b61ee 100644 --- a/lana/package.json +++ b/lana/package.json @@ -31,6 +31,7 @@ ], "type": "module", "main": "out/Main.js", + "browser": "out/web/Main.web.cjs", "icon": "./certinia-icon-color.png", "galleryBanner": { "color": "#000000", @@ -52,9 +53,13 @@ "categories": [ "Other" ], + "capabilities": { + "virtualWorkspaces": true + }, "activationEvents": [ "onLanguage:apexlog", - "onStartupFinished" + "onStartupFinished", + "onFileSystem:memfs" ], "contributes": { "commands": [ @@ -138,20 +143,6 @@ "default": false, "markdownDescription": "Tint the Call Tree Name column by event category, matching the Timeline colors, instead of showing a small color chip. Default: `false`", "order": 0 - }, - "lana.callTree.columnView": { - "title": "Column view", - "type": "string", - "default": "General", - "enum": [ - "General", - "Time", - "Governor Limits", - "Database", - "Memory" - ], - "markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`", - "order": 1 } } }, @@ -378,17 +369,21 @@ ] }, "scripts": { - "vscode:prepublish": "pnpm -w run build && pnpm -w run copy:package-docs" + "build:vsix": "vsce package --no-dependencies", + "vscode:prepublish": "pnpm -w run build && pnpm -w run copy:package-docs", + "vscode:bundle": "pnpm -w run build" }, "dependencies": { - "@apexdevtools/apex-parser": "5.1.0", - "effect": "^3.22.0" + "@apexdevtools/apex-parser": "5.2.0", + "effect": "^3.22.0", + "vscode-uri": "^3.2.0" }, "devDependencies": { - "@salesforce/vscode-services": "^67.12.0", + "@salesforce/vscode-services": "^67.15.0", "@types/jest": "^30.0.0", "@types/node": "~22.20.1", "@types/vscode": "~1.102.0", + "@vscode/vsce": "^3.9.2", "typescript": "npm:@typescript/typescript6@^6.0.2" } } diff --git a/lana/src/Main.ts b/lana/src/Main.ts index fb964e909..8222bef92 100644 --- a/lana/src/Main.ts +++ b/lana/src/Main.ts @@ -12,8 +12,6 @@ export function activate(extensionContext: ExtensionContext) { context = new Context(extensionContext, new Display()); } -export async function deactivate() { +export function deactivate() { context = null; - const { disposeServices } = await import('./services/servicesRuntime.js'); - await disposeServices(); } diff --git a/lana/src/Main.web.ts b/lana/src/Main.web.ts new file mode 100644 index 000000000..b9b78a958 --- /dev/null +++ b/lana/src/Main.web.ts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import codiconCss from 'virtual:lana-codicon-css'; +import codiconFont from 'virtual:lana-codicon-font'; +import logViewerHtml from 'virtual:lana-log-viewer-html'; +import logViewerScript from 'virtual:lana-log-viewer-script'; + +import { setEmbeddedLogViewerAssets } from './display/LogViewerAssets.js'; + +setEmbeddedLogViewerAssets({ + html: logViewerHtml, + script: logViewerScript, + codiconCss, + codiconFont, +}); + +export { activate, context, deactivate } from './Main.js'; diff --git a/lana/src/__tests__/Main.test.ts b/lana/src/__tests__/Main.test.ts index c4c126840..1ef26067c 100644 --- a/lana/src/__tests__/Main.test.ts +++ b/lana/src/__tests__/Main.test.ts @@ -24,7 +24,6 @@ const mockInitServices = initServices as jest.Mock; describe('Main', () => { beforeEach(() => { jest.clearAllMocks(); - mockDisposeServices.mockResolvedValue(undefined); }); it('activates without initializing Salesforce Services', () => { @@ -37,9 +36,9 @@ describe('Main', () => { expect(mockInitServices).not.toHaveBeenCalled(); }); - it('disposes Salesforce Services during deactivation', async () => { - await deactivate(); + it('deactivates without loading the Salesforce Services chunk', () => { + deactivate(); - expect(mockDisposeServices).toHaveBeenCalledWith(); + expect(mockDisposeServices).not.toHaveBeenCalled(); }); }); diff --git a/lana/src/__tests__/helpers/test-builders.ts b/lana/src/__tests__/helpers/test-builders.ts index d6e3476ba..710660e5c 100644 --- a/lana/src/__tests__/helpers/test-builders.ts +++ b/lana/src/__tests__/helpers/test-builders.ts @@ -159,6 +159,7 @@ export function createMockApexLog(overrides: PartialApexLog = {}): ApexLog { export interface MockDisplay { output: jest.Mock; showErrorMessage: jest.Mock; + showFile: jest.Mock; showInformationMessage: jest.Mock; showWarningMessage: jest.Mock; } @@ -167,6 +168,7 @@ export function createMockDisplay(): MockDisplay { return { output: jest.fn(), showErrorMessage: jest.fn(), + showFile: jest.fn(), showInformationMessage: jest.fn(), showWarningMessage: jest.fn(), }; @@ -179,6 +181,7 @@ export interface MockContext { context: MockExtensionContext; display: MockDisplay; workspaces: { uri: { fsPath: string }; name: string }[]; + workspaceManager?: unknown; } /** diff --git a/lana/src/__tests__/mocks/vscode.ts b/lana/src/__tests__/mocks/vscode.ts index 9636105d5..9e0042484 100644 --- a/lana/src/__tests__/mocks/vscode.ts +++ b/lana/src/__tests__/mocks/vscode.ts @@ -12,6 +12,7 @@ // a drift from `@types/vscode` surfaces as ONE error at the factory, not at // every call site. import type { EndOfLine, TextDocument } from 'vscode'; +import { URI, Utils } from 'vscode-uri'; // Track subscriptions for cleanup const subscriptions: { dispose: jest.Mock }[] = []; @@ -110,36 +111,31 @@ export const ViewColumn = { } as const; export type ViewColumn = (typeof ViewColumn)[keyof typeof ViewColumn]; -// Mock Uri class +// Delegate URI semantics to vscode-uri so virtual URI tests match VS Code. export const Uri = { - file: jest.fn((path: string) => ({ - scheme: 'file', - authority: '', - path, - fsPath: path, - query: '', - fragment: '', - with: jest.fn(), - toString: jest.fn(() => `file://${path}`), - toJSON: jest.fn(() => ({ scheme: 'file', path, fsPath: path })), - })), - parse: jest.fn((value: string) => ({ - scheme: value.startsWith('file://') ? 'file' : 'unknown', - authority: '', - path: value.replace('file://', ''), - fsPath: value.replace('file://', ''), - query: '', - fragment: '', - with: jest.fn(), - toString: jest.fn(() => value), - })), - joinPath: jest.fn((base, ...pathSegments) => ({ - ...base, - path: [base.path, ...pathSegments].join('/'), - fsPath: [base.fsPath, ...pathSegments].join('/'), - })), + file: (path: string) => URI.file(path), + parse: (value: string) => URI.parse(value), + joinPath: (base: URI, ...pathSegments: string[]) => Utils.joinPath(base, ...pathSegments), }; +export class TabInputText { + readonly uri: ReturnType; + + constructor(uri: ReturnType) { + this.uri = uri; + } +} + +export class TabInputTextDiff { + readonly original: ReturnType; + readonly modified: ReturnType; + + constructor(original: ReturnType, modified: ReturnType) { + this.original = original; + this.modified = modified; + } +} + // Mock RelativePattern (constructor used for glob searches) export const RelativePattern = jest.fn(); @@ -346,6 +342,13 @@ export const window = { replace: jest.fn(), })), createWebviewPanel: jest.fn(), + tabGroups: { + activeTabGroup: { activeTab: undefined as { input: unknown } | undefined }, + all: [] as { tabs: { input: unknown }[] }[], + onDidChangeTabs: jest.fn((_listener: (event: unknown) => unknown) => ({ + dispose: jest.fn(), + })), + }, activeTextEditor: undefined as unknown, visibleTextEditors: [], onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), @@ -372,6 +375,7 @@ export const commands = { // Mock languages export const languages = { + setTextDocumentLanguage: jest.fn().mockResolvedValue(undefined), registerFoldingRangeProvider: jest.fn((_selector, _provider) => { const disposable = { dispose: jest.fn() }; subscriptions.push(disposable); @@ -541,10 +545,18 @@ export const resetMocks = (): void => { // Reset workspace folders workspace.workspaceFolders = []; + workspace.textDocuments = []; // Reset active editor window.activeTextEditor = undefined; window.visibleTextEditors = []; + window.tabGroups.activeTabGroup.activeTab = undefined; + window.tabGroups.all = []; +}; + +/** Arranges open tabs for isOpenAsTextTab; one group is enough for most tests. */ +export const setOpenTabs = (...inputs: unknown[]): void => { + window.tabGroups.all = [{ tabs: inputs.map((input) => ({ input })) }]; }; // Export as default for module replacement @@ -554,6 +566,8 @@ export default { Selection, ViewColumn, Uri, + TabInputText, + TabInputTextDiff, RelativePattern, FoldingRange, FoldingRangeKind, diff --git a/lana/src/cache/LogEventCache.ts b/lana/src/cache/LogEventCache.ts index 896ecedd6..1f07fb043 100644 --- a/lana/src/cache/LogEventCache.ts +++ b/lana/src/cache/LogEventCache.ts @@ -1,12 +1,12 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { readFile } from 'fs/promises'; -import { workspace } from 'vscode'; +import { workspace, type Uri } from 'vscode'; import { parse, type ApexLog, type LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; +import { readFileText } from '../fs/workspaceFs.js'; export interface EventSearchResult { event: LogEvent; @@ -17,17 +17,18 @@ export class LogEventCache { private static readonly MAX_CACHE_SIZE = 10; private static cache = new Map(); - static async getApexLog(filePath: string): Promise { - const cached = LogEventCache.cache.get(filePath); + static async getApexLog(uri: Uri): Promise { + const key = uri.toString(); + const cached = LogEventCache.cache.get(key); if (cached) { // Move to end (most recently used) - LogEventCache.cache.delete(filePath); - LogEventCache.cache.set(filePath, cached); + LogEventCache.cache.delete(key); + LogEventCache.cache.set(key, cached); return cached; } try { - const content = await readFile(filePath, 'utf-8'); + const content = await readFileText(uri); const apexLog = parse(content); // Evict oldest if at capacity @@ -38,7 +39,7 @@ export class LogEventCache { } } - LogEventCache.cache.set(filePath, apexLog); + LogEventCache.cache.set(key, apexLog); return apexLog; } catch { return null; @@ -49,15 +50,15 @@ export class LogEventCache { return LogEventCache.searchEvents(apexLog.children, timestamp, 0); } - static clearCache(filePath: string): void { - LogEventCache.cache.delete(filePath); + static clearCache(uriString: string): void { + LogEventCache.cache.delete(uriString); } static apply(context: Context): void { context.context.subscriptions.push( workspace.onDidCloseTextDocument((doc) => { if (doc.languageId === 'apexlog') { - LogEventCache.clearCache(doc.uri.fsPath); + LogEventCache.clearCache(doc.uri.toString()); } }), ); diff --git a/lana/src/cache/__tests__/LogEventCache.test.ts b/lana/src/cache/__tests__/LogEventCache.test.ts index 8ca68198e..4f2fa72cb 100644 --- a/lana/src/cache/__tests__/LogEventCache.test.ts +++ b/lana/src/cache/__tests__/LogEventCache.test.ts @@ -2,8 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; - -import { workspace } from 'vscode'; +import { Uri, workspace } from 'vscode'; import { createMockApexLog, @@ -12,20 +11,18 @@ import { } from '../../__tests__/helpers/test-builders.js'; import { LogEventCache } from '../LogEventCache.js'; -// Mock fs/promises -jest.mock('fs/promises', () => ({ - readFile: jest.fn(), -})); - // Mock apex-log-parser jest.mock('apex-log-parser', () => ({ parse: jest.fn(), })); import { parse } from 'apex-log-parser'; -import { readFile } from 'fs/promises'; -const mockReadFile = readFile as jest.Mock; +// The file-I/O layer is deliberately not mocked out. Stubbing the whole module is +// what let getApexLog read through a service that throws until another extension +// initialises it, with the failure swallowed by its own catch. +const mockReadFile = workspace.fs.readFile as jest.Mock; +const readsText = (text: string) => new TextEncoder().encode(text); const mockParse = parse as jest.Mock; describe('LogEventCache', () => { @@ -39,16 +36,16 @@ describe('LogEventCache', () => { describe('cache behavior', () => { it('should return cached ApexLog on subsequent calls', async () => { const mockApexLog = createMockApexLog({ size: 1000 }); - mockReadFile.mockResolvedValueOnce('log content'); + mockReadFile.mockResolvedValueOnce(readsText('log content')); mockParse.mockReturnValueOnce(mockApexLog); // First call - should read and parse - const result1 = await LogEventCache.getApexLog('/test/file.log'); + const result1 = await LogEventCache.getApexLog(Uri.file('/test/file.log')); expect(result1).toBe(mockApexLog); expect(mockReadFile).toHaveBeenCalledTimes(1); // Second call - should return cached - const result2 = await LogEventCache.getApexLog('/test/file.log'); + const result2 = await LogEventCache.getApexLog(Uri.file('/test/file.log')); expect(result2).toBe(mockApexLog); expect(mockReadFile).toHaveBeenCalledTimes(1); // Still 1 }); @@ -57,28 +54,30 @@ describe('LogEventCache', () => { const log1 = createMockApexLog({ size: 100 }); const log2 = createMockApexLog({ size: 200 }); - mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockReadFile + .mockResolvedValueOnce(readsText('content1')) + .mockResolvedValueOnce(readsText('content2')); mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); - await LogEventCache.getApexLog('/test/file1.log'); - await LogEventCache.getApexLog('/test/file2.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); + await LogEventCache.getApexLog(Uri.file('/test/file2.log')); // Access file1 again - should move to end - await LogEventCache.getApexLog('/test/file1.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); // @ts-expect-error - accessing private static for testing const keys = Array.from(LogEventCache.cache.keys()); - expect(keys).toEqual(['/test/file2.log', '/test/file1.log']); + expect(keys).toEqual(['file:///test/file2.log', 'file:///test/file1.log']); }); it('should evict oldest entry when cache reaches MAX_CACHE_SIZE', async () => { // Create 11 logs to trigger eviction (MAX_CACHE_SIZE is 10) for (let i = 0; i < 11; i++) { const mockLog = createMockApexLog({ size: i * 100 }); - mockReadFile.mockResolvedValueOnce(`content${i}`); + mockReadFile.mockResolvedValueOnce(readsText(`content${i}`)); mockParse.mockReturnValueOnce(mockLog); - await LogEventCache.getApexLog(`/test/file${i}.log`); + await LogEventCache.getApexLog(Uri.file(`/test/file${i}.log`)); } // @ts-expect-error - accessing private static for testing @@ -87,30 +86,30 @@ describe('LogEventCache', () => { // First file should be evicted // @ts-expect-error - accessing private static for testing - const hasFirst = LogEventCache.cache.has('/test/file0.log'); + const hasFirst = LogEventCache.cache.has('file:///test/file0.log'); expect(hasFirst).toBe(false); // Last file should exist // @ts-expect-error - accessing private static for testing - const hasLast = LogEventCache.cache.has('/test/file10.log'); + const hasLast = LogEventCache.cache.has('file:///test/file10.log'); expect(hasLast).toBe(true); }); it('should return null when file read fails', async () => { mockReadFile.mockRejectedValueOnce(new Error('File not found')); - const result = await LogEventCache.getApexLog('/test/nonexistent.log'); + const result = await LogEventCache.getApexLog(Uri.file('/test/nonexistent.log')); expect(result).toBeNull(); }); it('should return null when parse fails', async () => { - mockReadFile.mockResolvedValueOnce('invalid content'); + mockReadFile.mockResolvedValueOnce(readsText('invalid content')); mockParse.mockImplementationOnce(() => { throw new Error('Parse error'); }); - const result = await LogEventCache.getApexLog('/test/invalid.log'); + const result = await LogEventCache.getApexLog(Uri.file('/test/invalid.log')); expect(result).toBeNull(); }); @@ -316,41 +315,43 @@ describe('LogEventCache', () => { describe('clearCache', () => { it('should remove specific entry from cache', async () => { const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(true); - LogEventCache.clearCache('/test/file.log'); + LogEventCache.clearCache('file:///test/file.log'); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(false); }); it('should not affect other cached entries', async () => { const log1 = createMockApexLog({ size: 100 }); const log2 = createMockApexLog({ size: 200 }); - mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockReadFile + .mockResolvedValueOnce(readsText('content1')) + .mockResolvedValueOnce(readsText('content2')); mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); - await LogEventCache.getApexLog('/test/file1.log'); - await LogEventCache.getApexLog('/test/file2.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); + await LogEventCache.getApexLog(Uri.file('/test/file2.log')); - LogEventCache.clearCache('/test/file1.log'); + LogEventCache.clearCache('file:///test/file1.log'); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file1.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file1.log')).toBe(false); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file2.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file2.log')).toBe(true); }); it('should handle clearing non-existent entry gracefully', () => { expect(() => { - LogEventCache.clearCache('/test/nonexistent.log'); + LogEventCache.clearCache('file:///test/nonexistent.log'); }).not.toThrow(); }); }); @@ -368,13 +369,13 @@ describe('LogEventCache', () => { it('should clear cache when apexlog document is closed', async () => { // Setup cache const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // Capture the callback - let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = - null; + let closeCallback: + ((doc: { languageId: string; uri: { toString: () => string } }) => void) | null = null; (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { closeCallback = cb; return { dispose: jest.fn() }; @@ -386,23 +387,23 @@ describe('LogEventCache', () => { // Simulate closing an apexlog document closeCallback!({ languageId: 'apexlog', - uri: { fsPath: '/test/file.log' }, + uri: { toString: () => 'file:///test/file.log' }, }); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(false); }); it('should not clear cache when non-apexlog document is closed', async () => { // Setup cache const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // Capture the callback - let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = - null; + let closeCallback: + ((doc: { languageId: string; uri: { toString: () => string } }) => void) | null = null; (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { closeCallback = cb; return { dispose: jest.fn() }; @@ -414,11 +415,11 @@ describe('LogEventCache', () => { // Simulate closing a non-apexlog document closeCallback!({ languageId: 'javascript', - uri: { fsPath: '/test/file.log' }, + uri: { toString: () => 'file:///test/file.log' }, }); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(true); }); }); }); diff --git a/lana/src/codelenses/ShowAnalysisCodeLens.ts b/lana/src/codelenses/ShowAnalysisCodeLens.ts index f215820fa..d1a74311b 100644 --- a/lana/src/codelenses/ShowAnalysisCodeLens.ts +++ b/lana/src/codelenses/ShowAnalysisCodeLens.ts @@ -1,6 +1,7 @@ import { CodeLens, Range, languages, type CodeLensProvider, type TextDocument } from 'vscode'; import type { Context } from '../Context.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { ShowLogAnalysis } from '../commands/ShowLogAnalysis.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; @@ -12,7 +13,7 @@ class ShowAnalysisCodeLens implements CodeLensProvider { } async provideCodeLenses(document: TextDocument): Promise { - if (!isApexLogContent(document)) { + if (!isOpenAsTextTab(document.uri) || !isApexLogContent(document)) { return []; } @@ -28,11 +29,7 @@ class ShowAnalysisCodeLens implements CodeLensProvider { } static apply(context: Context): void { - const docSelector = [ - { scheme: 'file', language: 'apexlog' }, - { scheme: 'file', pattern: '**/*.log' }, - { scheme: 'file', pattern: '**/*.txt' }, - ]; + const docSelector = [{ language: 'apexlog' }, { pattern: '**/*.log' }, { pattern: '**/*.txt' }]; const codeLensProviderDisposable = languages.registerCodeLensProvider( docSelector, diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index d025542fa..bdad33299 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -1,16 +1,15 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { createReadStream, existsSync } from 'fs'; -import { writeFile } from 'fs/promises'; -import { homedir } from 'os'; -import { basename, dirname, join, parse } from 'path'; import { Uri, commands, window as vscWindow, workspace, type WebviewPanel } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; +import { getEmbeddedLogViewerAssets } from '../display/LogViewerAssets.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; +import { fileOrFolderExists, readFileText, writeFileText } from '../fs/workspaceFs.js'; import { PRIVATE_SECTIONS, getColumnOverrides, @@ -32,7 +31,7 @@ interface WebViewLogFileRequest { export class LogView { private static helpUrl = 'https://certinia.github.io/debug-log-analyzer/'; private static currentPanel: WebviewPanel | undefined; - private static currentLogPath: string | undefined; + private static currentLogUri: Uri | undefined; private static pendingNavigationTimestamp: number | undefined; static getCurrentView() { @@ -40,7 +39,11 @@ export class LogView { } static getLogPath() { - return LogView.currentLogPath; + return LogView.currentLogUri ? getLogDisplayPath(LogView.currentLogUri) : undefined; + } + + static getLogUri(): Uri | undefined { + return LogView.currentLogUri; } static setPendingNavigation(timestamp: number): void { @@ -49,26 +52,32 @@ export class LogView { static async createView( context: Context, - beforeSendLog?: Promise, - logPath?: string, + beforeSendLog?: Promise, + logUri?: Uri, logData?: string, ): Promise { - const panel = WebView.apply('logFile', `Log: ${logPath ? basename(logPath) : 'Untitled'}`, [ - Uri.file(join(context.context.extensionPath, 'out')), - Uri.file(dirname(logPath || '')), + const logName = logUri ? Utils.basename(logUri) : 'Untitled'; + const logDir = logUri ? Utils.dirname(logUri) : context.context.extensionUri; + const panel = WebView.apply('logFile', `Log: ${logName}`, [ + Utils.joinPath(context.context.extensionUri, 'out'), + logDir, ]); this.currentPanel = panel; - this.currentLogPath = logPath; - - const logViewerRoot = join(context.context.extensionPath, 'out'); - const index = join(logViewerRoot, 'index.html'); - const bundleUri = panel.webview.asWebviewUri(Uri.file(join(logViewerRoot, 'bundle.js'))); - const codiconUri = panel.webview.asWebviewUri(Uri.file(join(logViewerRoot, 'codicon.css'))); - const indexSrc = await this.getFile(index); - panel.iconPath = Uri.file(join(logViewerRoot, 'certinia-icon-color.png')); - panel.webview.html = indexSrc - .replace(/bundle\.js/gi, bundleUri.toString(true)) - .replace(/codicon\.css/gi, codiconUri.toString(true)); + this.currentLogUri = logUri; + + const logViewerRoot = Utils.joinPath(context.context.extensionUri, 'out'); + panel.iconPath = Utils.joinPath(logViewerRoot, 'certinia-icon-color.png'); + const embeddedAssets = getEmbeddedLogViewerAssets(); + if (embeddedAssets) { + panel.webview.html = LogView.embedAssets(embeddedAssets); + } else { + const bundleUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'bundle.js')); + const codiconUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'codicon.css')); + const index = Utils.joinPath(logViewerRoot, 'index.html'); + panel.webview.html = (await readFileText(index)) + .replace(/bundle\.js/gi, bundleUri.toString(true)) + .replace(/codicon\.css/gi, codiconUri.toString(true)); + } // The panel keeps its context when hidden, so it is never re-created: settings // edits have to be pushed to it. Only push when the resolved payload actually @@ -90,7 +99,7 @@ export class LogView { () => { configListener.dispose(); this.currentPanel = undefined; - this.currentLogPath = undefined; + this.currentLogUri = undefined; }, undefined, context.context.subscriptions, @@ -98,27 +107,37 @@ export class LogView { panel.webview.onDidReceiveMessage( async (msg: WebViewLogFileRequest) => { + if (!isWebViewLogFileRequest(msg)) { + return; + } const { cmd, requestId, payload } = msg; switch (cmd) { case 'fetchLog': { - await beforeSendLog; - LogView.sendLog(requestId, panel, context, logPath, logData); + if (!requestId) { + break; + } + try { + // A retrieve that resolves to a body could not be cached, so send it inline. + const retrievedLog = await beforeSendLog; + await LogView.sendLog(requestId, panel, context, logUri, retrievedLog || logData); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + context.display.showErrorMessage(`Error loading logfile: ${errorMessage}`); + } break; } case 'openPath': { - const filePath = payload as string; - if (filePath) { - context.display.showFile(filePath); + if (logUri) { + context.display.showFile(logUri); } break; } case 'openType': { - const symbol = payload as string; - if (symbol) { - await OpenFileInPackage.openFileForSymbol(context, symbol); + if (typeof payload === 'string' && payload) { + await OpenFileInPackage.openFileForSymbol(context, payload); } break; } @@ -148,8 +167,8 @@ export class LogView { } case 'updateConfig': { - const { section, value } = payload as { section: string; value: unknown }; - if (section) { + if (isConfigUpdate(payload)) { + const { section, value } = payload; if ((PRIVATE_SECTIONS as readonly string[]).includes(section)) { updatePrivateSection(context.context.globalState, section, value); } else { @@ -160,20 +179,20 @@ export class LogView { } case 'saveFile': { - const { fileContent, options } = payload as { - fileContent: string; - options: { defaultFileName?: string }; - }; - - if (fileContent && options?.defaultFileName) { + if (isSaveFileRequest(payload)) { + const { fileContent, options } = payload; const defaultWorkspace = (workspace.workspaceFolders || [])[0]; - const defaultDir = defaultWorkspace?.uri.path || homedir(); const destinationFile = await vscWindow.showSaveDialog({ - defaultUri: Uri.file(join(defaultDir, options.defaultFileName)), + // With no workspace folder, let VS Code pick its own last-used location: + // the extension's install directory is wrong, and os.homedir() is a web + // polyfill that reports '/'. + defaultUri: defaultWorkspace + ? Utils.joinPath(defaultWorkspace.uri, options.defaultFileName) + : undefined, }); if (destinationFile) { - writeFile(destinationFile.fsPath, fileContent).catch((error) => { + writeFileText(destinationFile, fileContent).catch((error) => { const msg = error instanceof Error ? error.message : String(error); vscWindow.showErrorMessage(`Unable to save file: ${msg}`); }); @@ -182,18 +201,9 @@ export class LogView { break; } - case 'showError': { - const { text } = payload as { text: string }; - if (text) { - vscWindow.showErrorMessage(text); - } - break; - } - case 'goToLogLine': { - const { timestamp } = payload as { timestamp: number }; - if (timestamp && LogView.currentLogPath) { - RawLogNavigation.goToLineByTimestamp(LogView.currentLogPath, timestamp); + if (isTimestampPayload(payload) && logUri) { + await RawLogNavigation.goToLineByTimestamp(logUri, payload.timestamp); } break; } @@ -219,6 +229,7 @@ export class LogView { config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {}; config.database.sosl.columnOverrides = overrides['database.sosl.columnOverrides'] ?? {}; const columnViews = getColumnViews(context.context.globalState); + config.callTree.columnView = columnViews['callTree.columnView'] ?? 'General'; config.database.soql.columnView = columnViews['database.soql.columnView'] ?? 'General'; config.database.dml.columnView = columnViews['database.dml.columnView'] ?? 'General'; config.database.sosl.columnView = columnViews['database.sosl.columnView'] ?? 'General'; @@ -226,36 +237,45 @@ export class LogView { return config; } - private static async getFile(filePath: string): Promise { - let data = ''; - return new Promise((resolve, reject) => { - createReadStream(filePath) - .on('error', (error) => { - reject(error); - }) - .on('data', (row) => { - data += row; - }) - .on('end', () => { - resolve(data); - }); - }); + private static embedAssets( + assets: NonNullable>, + ): string { + const fontData = `data:font/ttf;base64,${assets.codiconFont}`; + const codiconCss = assets.codiconCss + .replace(/url\((['"]?)\.\/codicon\.ttf[^)]*\)/i, `url("${fontData}")`) + .replace(/<\/style/gi, '<\\/style'); + const script = assets.script.replace(/<\/script/gi, '<\\/script'); + + return assets.html + .replace( + /]*\bid="vscode-codicon-stylesheet")[^>]*>/i, + () => ``, + ) + .replace( + /]*\bsrc="bundle\.js")[^>]*><\/script>/i, + () => ``, + ); } - private static sendLog( + private static async sendLog( requestId: string, panel: WebviewPanel, context: Context, - logFilePath?: string, + logUri?: Uri, logData?: string, ) { - if (!logData && !existsSync(logFilePath || '')) { - context.display.showErrorMessage('Log file could not be found.', { - modal: true, - }); + // Caching can fail, so only advertise a URI the webview and navigation can read. + const cachedUri = logUri && (await fileOrFolderExists(logUri)) ? logUri : undefined; + if (!cachedUri) { + LogView.currentLogUri = undefined; + if (!logData) { + context.display.showErrorMessage('Log file could not be found.', { + modal: true, + }); + return; + } } - const filePath = parse(logFilePath || ''); const navigateToTimestamp = LogView.pendingNavigationTimestamp; LogView.pendingNavigationTimestamp = undefined; @@ -263,12 +283,69 @@ export class LogView { requestId, cmd: 'fetchLog', payload: { - logName: filePath.base, - logUri: logFilePath ? panel.webview.asWebviewUri(Uri.file(logFilePath)).toString(true) : '', - logPath: logFilePath, + logName: logUri ? Utils.basename(logUri) : '', + logUri: cachedUri ? panel.webview.asWebviewUri(cachedUri).toString(true) : '', + logPath: cachedUri ? getLogDisplayPath(cachedUri) : undefined, logData: logData, navigateToTimestamp, }, }); } } + +function getLogDisplayPath(logUri: Uri): string { + return ( + workspace.asRelativePath(logUri, true) || + (logUri.scheme === 'file' ? logUri.fsPath : logUri.path) + ); +} + +function isWebViewLogFileRequest(value: unknown): value is WebViewLogFileRequest { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).cmd === 'string' && + ((value as Record).requestId === undefined || + typeof (value as Record).requestId === 'string') + ); +} + +function isConfigUpdate(value: unknown): value is { section: string; value: unknown } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).section === 'string' && + Boolean((value as Record).section) + ); +} + +function isSaveFileRequest( + value: unknown, +): value is { fileContent: string; options: { defaultFileName: string } } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const payload = value as Record; + const options = payload.options; + return ( + typeof payload.fileContent === 'string' && + Boolean(payload.fileContent) && + typeof options === 'object' && + options !== null && + !Array.isArray(options) && + typeof (options as Record).defaultFileName === 'string' && + Boolean((options as Record).defaultFileName) + ); +} + +function isTimestampPayload(value: unknown): value is { timestamp: number } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).timestamp === 'number' && + Number.isFinite((value as Record).timestamp) + ); +} diff --git a/lana/src/commands/RetrieveLogFile.ts b/lana/src/commands/RetrieveLogFile.ts index 656e12ad9..ef8c9541e 100644 --- a/lana/src/commands/RetrieveLogFile.ts +++ b/lana/src/commands/RetrieveLogFile.ts @@ -1,7 +1,6 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { join } from 'path'; import { window, workspace, @@ -9,6 +8,7 @@ import { type QuickPickItem, type WebviewPanel, } from 'vscode'; +import { Utils } from 'vscode-uri'; import { appName } from '../AppSettings.js'; import type { Context } from '../Context.js'; @@ -34,6 +34,8 @@ class DebugLogItem extends Item { } export class RetrieveLogFile { + private static servicesDisposalRegistered = false; + static apply(context: Context): void { new Command('retrieveLogFile', 'Log: Retrieve Apex Log And Show Analysis', () => RetrieveLogFile.safeCommand(context), @@ -56,8 +58,19 @@ export class RetrieveLogFile { return; } - const workspacePath = workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) { + // Disposal is registered here, not in deactivate(), so shutdown never loads this chunk + // when the command was not used. + if (!RetrieveLogFile.servicesDisposalRegistered) { + RetrieveLogFile.servicesDisposalRegistered = true; + context.context.subscriptions.push({ + dispose: () => { + salesforceServices.disposeServices().catch(() => {}); + }, + }); + } + + const workspaceFolder = workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { throw new Error('No workspace selected'); } const loadingPicker = RetrieveLogFile.showLoadingPicker(); @@ -65,27 +78,32 @@ export class RetrieveLogFile { const logFiles = await salesforceServices.listLogs(); const logFileId = await RetrieveLogFile.getLogFile(logFiles); if (logFileId) { - const logFilePath = join( - workspacePath, + const logUri = Utils.joinPath( + workspaceFolder.uri, '.sfdx', 'tools', 'debug', 'logs', `${logFileId}.log`, ); - if (await salesforceServices.fileOrFolderExists(logFilePath)) { - return LogView.createView(context, Promise.resolve(), logFilePath); + if (await salesforceServices.fileOrFolderExists(logUri)) { + return LogView.createView(context, Promise.resolve(), logUri); } - const logData = await salesforceServices.getLogBody(logFileId); - this.assertRetrievedLog(logFileId, logData); - try { - await salesforceServices.writeFile(logFilePath, logData); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - context.display.output(`Unable to cache retrieved log: ${message}`, true); - } - return LogView.createView(context, undefined, logFilePath, logData); + // Open the panel first and retrieve behind it. The body only crosses the webview + // message channel when it could not be cached, so the webview streams it from disk. + const retrieveLog = (async (): Promise => { + const logData = await salesforceServices.getLogBody(logFileId); + RetrieveLogFile.assertRetrievedLog(logFileId, logData); + try { + await salesforceServices.writeFile(logUri, logData); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + context.display.output(`Unable to cache retrieved log: ${message}`, true); + return logData; + } + })(); + return LogView.createView(context, retrieveLog, logUri); } } finally { loadingPicker.dispose(); diff --git a/lana/src/commands/ShowInLogAnalysis.ts b/lana/src/commands/ShowInLogAnalysis.ts index 7a87a6bcf..b1a9879a6 100644 --- a/lana/src/commands/ShowInLogAnalysis.ts +++ b/lana/src/commands/ShowInLogAnalysis.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { window } from 'vscode'; +import { Uri, window } from 'vscode'; import type { Context } from '../Context.js'; import { Command } from './Command.js'; @@ -30,21 +30,21 @@ export class ShowInLogAnalysis { } const panel = LogView.getCurrentView(); - const logPath = LogView.getLogPath(); + const currentLogUri = LogView.getLogUri(); // If panel doesn't exist, open the log analysis view first if (!panel) { const activeEditor = window.activeTextEditor; - const logFilePath = filePath ?? activeEditor?.document.uri.fsPath; + const logUri = filePath ? Uri.parse(filePath) : activeEditor?.document.uri; - if (!logFilePath) { + if (!logUri) { context.display.showInformationMessage('No active Apex log file.'); return; } // Set pending navigation so it's sent after log is parsed LogView.setPendingNavigation(timestamp); - await LogView.createView(context, Promise.resolve(), logFilePath); + await LogView.createView(context, Promise.resolve(), logUri); return; // Navigation will happen via fetchLog payload } else { // Panel exists - reveal it first @@ -52,10 +52,14 @@ export class ShowInLogAnalysis { // Verify we're navigating to the same log const activeEditor = window.activeTextEditor; - if (logPath && activeEditor && activeEditor.document.uri.fsPath !== logPath) { + if ( + currentLogUri && + activeEditor && + activeEditor.document.uri.toString() !== currentLogUri.toString() + ) { // Different log file is active, open the active one LogView.setPendingNavigation(timestamp); - await LogView.createView(context, Promise.resolve(), activeEditor.document.uri.fsPath); + await LogView.createView(context, Promise.resolve(), activeEditor.document.uri); return; // Navigation will happen via fetchLog payload } } diff --git a/lana/src/commands/ShowLogAnalysis.ts b/lana/src/commands/ShowLogAnalysis.ts index 1f9028968..c6cb77269 100644 --- a/lana/src/commands/ShowLogAnalysis.ts +++ b/lana/src/commands/ShowLogAnalysis.ts @@ -1,12 +1,11 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { existsSync } from 'fs'; -import type { Uri } from 'vscode'; -import { window } from 'vscode'; +import { TabInputText, window, type Uri } from 'vscode'; import { appName } from '../AppSettings.js'; import type { Context } from '../Context.js'; +import { fileOrFolderExists } from '../fs/workspaceFs.js'; import { Command } from './Command.js'; import { LogView } from './LogView.js'; @@ -33,12 +32,13 @@ export class ShowLogAnalysis { } private static async command(context: Context, uri: Uri): Promise { - const filePath = uri?.fsPath || window?.activeTextEditor?.document.fileName || ''; - const fileContent = !existsSync(filePath) ? window?.activeTextEditor?.document.getText() : ''; + const activeTab = window.tabGroups.activeTabGroup.activeTab; + const logUri = + uri || + window.activeTextEditor?.document.uri || + (activeTab?.input instanceof TabInputText ? activeTab.input.uri : undefined); - if (filePath || fileContent) { - LogView.createView(context, Promise.resolve(), filePath, fileContent); - } else { + if (!logUri) { context.display.showErrorMessage( 'No file selected or the file is too large. Try again using the file explorer or text editor command.', ); @@ -46,5 +46,10 @@ export class ShowLogAnalysis { 'No file selected or the file is too large. Try again using the file explorer or text editor command.', ); } + + const fileContent = (await fileOrFolderExists(logUri)) + ? undefined + : window.activeTextEditor?.document.getText(); + await LogView.createView(context, Promise.resolve(), logUri, fileContent); } } diff --git a/lana/src/commands/__tests__/LogView.test.ts b/lana/src/commands/__tests__/LogView.test.ts new file mode 100644 index 000000000..07c41d434 --- /dev/null +++ b/lana/src/commands/__tests__/LogView.test.ts @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { afterEach, describe, expect, it } from '@jest/globals'; + +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { Uri, workspace } from '../../__tests__/mocks/vscode.js'; +import { setEmbeddedLogViewerAssets } from '../../display/LogViewerAssets.js'; +import { WebView } from '../../display/WebView.js'; +import { LogView } from '../LogView.js'; + +jest.mock('../../display/WebView.js', () => ({ + WebView: { apply: jest.fn() }, +})); +jest.mock('../../workspace/AppConfig.js', () => ({ + PRIVATE_SECTIONS: [], + getColumnOverrides: jest.fn(() => ({})), + getColumnViews: jest.fn(() => ({})), + getConfig: jest.fn(() => ({ + timeline: {}, + callTree: { columnOverrides: {} }, + database: { + soql: { columnView: 'General', columnOverrides: {} }, + dml: { columnView: 'General', columnOverrides: {} }, + sosl: { columnView: 'General', columnOverrides: {} }, + }, + inspector: {}, + })), + getInspectorState: jest.fn(() => ({})), + sameConfig: jest.fn(() => true), + updateConfig: jest.fn(), + updatePrivateSection: jest.fn(), +})); + +const mockApplyWebView = WebView.apply as jest.Mock; +const mockReadFile = workspace.fs.readFile as unknown as jest.Mock; + +describe('LogView', () => { + afterEach(() => { + setEmbeddedLogViewerAssets(undefined); + }); + + it('uses a display path in the payload and the captured URI for open actions', async () => { + let receiveMessage: ((message: unknown) => Promise) | undefined; + const postMessage = jest.fn().mockResolvedValue(true); + const panel = { + iconPath: undefined, + onDidDispose: jest.fn(() => ({ dispose: jest.fn() })), + reveal: jest.fn(), + webview: { + asWebviewUri: jest.fn((uri: { path: string }) => Uri.parse(`webview:${uri.path}`)), + html: '', + onDidReceiveMessage: jest.fn((listener: (message: unknown) => Promise) => { + receiveMessage = listener; + return { dispose: jest.fn() }; + }), + postMessage, + }, + }; + mockApplyWebView.mockReturnValue(panel as unknown as import('vscode').WebviewPanel); + setEmbeddedLogViewerAssets({ + html: '', + script: 'const replacementToken = "$&"; globalThis.viewerLoaded = true;', + codiconCss: '@font-face { src: url("./codicon.ttf?hash") format("truetype"); } /* $& */', + codiconFont: 'Zm9udA==', + }); + workspace.asRelativePath.mockReturnValue('workspace/logs/virtual.log'); + const context = createMockContext(); + const logUri = Uri.parse('memfs:/repository/logs/virtual.log'); + + await LogView.createView( + context as unknown as import('../../Context.js').Context, + Promise.resolve(), + logUri, + 'log body', + ); + expect(panel.webview.html).toContain( + ''), + ); + + await LogView.createView(createMockContext() as unknown as import('../../Context.js').Context); + + expect(mockReadFile).toHaveBeenCalledWith(Uri.parse('file:///test/extension/out/index.html')); + expect(panel.webview.html).toContain('webview:/test/extension/out/bundle.js'); + expect(panel.webview.html).not.toContain('src="bundle.js"'); + }); +}); diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts index 4d0e1f6d6..690b9f52f 100644 --- a/lana/src/commands/__tests__/RetrieveLogFile.test.ts +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -75,6 +75,11 @@ const log = (id: string, startTime = '2024-01-01T00:00:00.000Z', durationMillise Status: 'Success', }); +/** The deferred retrieve handed to LogView.createView as its beforeSendLog promise. */ +function retrieveLogPromise(): Promise { + return mockCreateView.mock.calls[0]?.[1] as Promise; +} + describe('RetrieveLogFile', () => { beforeEach(() => { jest.clearAllMocks(); @@ -120,18 +125,19 @@ describe('RetrieveLogFile', () => { RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); + expect(mockCreateView).toHaveBeenCalledWith( + context, + expect.any(Promise), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), + ); + // A cached log is streamed from disk, so the body is never sent to the webview. + await expect(retrieveLogPromise()).resolves.toBeUndefined(); expect(mockGetLogBody).toHaveBeenCalledWith('selected-log'); expect(mockFileOrFolderExists).toHaveBeenCalledWith( - expect.stringContaining('selected-log.log'), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), ); expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('selected-log.log'), - 'log body', - ); - expect(mockCreateView).toHaveBeenCalledWith( - context, - undefined, - expect.stringContaining('selected-log.log'), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), 'log body', ); }); @@ -149,11 +155,11 @@ describe('RetrieveLogFile', () => { await command()(); expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('/test/first-workspace'), + expect.objectContaining({ path: expect.stringContaining('/test/first-workspace') }), 'log body', ); expect(mockWriteFile).not.toHaveBeenCalledWith( - expect.stringContaining('/test/second-workspace'), + expect.objectContaining({ path: expect.stringContaining('/test/second-workspace') }), expect.anything(), ); }); @@ -171,7 +177,7 @@ describe('RetrieveLogFile', () => { expect(mockCreateView).toHaveBeenCalledWith( context, expect.any(Promise), - expect.stringContaining('cached-log.log'), + expect.objectContaining({ path: expect.stringContaining('cached-log.log') }), ); }); @@ -186,14 +192,20 @@ describe('RetrieveLogFile', () => { expect(context.display.showErrorMessage).not.toHaveBeenCalled(); }); - it('still opens a retrieved log when cache writing fails', async () => { + it('sends the log body inline when cache writing fails', async () => { mockListLogs.mockResolvedValue([log('selected-log')]); mockPick.mockResolvedValue([{ logId: 'selected-log' }]); mockWriteFile.mockRejectedValue(new Error('read-only workspace')); const context = createMockContext(); RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); + expect(mockCreateView).toHaveBeenCalled(); + await expect(retrieveLogPromise()).resolves.toBe('log body'); + expect(context.display.output).toHaveBeenCalledWith( + expect.stringContaining('Unable to cache retrieved log'), + true, + ); }); it('sorts logs newest first before presenting them', async () => { @@ -244,9 +256,7 @@ describe('RetrieveLogFile', () => { const context = createMockContext(); RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); - expect(context.display.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Salesforce denied access'), - ); + await expect(retrieveLogPromise()).rejects.toThrow('Salesforce denied access'); }, ); }); diff --git a/lana/src/decorations/RawLogLineDecoration.ts b/lana/src/decorations/RawLogLineDecoration.ts index daf8d264a..1bca5ce01 100644 --- a/lana/src/decorations/RawLogLineDecoration.ts +++ b/lana/src/decorations/RawLogLineDecoration.ts @@ -15,6 +15,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { buildMetricParts, formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; @@ -88,9 +89,14 @@ export class RawLogLineDecoration { } const timestamp = parseInt(match[1], 10); - const filePath = document.uri.fsPath; + const filePath = document.uri.toString(); - const apexLog = await LogEventCache.getApexLog(filePath); + if (!isOpenAsTextTab(document.uri)) { + this.clearDecorations(editor); + return; + } + + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { this.clearDecorations(editor); return; diff --git a/lana/src/display/Display.ts b/lana/src/display/Display.ts index 3a33b33d0..16409d793 100644 --- a/lana/src/display/Display.ts +++ b/lana/src/display/Display.ts @@ -1,7 +1,13 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { Uri, commands, window, type MessageOptions, type TextDocumentShowOptions } from 'vscode'; +import { + commands, + window, + type MessageOptions, + type TextDocumentShowOptions, + type Uri, +} from 'vscode'; import { appName } from '../AppSettings.js'; @@ -23,7 +29,7 @@ export class Display { window.showErrorMessage(s, options); } - showFile(path: string, options: TextDocumentShowOptions = {}): void { - commands.executeCommand('vscode.open', Uri.file(path.trim()), options); + showFile(uri: Uri, options: TextDocumentShowOptions = {}): void { + commands.executeCommand('vscode.open', uri, options); } } diff --git a/lana/src/display/LogViewerAssets.ts b/lana/src/display/LogViewerAssets.ts new file mode 100644 index 000000000..5b21e9b2a --- /dev/null +++ b/lana/src/display/LogViewerAssets.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +export interface EmbeddedLogViewerAssets { + html: string; + script: string; + codiconCss: string; + codiconFont: string; +} + +let embeddedAssets: EmbeddedLogViewerAssets | undefined; + +export function setEmbeddedLogViewerAssets(assets: EmbeddedLogViewerAssets | undefined): void { + embeddedAssets = assets; +} + +export function getEmbeddedLogViewerAssets(): EmbeddedLogViewerAssets | undefined { + return embeddedAssets; +} diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index ab8f6ce48..482ee7479 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { basename } from 'path'; import { Position, Selection, ViewColumn, workspace, type TextDocumentShowOptions } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; import { getMethodLine, parseApex } from '../salesforce/ApexParser/ApexSymbolLocator.js'; @@ -31,7 +31,7 @@ export class OpenFileInPackage { if (!symbolLocation.isExactMatch) { context.display.showErrorMessage( - `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${basename(uri.fsPath)}'`, + `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${Utils.basename(uri)}'`, ); } const zeroIndexedLineNumber = symbolLocation.line - 1; @@ -44,7 +44,7 @@ export class OpenFileInPackage { selection: new Selection(pos, pos), }; - context.display.showFile(uri.fsPath, options); + context.display.showFile(uri, options); } catch (err) { const message = err instanceof Error ? err.message : String(err); context.display.showErrorMessage(`Unable to open '${symbolName}': ${message}`); diff --git a/lana/src/display/QuickPickWorkspace.ts b/lana/src/display/QuickPickWorkspace.ts index 0f81f10c6..16763b750 100644 --- a/lana/src/display/QuickPickWorkspace.ts +++ b/lana/src/display/QuickPickWorkspace.ts @@ -1,32 +1,47 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { parse } from 'path'; import { window } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; +import { VSWorkspace } from '../workspace/VSWorkspace.js'; import { Item, Options, QuickPick } from './QuickPick.js'; export class QuickPickWorkspace { - static async pickOrReturn(context: Context): Promise { + static async pickOrReturn(context: Context): Promise { const workspaceFolders = context.workspaceManager.workspaceFolders; if (workspaceFolders.length > 1) { const [workspace] = await QuickPick.pick( - workspaceFolders.map((ws) => new Item(ws.name(), ws.path(), '')), + workspaceFolders.map((ws) => new Item(ws.name(), ws.uri, '')), new Options('Select a workspace:'), ); if (workspace) { - return workspace.description; + const selectedWorkspace = workspaceFolders.find((ws) => ws.uri === workspace.description); + if (!selectedWorkspace) { + throw new Error('Selected workspace not found'); + } + return selectedWorkspace; } else { throw new Error('No workspace selected'); } } else if (workspaceFolders.length === 1) { - return workspaceFolders[0]?.path() || ''; + const selectedWorkspace = workspaceFolders[0]; + if (!selectedWorkspace) { + throw new Error('No workspace available'); + } + return selectedWorkspace; } else { if (window.activeTextEditor) { - return parse(window.activeTextEditor.document.fileName).dir; + const documentUri = window.activeTextEditor.document.uri; + const folderUri = Utils.dirname(documentUri); + return new VSWorkspace({ + uri: folderUri, + name: Utils.basename(folderUri), + index: 0, + }); } else { throw new Error('No workspace selected'); } diff --git a/lana/src/display/__tests__/OpenFileInPackage.test.ts b/lana/src/display/__tests__/OpenFileInPackage.test.ts index d76f6ec3a..6a0aa871e 100644 --- a/lana/src/display/__tests__/OpenFileInPackage.test.ts +++ b/lana/src/display/__tests__/OpenFileInPackage.test.ts @@ -81,7 +81,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/force-app/MyClass.cls' }, + uri: { path: '/ws/force-app/MyClass.cls', fsPath: '/ws/force-app/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 12, character: 4, isExactMatch: true }); @@ -93,8 +93,8 @@ describe('OpenFileInPackage.openFileForSymbol', () => { ); expect(display.showErrorMessage).not.toHaveBeenCalled(); expect(display.showFile).toHaveBeenCalledTimes(1); - const [path, options] = display.showFile.mock.calls[0]; - expect(path).toBe('/ws/force-app/MyClass.cls'); + const [uri, options] = display.showFile.mock.calls[0]; + expect(uri).toEqual(expect.objectContaining({ fsPath: '/ws/force-app/MyClass.cls' })); // line is converted to zero-indexed; character used as-is expect(options.selection.start).toEqual(expect.objectContaining({ line: 11, character: 4 })); expect(options.viewColumn).toBe(-1); @@ -104,7 +104,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/MyClass.cls' }, + uri: { path: '/ws/MyClass.cls', fsPath: '/ws/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 3, isExactMatch: true }); @@ -118,7 +118,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/force-app/MyClass.cls' }, + uri: { path: '/ws/force-app/MyClass.cls', fsPath: '/ws/force-app/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 1, diff --git a/lana/src/editor/TabState.ts b/lana/src/editor/TabState.ts new file mode 100644 index 000000000..51701d42e --- /dev/null +++ b/lana/src/editor/TabState.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { TabInputText, window, type Uri } from 'vscode'; + +/** + * True when `uri` is open as a plain text tab. + * + * A URI can back a TextDocument the user is not reading: either side of a diff, + * a notebook cell, a custom editor's backing document. Those fire + * onDidOpenTextDocument and appear in workspace.textDocuments like any other + * open, so work that costs a full read and parse must be gated on this — + * diffing a log should not parse it. + * + * Deliberately not a scheme check: a scheme names the filesystem provider, not + * how the resource is shown, and a memfs: or vscode-vfs: log in a normal tab is + * a normal open. Allow-list rather than a diff deny-list so tab kinds this build + * has never seen count as "not viewing", the safe direction for a gate. + */ +export function isOpenAsTextTab(uri: Uri): boolean { + const key = uri.toString(); + for (const group of window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof TabInputText && tab.input.uri.toString() === key) { + return true; + } + } + } + return false; +} diff --git a/lana/src/editor/__tests__/TabState.test.ts b/lana/src/editor/__tests__/TabState.test.ts new file mode 100644 index 000000000..6ce194c1f --- /dev/null +++ b/lana/src/editor/__tests__/TabState.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + TabInputText, + TabInputTextDiff, + Uri, + setOpenTabs, + window, +} from '../../__tests__/mocks/vscode.js'; +import { isOpenAsTextTab } from '../TabState.js'; + +describe('isOpenAsTextTab', () => { + it('is true for a URI open as a plain text tab', () => { + const uri = Uri.file('/logs/run.log'); + setOpenTabs(new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is true when the tab is in a non-active group', () => { + const uri = Uri.file('/logs/run.log'); + window.tabGroups.all = [ + { tabs: [{ input: new TabInputText(Uri.file('/other.log')) }] }, + { tabs: [{ input: new TabInputText(uri) }] }, + ]; + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is false for a URI shown only as a diff side', () => { + const original = Uri.parse('git:/repo/run.log'); + const modified = Uri.file('/repo/run.log'); + setOpenTabs(new TabInputTextDiff(original, modified)); + + expect(isOpenAsTextTab(original)).toBe(false); + expect(isOpenAsTextTab(modified)).toBe(false); + }); + + it('is true when the same URI is open in a diff and in a normal tab', () => { + const uri = Uri.file('/repo/run.log'); + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/repo/run.log'), uri), new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is not a scheme check: a memfs log in a normal tab counts', () => { + const uri = Uri.parse('memfs:/logs/virtual.log'); + setOpenTabs(new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is false for tab kinds it does not recognise', () => { + const uri = Uri.file('/logs/run.log'); + setOpenTabs({}); + + expect(isOpenAsTextTab(uri)).toBe(false); + }); + + it('is false when no tabs are open', () => { + expect(isOpenAsTextTab(Uri.file('/logs/run.log'))).toBe(false); + }); +}); diff --git a/lana/src/folding/RawLogFoldingProvider.ts b/lana/src/folding/RawLogFoldingProvider.ts index a2998675c..decbfa8eb 100644 --- a/lana/src/folding/RawLogFoldingProvider.ts +++ b/lana/src/folding/RawLogFoldingProvider.ts @@ -17,6 +17,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { TIMESTAMP_REGEX } from '../log-utils.js'; @@ -28,8 +29,11 @@ class RawLogFoldingProvider implements FoldingRangeProvider { document: TextDocument, _context: FoldingContext, ): Promise { - const filePath = document.uri.fsPath; - const apexLog = await LogEventCache.getApexLog(filePath); + if (!isOpenAsTextTab(document.uri)) { + return []; + } + + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { return []; @@ -87,11 +91,11 @@ class RawLogFoldingProvider implements FoldingRangeProvider { * unrelated action forces a re-evaluation. */ private warmAndSignal(document: TextDocument): void { - if (document.uri.scheme !== 'file' || !isApexLogContent(document)) { + if (!isOpenAsTextTab(document.uri) || !isApexLogContent(document)) { return; } - void LogEventCache.getApexLog(document.uri.fsPath).then((apexLog) => { + void LogEventCache.getApexLog(document.uri).then((apexLog) => { if (apexLog) { this.changeEmitter.fire(); } @@ -99,17 +103,23 @@ class RawLogFoldingProvider implements FoldingRangeProvider { } static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; + const docSelector = [{ language: 'apexlog' }]; const provider = new RawLogFoldingProvider(); context.context.subscriptions.push( provider.changeEmitter, languages.registerFoldingRangeProvider(docSelector, provider), - workspace.onDidOpenTextDocument((doc) => { - provider.warmAndSignal(doc); + // Not onDidOpenTextDocument: it fires before the tab model is updated, so the + // gate would reject a legitimate open. A tab change is also the repair path — + // a folding request that lost the race is re-requested by the next fire(). + window.tabGroups.onDidChangeTabs(() => { + const editor = window.activeTextEditor; + if (editor) { + provider.warmAndSignal(editor.document); + } }), // Reopening a closed editor often re-attaches the retained document model - // without re-firing onDidOpenTextDocument, so also signal on editor activation. + // without re-firing the tab change, so also signal on editor activation. window.onDidChangeActiveTextEditor((editor) => { if (editor) { provider.warmAndSignal(editor.document); diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts index 6012b0c89..9f7313d7e 100644 --- a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -10,7 +10,12 @@ import { createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; -import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; +import { + TabInputText, + Uri, + createMockTextDocument, + setOpenTabs, +} from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; import { RawLogFoldingProvider } from '../RawLogFoldingProvider.js'; @@ -29,6 +34,8 @@ describe('RawLogFoldingProvider', () => { beforeEach(() => { provider = new RawLogFoldingProvider(); mockGetApexLog.mockReset(); + // The provider only works for a document the user has open as a text tab. + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); }); describe('provideFoldingRanges', () => { @@ -312,17 +319,20 @@ describe('RawLogFoldingProvider', () => { expect(languages.registerFoldingRangeProvider).toHaveBeenCalledTimes(1); expect(languages.registerFoldingRangeProvider).toHaveBeenCalledWith( - [{ scheme: 'file', language: 'apexlog' }], + [{ language: 'apexlog' }], expect.any(RawLogFoldingProvider), ); }); - it('should register an onDidOpenTextDocument listener', () => { + it('warms on tab changes, not on document open', () => { const mockContext = createMockContext(); RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); - expect(workspace.onDidOpenTextDocument).toHaveBeenCalledTimes(1); + // onDidOpenTextDocument fires before the tab model updates, so isOpenAsTextTab + // would reject a legitimate open. + expect(window.tabGroups.onDidChangeTabs).toHaveBeenCalledTimes(1); + expect(workspace.onDidOpenTextDocument).not.toHaveBeenCalled(); }); it('should add disposables to context subscriptions', () => { @@ -330,7 +340,7 @@ describe('RawLogFoldingProvider', () => { RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); - // emitter + folding provider registration + open listener + active-editor listener + // emitter + folding provider registration + tab listener + active-editor listener expect(mockContext.context.subscriptions.length).toBe(4); }); }); @@ -345,16 +355,22 @@ describe('RawLogFoldingProvider', () => { const registeredProvider = (languages.registerFoldingRangeProvider as jest.Mock).mock .calls[0]?.[1] as RawLogFoldingProvider; - const openHandler = (workspace.onDidOpenTextDocument as jest.Mock).mock.calls[0]?.[0] as ( - doc: unknown, + const tabsHandler = (window.tabGroups.onDidChangeTabs as jest.Mock).mock.calls[0]?.[0] as ( + event: unknown, ) => void; const activeEditorHandler = (window.onDidChangeActiveTextEditor as jest.Mock).mock .calls[0]?.[0] as (editor: unknown) => void; + // The tab handler reads the active editor rather than taking a document. + const openHandler = (doc: unknown) => { + window.activeTextEditor = { document: doc } as typeof window.activeTextEditor; + tabsHandler({}); + }; + return { registeredProvider, openHandler, activeEditorHandler }; } - const flush = () => new Promise((resolve) => setImmediate(resolve)); + const flush = () => new Promise((resolve) => queueMicrotask(resolve)); it('warms the cache and fires onDidChangeFoldingRanges when an apex log opens', async () => { const { registeredProvider, openHandler } = applyAndCapture(); @@ -366,7 +382,9 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).toHaveBeenCalledTimes(1); }); @@ -380,7 +398,9 @@ describe('RawLogFoldingProvider', () => { activeEditorHandler({ document: doc }); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).toHaveBeenCalledTimes(1); }); @@ -407,7 +427,9 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).not.toHaveBeenCalled(); }); }); diff --git a/lana/src/fs/workspaceFs.ts b/lana/src/fs/workspaceFs.ts new file mode 100644 index 000000000..f8669247d --- /dev/null +++ b/lana/src/fs/workspaceFs.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { workspace, type Uri } from 'vscode'; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * Reads a file as UTF-8 text. + * + * One decode pass, one string: no normalisation and no intermediate copy, so a + * multi-hundred-MB log does not double its peak memory here. + */ +export async function readFileText(uri: Uri): Promise { + return decoder.decode(await workspace.fs.readFile(uri)); +} + +export async function writeFileText(uri: Uri, content: string): Promise { + await workspace.fs.writeFile(uri, encoder.encode(content)); +} + +/** + * `workspace.fs` has no `exists`, so `stat` is the idiom. Any failure — missing, + * unreadable, or no provider for the scheme — answers the question every caller + * is really asking: can this be read? + */ +export async function fileOrFolderExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} diff --git a/lana/src/hovers/RawLogHoverProvider.ts b/lana/src/hovers/RawLogHoverProvider.ts deleted file mode 100644 index 8403eb510..000000000 --- a/lana/src/hovers/RawLogHoverProvider.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ -import { - Hover, - languages, - MarkdownString, - type HoverProvider, - type Position, - type ProviderResult, - type TextDocument, -} from 'vscode'; - -import { LogEventCache } from '../cache/LogEventCache.js'; -import type { Context } from '../Context.js'; -import { buildMetricParts, TIMESTAMP_REGEX } from '../log-utils.js'; - -class RawLogHoverProvider implements HoverProvider { - provideHover(document: TextDocument, position: Position): ProviderResult { - const line = document.lineAt(position.line); - const match = line.text.match(TIMESTAMP_REGEX); - - if (!match?.[1]) { - return null; - } - - const timestamp = parseInt(match[1], 10); - return this.buildHover(document.uri.fsPath, timestamp); - } - - private async buildHover(filePath: string, timestamp: number): Promise { - const args = encodeURIComponent(JSON.stringify({ timestamp, filePath })); - const commandUri = `command:lana.showInLogAnalysis?${args}`; - - const apexLog = await LogEventCache.getApexLog(filePath); - const result = apexLog ? LogEventCache.findEventByTimestamp(apexLog, timestamp) : null; - - const metricParts = result ? buildMetricParts(result.event) : []; - - const parts: string[] = []; - if (metricParts.length > 0) { - parts.push(metricParts.join(' · ')); - parts.push('---'); - } - parts.push(`[Show in Log Analysis](${commandUri})`); - - const markdown = new MarkdownString(parts.join('\n\n'), true); - markdown.isTrusted = true; - - return new Hover(markdown); - } - - static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; - - const hoverProviderDisposable = languages.registerHoverProvider( - docSelector, - new RawLogHoverProvider(), - ); - - context.context.subscriptions.push(hoverProviderDisposable); - } -} - -export { RawLogHoverProvider }; diff --git a/lana/src/language/ApexLogLanguageDetector.ts b/lana/src/language/ApexLogLanguageDetector.ts index b9a021172..0c301ea87 100644 --- a/lana/src/language/ApexLogLanguageDetector.ts +++ b/lana/src/language/ApexLogLanguageDetector.ts @@ -1,9 +1,6 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { closeSync, openSync, readSync } from 'node:fs'; -import { extname } from 'node:path'; - import { TabInputText, commands, @@ -13,6 +10,7 @@ import { type TextDocument, type Uri, } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; @@ -38,35 +36,8 @@ export function isApexLogContent(doc: TextDocument): boolean { return false; } -function isApexLogFile(fsPath: string): boolean { - let fd: number; - try { - fd = openSync(fsPath, 'r'); - } catch { - return false; - } - - try { - const buf = Buffer.alloc(4096); - const bytesRead = readSync(fd, buf, 0, 4096, 0); - const text = buf.toString('utf8', 0, bytesRead); - const lines = text.split(/\r?\n/); - - const linesToCheck = Math.min(MAX_LINES_TO_CHECK, lines.length); - for (let i = 0; i < linesToCheck; i++) { - const line = lines[i] ?? ''; - if (APEXLOG_HEADER.test(line) || EXECUTION_STARTED.test(line) || USER_INFO.test(line)) { - return true; - } - } - return false; - } finally { - closeSync(fd); - } -} - function hasDetectExtension(uri: Uri): boolean { - return DETECT_EXTENSIONS.has(extname(uri.fsPath).toLowerCase()); + return DETECT_EXTENSIONS.has(Utils.extname(uri).toLowerCase()); } function getActiveTabUri(): Uri | undefined { @@ -79,26 +50,19 @@ function getActiveTabUri(): Uri | undefined { function updateContextKey(): void { const editor = window.activeTextEditor; - if (editor && editor.document.uri.scheme === 'file') { + if (editor) { const doc = editor.document; - if (hasDetectExtension(doc.uri)) { - const detected = isApexLogContent(doc); - commands.executeCommand('setContext', 'lana.isApexLog', detected); - return; - } - commands.executeCommand('setContext', 'lana.isApexLog', false); - return; - } - - // Fallback to tab API for large files where activeTextEditor is undefined - const tabUri = getActiveTabUri(); - if (tabUri && tabUri.scheme === 'file' && hasDetectExtension(tabUri)) { - const detected = isApexLogFile(tabUri.fsPath); + const detected = hasDetectExtension(doc.uri) && isApexLogContent(doc); commands.executeCommand('setContext', 'lana.isApexLog', detected); return; } - commands.executeCommand('setContext', 'lana.isApexLog', false); + // No text document, so the only way here is a file VS Code refused to open as one. + // Sniffing it means pulling the whole file through workspace.fs, which has no ranged + // read: a full read and allocation on every tab event, over an RPC in the web host. + // Trust the extension instead and accept offering the command on a large non-Apex file. + const tabUri = getActiveTabUri(); + commands.executeCommand('setContext', 'lana.isApexLog', !!tabUri && hasDetectExtension(tabUri)); } export class ApexLogLanguageDetector { @@ -132,7 +96,7 @@ export class ApexLogLanguageDetector { } function detectAndSetLanguage(doc: TextDocument): void { - if (doc.languageId === 'apexlog' || doc.uri.scheme !== 'file') { + if (doc.languageId === 'apexlog') { return; } diff --git a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts index 1689c05b1..4b4f8a506 100644 --- a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts +++ b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts @@ -3,8 +3,17 @@ */ import { describe, expect, it } from '@jest/globals'; +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; -import { isApexLogContent } from '../ApexLogLanguageDetector.js'; +import { + TabInputText, + Uri, + commands, + languages, + window, + workspace, +} from '../../__tests__/mocks/vscode.js'; +import { ApexLogLanguageDetector, isApexLogContent } from '../ApexLogLanguageDetector.js'; describe('isApexLogContent', () => { it('should detect standard log with settings header on line 1', () => { @@ -94,3 +103,83 @@ describe('isApexLogContent', () => { expect(isApexLogContent(doc)).toBe(false); }); }); + +describe('ApexLogLanguageDetector', () => { + it.each(['log', 'txt'])('detects .%s Apex logs from arbitrary URI schemes', (extension) => { + const doc = createMockTextDocument({ + languageId: 'plaintext', + lines: ['09:45:31.888 (1000)|EXECUTION_STARTED'], + }); + Object.defineProperty(doc, 'uri', { + value: Uri.parse(`git:/repository/logs/virtual.${extension}`), + }); + workspace.textDocuments = [doc]; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(languages.setTextDocumentLanguage).toHaveBeenCalledWith(doc, 'apexlog'); + }); + + it('retains the existing extension prefilter', () => { + const doc = createMockTextDocument({ + languageId: 'plaintext', + lines: ['09:45:31.888 (1000)|EXECUTION_STARTED'], + }); + Object.defineProperty(doc, 'uri', { value: Uri.parse('git:/repository/logs/virtual.json') }); + workspace.textDocuments = [doc]; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(languages.setTextDocumentLanguage).not.toHaveBeenCalled(); + }); + + it('sets the key from the extension alone when there is no text document', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), + }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', true); + }); + + it('never reads the file when there is no text document', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), + }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(workspace.fs.readFile).not.toHaveBeenCalled(); + }); + + it('clears the key for a non-log extension in the tab fallback', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/notes.json')), + }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); + }); + + it('clears the key when the active tab is not a text tab', () => { + window.tabGroups.activeTabGroup.activeTab = { input: {} }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); + }); +}); diff --git a/lana/src/log-features/RawLogNavigation.ts b/lana/src/log-features/RawLogNavigation.ts index b14eba9b0..7ca9aec3c 100644 --- a/lana/src/log-features/RawLogNavigation.ts +++ b/lana/src/log-features/RawLogNavigation.ts @@ -1,7 +1,9 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { Selection, Uri, commands, window, workspace } from 'vscode'; +import { Selection, commands, window, type Uri } from 'vscode'; + +import { readFileText } from '../fs/workspaceFs.js'; /** * Handles navigation within raw Apex log files. @@ -15,12 +17,10 @@ export class RawLogNavigation { * @param logPath - Path to the log file * @param timestamp - Nanosecond timestamp to find (from log event) */ - public static async goToLineByTimestamp(logPath: string, timestamp: number): Promise { + public static async goToLineByTimestamp(logUri: Uri, timestamp: number): Promise { try { - const uri = Uri.file(logPath); - // Read file (no normalization - avoids doubling memory for large files) - const text = new TextDecoder().decode(await workspace.fs.readFile(uri)); + const text = await readFileText(logUri); // Find the exact timestamp pattern: (nanoseconds)| const index = text.indexOf(`(${timestamp})|`); @@ -45,7 +45,7 @@ export class RawLogNavigation { } // Open file with line selected (cursor ends up at end - VS Code limitation) - await commands.executeCommand('vscode.open', uri, { + await commands.executeCommand('vscode.open', logUri, { preview: false, selection: new Selection(lineNumber, 0, lineNumber, lineLength), }); diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts index 182337a2f..3f4013e8b 100644 --- a/lana/src/salesforce/codesymbol/SfdxProject.ts +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import path from 'path'; import { RelativePattern, type Uri, workspace } from 'vscode'; +import { Utils } from 'vscode-uri'; export interface PackageDirectory { readonly uri: Uri; @@ -44,8 +44,9 @@ export class SfdxProject { // resolved, so a rejected findFiles never leaves an empty-but-valid cache. const classIndex = new Map(); for (const uri of allUris) { - // uri.path is always '/'-separated (unlike fsPath), so posix basename is safe everywhere - const className = path.posix.basename(uri.path, '.cls').toLowerCase(); + const className = Utils.basename(uri) + .replace(/\.cls$/i, '') + .toLowerCase(); const uris = classIndex.get(className); if (uris) { uris.push(uri); diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index 563d2dfac..5c1cc6d36 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -2,6 +2,7 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; + import { SfdxProject } from './SfdxProject.js'; interface RawPackageDirectory { @@ -45,7 +46,7 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise ({ path, fsPath: path }) as Uri; +const joinPath = (base: string, ...segments: string[]): string => + [base, ...segments].join('/').replace(/\/[^/]+\/\.\.\//g, '/'); + /** Mock the workspace scan so each project file resolves to its own contents, in order. */ function mockProjectFiles(files: { uri: Uri; contents: string }[]): void { (workspace.findFiles as jest.Mock).mockResolvedValue(files.map((file) => file.uri)); @@ -30,7 +32,7 @@ describe('getProjects', () => { jest.clearAllMocks(); // Mirror the real Uri.joinPath: join segments and normalize '..' (Uri.joinPath as jest.Mock).mockImplementation((base: Uri, ...segments: string[]) => - fileUri(posix.join(base.path, ...segments)), + fileUri(joinPath(base.path, ...segments)), ); }); diff --git a/lana/src/services/salesforceServices.ts b/lana/src/services/salesforceServices.ts index 28cc2112d..e1292dc18 100644 --- a/lana/src/services/salesforceServices.ts +++ b/lana/src/services/salesforceServices.ts @@ -3,9 +3,12 @@ */ import type { Uri } from 'vscode'; -import { ensureServicesAvailable, getRuntime, getServicesApi } from './servicesRuntime.js'; +import { getRuntime, getServicesApi } from './servicesRuntime.js'; -export { ensureServicesAvailable }; +export { disposeServices, ensureServicesAvailable } from './servicesRuntime.js'; + +/** The previous LogService query set no LIMIT, so it returned a full Tooling API page. */ +const MAX_LOG_RECORDS = 2000; /* eslint-disable @typescript-eslint/naming-convention -- Salesforce API field names are case-sensitive. */ export interface ApexLogListItem { @@ -19,7 +22,7 @@ export interface ApexLogListItem { } /* eslint-enable @typescript-eslint/naming-convention */ -export function listLogs(limit = 25): Promise { +export function listLogs(limit = MAX_LOG_RECORDS): Promise { const { ApexLogService } = getServicesApi().services; return getRuntime().runPromise(ApexLogService.listLogs(limit)); } @@ -29,11 +32,6 @@ export function getLogBody(logId: string): Promise { return getRuntime().runPromise(ApexLogService.getLogBody(logId)); } -export function readFile(uri: Uri | string): Promise { - const { FsService } = getServicesApi().services; - return getRuntime().runPromise(FsService.readFile(uri)); -} - export function writeFile(uri: Uri | string, content: string): Promise { const { FsService } = getServicesApi().services; return getRuntime().runPromise(FsService.safeWriteFile(uri, content)); diff --git a/lana/src/services/servicesRuntime.ts b/lana/src/services/servicesRuntime.ts index 9ccbec1f0..ac515981d 100644 --- a/lana/src/services/servicesRuntime.ts +++ b/lana/src/services/servicesRuntime.ts @@ -88,7 +88,6 @@ export function isSalesforceServicesApi(value: unknown): value is SalesforceVSCo isObject(dependencies) && typeof getProperty(apexLogService, 'listLogs') === 'function' && typeof getProperty(apexLogService, 'getLogBody') === 'function' && - typeof getProperty(fsService, 'readFile') === 'function' && typeof getProperty(fsService, 'safeWriteFile') === 'function' && typeof getProperty(fsService, 'fileOrFolderExists') === 'function' ); diff --git a/lana/src/symbols/RawLogSymbolProvider.ts b/lana/src/symbols/RawLogSymbolProvider.ts index 5eccb8e18..3dce6ec3a 100644 --- a/lana/src/symbols/RawLogSymbolProvider.ts +++ b/lana/src/symbols/RawLogSymbolProvider.ts @@ -7,7 +7,10 @@ import { Position, Range, SymbolKind, + window, type CancellationToken, + type Disposable, + type DocumentFilter, type DocumentSymbolProvider, type TextDocument, } from 'vscode'; @@ -16,6 +19,8 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; +import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; /** @@ -25,11 +30,19 @@ import { formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; * on scroll without these symbols. */ class RawLogSymbolProvider implements DocumentSymbolProvider { + private registration: Disposable | undefined; + private lostTabModelRace = false; + async provideDocumentSymbols( document: TextDocument, _token: CancellationToken, ): Promise { - const apexLog = await LogEventCache.getApexLog(document.uri.fsPath); + if (!isOpenAsTextTab(document.uri)) { + this.lostTabModelRace = true; + return []; + } + + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { return []; @@ -91,11 +104,49 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { return symbols; } + /** + * Re-register so VS Code asks for symbols again. + * + * The only repair path there is: a DocumentSymbolProvider has no change event, + * so a request that beat the tab model would otherwise leave the Outline empty + * for the life of the editor. This is the folding provider's changeEmitter.fire(). + */ + private reregister(docSelector: DocumentFilter[]): void { + this.registration?.dispose(); + this.registration = languages.registerDocumentSymbolProvider(docSelector, this); + } + static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; + const docSelector = [{ language: 'apexlog' }]; + const provider = new RawLogSymbolProvider(); + provider.reregister(docSelector); + + // Only retry for a log now sitting in a text tab, so a rejected diff side does + // not re-register on every tab change for the rest of the session. + const repair = () => { + const document = window.activeTextEditor?.document; + const worthRetrying = + provider.lostTabModelRace && + document && + isOpenAsTextTab(document.uri) && + isApexLogContent(document); + + // Only cleared on an actual retry: a tab change that arrives before the active + // editor settles must not spend the one repair the editor event still needs. + if (worthRetrying) { + provider.lostTabModelRace = false; + provider.reregister(docSelector); + } + }; context.context.subscriptions.push( - languages.registerDocumentSymbolProvider(docSelector, new RawLogSymbolProvider()), + { dispose: () => provider.registration?.dispose() }, + // Not onDidOpenTextDocument: it fires before the tab model is updated, so the + // gate would reject a legitimate open. + window.tabGroups.onDidChangeTabs(repair), + // Reopening a closed editor often re-attaches the retained document model + // without re-firing the tab change. + window.onDidChangeActiveTextEditor(repair), ); } } diff --git a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts index f7aad575c..bec11904a 100644 --- a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts +++ b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts @@ -10,7 +10,14 @@ import { createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; -import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; +import { + TabInputText, + TabInputTextDiff, + Uri, + createMockTextDocument, + setOpenTabs, + window, +} from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; import { RawLogSymbolProvider } from '../RawLogSymbolProvider.js'; @@ -21,6 +28,7 @@ jest.mock('../../cache/LogEventCache.js', () => ({ })); const mockGetApexLog = LogEventCache.getApexLog as jest.Mock; +const APEX_LOG_LINE = '09:45:31.888 (1000)|EXECUTION_STARTED'; describe('RawLogSymbolProvider', () => { let provider: RawLogSymbolProvider; @@ -28,6 +36,8 @@ describe('RawLogSymbolProvider', () => { beforeEach(() => { provider = new RawLogSymbolProvider(); mockGetApexLog.mockReset(); + // The provider only works for a document the user has open as a text tab. + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); }); describe('provideDocumentSymbols', () => { @@ -132,19 +142,87 @@ describe('RawLogSymbolProvider', () => { expect(symbols).toEqual([]); }); + + it('returns no symbols when the tab model does not list the document', async () => { + setOpenTabs(); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + + expect(await provider.provideDocumentSymbols(doc, {} as never)).toEqual([]); + expect(mockGetApexLog).not.toHaveBeenCalled(); + }); + + it('returns no symbols for a URI shown as a diff side', async () => { + const uri = Uri.file('/test/file.log'); + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/test/file.log'), uri)); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + + expect(await provider.provideDocumentSymbols(doc, {} as never)).toEqual([]); + expect(mockGetApexLog).not.toHaveBeenCalled(); + }); }); describe('apply', () => { - it('registers a document symbol provider for apexlog', () => { + const applyProvider = () => { const mockContext = createMockContext(); - RawLogSymbolProvider.apply(mockContext as unknown as import('../../Context.js').Context); + return (languages.registerDocumentSymbolProvider as jest.Mock).mock.calls[0]?.[1] as + RawLogSymbolProvider | undefined; + }; + + const fireTabChange = () => { + const handler = (window.tabGroups.onDidChangeTabs as jest.Mock).mock.calls[0]?.[0] as ( + event: unknown, + ) => void; + handler(undefined); + }; + + it('registers a document symbol provider for apexlog', () => { + applyProvider(); expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledWith( - [{ scheme: 'file', language: 'apexlog' }], + [{ language: 'apexlog' }], expect.any(RawLogSymbolProvider), ); }); + + it('asks VS Code again once the tab model lists a log it had rejected', async () => { + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + setOpenTabs(); + const registered = applyProvider(); + + await registered?.provideDocumentSymbols(doc, {} as never); + + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(2); + }); + + it('does not re-register for a log that is still only a diff side', async () => { + const uri = Uri.file('/test/file.log'); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + setOpenTabs(); + const registered = applyProvider(); + + await registered?.provideDocumentSymbols(doc, {} as never); + + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/test/file.log'), uri)); + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); + }); + + it('does not re-register when no request was rejected', () => { + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + applyProvider(); + + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/lana/src/types/virtual.d.ts b/lana/src/types/virtual.d.ts new file mode 100644 index 000000000..b3854a86b --- /dev/null +++ b/lana/src/types/virtual.d.ts @@ -0,0 +1,19 @@ +declare module 'virtual:lana-log-viewer-html' { + const content: string; + export default content; +} + +declare module 'virtual:lana-log-viewer-script' { + const content: string; + export default content; +} + +declare module 'virtual:lana-codicon-css' { + const content: string; + export default content; +} + +declare module 'virtual:lana-codicon-font' { + const content: string; + export default content; +} diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index f33bd03dc..57d5126ac 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -49,7 +49,8 @@ export interface Config { size: number; // The rest is private globalState (see INSPECTOR_STATE_SECTIONS), not settings. collapsed: Record; - paneSizes: Record; + sectionOrder: Record; + hiddenSections: Record; visible: boolean | null; }; } @@ -122,25 +123,28 @@ export const COLUMN_OVERRIDE_SECTIONS = [ ] as const; /** - * The Database column-view presets persist privately in globalState (they are - * not registered `lana.*` settings). `callTree.columnView` stays a public - * setting. + * The column-view presets are the view a table was last left in — remembered UI + * state, not a preference — so they persist privately in globalState rather than + * as registered `lana.*` settings. */ export const COLUMN_VIEW_SECTIONS = [ + 'callTree.columnView', 'database.soql.columnView', 'database.dml.columnView', 'database.sosl.columnView', ] as const; /** - * The inspector's layout state (which sections are collapsed, their sizes, the - * call tree's view mode, whether the panel is open) is remembered UI state + * The inspector's layout state (which sections are collapsed, which it hides, + * the order they are in, whether the panel is open) is remembered UI state * rather than a preference, so it persists in globalState. Dock position and - * size stay public `lana.inspector.*` settings. + * size stay public `lana.inspector.*` settings. Section sizes are remembered + * nowhere: a section takes the space its content and the panel allow. */ export const INSPECTOR_STATE_SECTIONS = [ 'inspector.collapsed', - 'inspector.paneSizes', + 'inspector.sectionOrder', + 'inspector.hiddenSections', 'inspector.visible', ] as const; @@ -152,12 +156,16 @@ export const PRIVATE_SECTIONS = [ ] as const; type ColumnOverrides = Record; -type InspectorState = Pick; +type InspectorState = Pick< + Config['inspector'], + 'collapsed' | 'sectionOrder' | 'hiddenSections' | 'visible' +>; export function getInspectorState(globalState: Memento): InspectorState { return { collapsed: globalState.get>('inspector.collapsed', {}), - paneSizes: globalState.get>('inspector.paneSizes', {}), + sectionOrder: globalState.get>('inspector.sectionOrder', {}), + hiddenSections: globalState.get>('inspector.hiddenSections', {}), visible: globalState.get('inspector.visible', null), }; } diff --git a/lana/src/workspace/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 695e09a65..303184530 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -14,9 +14,11 @@ export class VSWorkspace { this.workspaceFolder = workspaceFolder; } - path(): string { - return this.workspaceFolder.uri.fsPath; + /** URI string for desktop and virtual web workspaces. */ + get uri(): string { + return this.workspaceFolder.uri.toString(); } + name(): string { return this.workspaceFolder.name; } diff --git a/lana/src/workspace/__tests__/AppConfig.test.ts b/lana/src/workspace/__tests__/AppConfig.test.ts index 32f71f7cb..396b4c1b2 100644 --- a/lana/src/workspace/__tests__/AppConfig.test.ts +++ b/lana/src/workspace/__tests__/AppConfig.test.ts @@ -7,7 +7,9 @@ import { workspace } from 'vscode'; import { COLUMN_OVERRIDE_SECTIONS, + COLUMN_VIEW_SECTIONS, getColumnOverrides, + getColumnViews, getConfig, sameConfig, updateColumnOverride, @@ -113,6 +115,19 @@ describe('AppConfig column overrides', () => { }); }); + describe('getColumnViews', () => { + it('reads every view preset from globalState, defaulting to General', () => { + const globalState = mockMemento({ 'callTree.columnView': 'Memory' }); + + const views = getColumnViews(globalState); + + // The Call Tree's view is remembered UI state, not a `lana.*` setting. + expect(views['callTree.columnView']).toBe('Memory'); + expect(views['database.soql.columnView']).toBe('General'); + expect(globalState.get).toHaveBeenCalledTimes(COLUMN_VIEW_SECTIONS.length); + }); + }); + describe('updateColumnOverride', () => { it('writes only to globalState', () => { const globalState = mockMemento(); diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts index 866364b07..cbcb3b45c 100644 --- a/lana/src/workspace/__tests__/VSWorkspace.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -24,9 +24,9 @@ describe('VSWorkspace', () => { vsWorkspace = new VSWorkspace(mockWorkspaceFolder); }); - describe('path', () => { - it('should return workspace folder path', () => { - expect(vsWorkspace.path()).toBe('/workspace'); + describe('uri', () => { + it('should expose the workspace folder URI', () => { + expect(vsWorkspace.workspaceFolder.uri.fsPath).toBe('/workspace'); }); }); diff --git a/lana/test/playwright/fixtures/apex-log.log b/lana/test/playwright/fixtures/apex-log.log new file mode 100644 index 000000000..700f59e32 --- /dev/null +++ b/lana/test/playwright/fixtures/apex-log.log @@ -0,0 +1,28 @@ +64.0 APEX_CODE,FINE;APEX_PROFILING,FINE;CALLOUT,FINEST;DATA_ACCESS,INFO;DB,FINEST;NBA,FINE;SYSTEM,FINE;VALIDATION,INFO;VISUALFORCE,FINE;WAVE,FINE;WORKFLOW,FINE +Execute Anonymous: AccountService.createAccountsAndContacts(); +10:29:24.6 (6297619)|USER_INFO|[EXTERNAL]|005Ea00000R6orz|first-last@example.com|(GMT-07:00) Pacific Daylight Time (America/Los_Angeles)|GMT-07:00 +10:29:24.6 (6329577)|EXECUTION_STARTED +10:29:24.6 (6337821)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex +10:29:24.6 (6957638)|SYSTEM_MODE_ENTER|false +10:29:24.6 (84434204)|METHOD_ENTRY|[5]|01pEa00000CgcGT|AccountService.AccountService() +10:29:24.6 (84539933)|SYSTEM_METHOD_ENTRY|[7]|com.salesforce.api.interop.apex.bcl.DateMethods.newInstance(Integer, Integer, Integer) +10:29:24.6 (84645761)|SYSTEM_METHOD_EXIT|[7]|com.salesforce.api.interop.apex.bcl.DateMethods.newInstance(Integer, Integer, Integer) +10:29:24.6 (84669076)|METHOD_EXIT|[5]|AccountService +10:29:24.6 (84707278)|METHOD_ENTRY|[1]|01pEa00000CgcGT|AccountService.createAccountsAndContacts() +10:29:24.6 (84756747)|METHOD_ENTRY|[11]|01pEa00000CgcGT|AccountService.getRevenue() +10:29:24.6 (84822141)|SYSTEM_METHOD_ENTRY|[120]|com.salesforce.api.interop.apex.bcl.DateMethods.today() +10:29:24.6 (84859046)|SYSTEM_METHOD_EXIT|[120]|com.salesforce.api.interop.apex.bcl.DateMethods.today() +10:29:24.6 (84869926)|METHOD_ENTRY|[120]|01pEa00000CgcGT|AccountService.getDayValue(Date) +10:29:24.6 (84890620)|SYSTEM_METHOD_ENTRY|[142]|com.salesforce.api.interop.apex.bcl.DateMethods.daysBetween(Date) +10:29:24.6 (84904980)|SYSTEM_METHOD_EXIT|[142]|com.salesforce.api.interop.apex.bcl.DateMethods.daysBetween(Date) +10:29:24.6 (84949994)|SYSTEM_METHOD_ENTRY|[1]|Math.Math() +10:29:24.6 (84998791)|SYSTEM_METHOD_EXIT|[1]|Math +10:29:24.6 (85010745)|METHOD_ENTRY|[143]||System.Math.mod(Integer, Integer) +10:29:24.6 (86075910)|METHOD_EXIT|[143]||System.Math.mod(Integer, Integer) +10:29:24.6 (86151658)|METHOD_EXIT|[120]|01pEa00000CgcGT|AccountService.getDayValue(Date) +10:29:24.6 (86198738)|SYSTEM_METHOD_ENTRY|[120]|Decimal.multiply(Decimal) +10:29:24.6 (86225666)|SYSTEM_METHOD_EXIT|[120]|Decimal.multiply(Decimal) +10:29:24.6 (86232986)|METHOD_EXIT|[11]|01pEa00000CgcGT|AccountService.getRevenue() +10:29:24.6 (86300000)|METHOD_EXIT|[1]|01pEa00000CgcGT|AccountService.createAccountsAndContacts() +10:29:24.6 (86400000)|CODE_UNIT_FINISHED|execute_anonymous_apex +10:29:24.6 (86500000)|EXECUTION_FINISHED diff --git a/lana/test/playwright/playwright.config.web.ts b/lana/test/playwright/playwright.config.web.ts new file mode 100644 index 000000000..838c88f98 --- /dev/null +++ b/lana/test/playwright/playwright.config.web.ts @@ -0,0 +1,11 @@ +import { createWebConfig } from '@salesforce/playwright-vscode-ext'; +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + ...createWebConfig({ testDir: './specs', workers: 1, fullyParallel: false }), + outputDir: '../../test-results/web', + reporter: [['html', { open: 'never', outputFolder: '../../playwright-report/web' }]], + testMatch: '**/*.web.spec.ts', +}; + +export default config; diff --git a/lana/test/playwright/specs/logAnalysis.web.spec.ts b/lana/test/playwright/specs/logAnalysis.web.spec.ts new file mode 100644 index 000000000..3a23117ff --- /dev/null +++ b/lana/test/playwright/specs/logAnalysis.web.spec.ts @@ -0,0 +1,18 @@ +import { test } from '@playwright/test'; +import { + closeWelcomeTabs, + waitForExtensionsActivated, + waitForVSCodeWorkbench, + waitForWorkspaceReady, +} from '@salesforce/playwright-vscode-ext'; + +import { assertLogAnalysisRenders, openLogAnalysis } from '../support/logAnalysis'; + +test('opens a sample log and renders its analysis in VS Code Web', async ({ page }) => { + await waitForVSCodeWorkbench(page); + await waitForWorkspaceReady(page); + await closeWelcomeTabs(page); + await waitForExtensionsActivated(page); + await openLogAnalysis(page); + await assertLogAnalysisRenders(page); +}); diff --git a/lana/test/playwright/support/logAnalysis.ts b/lana/test/playwright/support/logAnalysis.ts new file mode 100644 index 000000000..dd397e5d6 --- /dev/null +++ b/lana/test/playwright/support/logAnalysis.ts @@ -0,0 +1,28 @@ +import { expect, type Page } from '@playwright/test'; +import { + executeCommandWithCommandPalette, + hasContent, + openFileFromExplorerTree, + webviewActiveFrame, +} from '@salesforce/playwright-vscode-ext'; + +import { LOG_FILE_NAME } from './logWorkspace'; + +export const assertLogAnalysisRenders = async (page: Page): Promise => { + const analysis = await webviewActiveFrame(page, hasContent('log-viewer'), { + timeout: 60_000, + }); + + const flameChart = analysis.locator('timeline-flame-chart'); + await expect(flameChart).toBeVisible({ timeout: 30_000 }); + + await analysis.locator('vscode-tab-header').filter({ hasText: 'Call Tree' }).click(); + const callTree = analysis.locator('call-tree-view'); + await expect(callTree).toBeVisible(); + await expect(callTree.locator('.tabulator-row').first()).toBeVisible({ timeout: 30_000 }); +}; + +export const openLogAnalysis = async (page: Page): Promise => { + await openFileFromExplorerTree(page, LOG_FILE_NAME); + await executeCommandWithCommandPalette(page, 'Log: Show Apex Log Analysis'); +}; diff --git a/lana/test/playwright/support/logWorkspace.ts b/lana/test/playwright/support/logWorkspace.ts new file mode 100644 index 000000000..cd7805f0a --- /dev/null +++ b/lana/test/playwright/support/logWorkspace.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createTestWorkspace } from '@salesforce/playwright-vscode-ext'; + +import { fixtureLogPath } from './paths'; + +export const LOG_FILE_NAME = 'apex-log.log'; + +export const createLogWorkspace = async (): Promise => { + const workspaceDir = await createTestWorkspace(); + await fs.copyFile(fixtureLogPath, path.join(workspaceDir, LOG_FILE_NAME)); + return workspaceDir; +}; diff --git a/lana/test/playwright/support/paths.ts b/lana/test/playwright/support/paths.ts new file mode 100644 index 000000000..262fd59d4 --- /dev/null +++ b/lana/test/playwright/support/paths.ts @@ -0,0 +1,27 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const findRepoRoot = (startDirectory: string): string => { + let directory = startDirectory; + + while (!existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { + const parentDirectory = path.dirname(directory); + if (parentDirectory === directory) { + throw new Error(`Could not find the repository root from ${startDirectory}`); + } + directory = parentDirectory; + } + + return directory; +}; + +export const repoRoot = findRepoRoot(process.cwd()); +export const extensionRoot = path.join(repoRoot, 'lana'); +export const fixtureLogPath = path.join( + extensionRoot, + 'test', + 'playwright', + 'fixtures', + 'apex-log.log', +); +export const vscodeWebTestPath = path.join(repoRoot, '.vscode-test-web'); diff --git a/lana/test/playwright/tsconfig.json b/lana/test/playwright/tsconfig.json new file mode 100644 index 000000000..5df1edcd7 --- /dev/null +++ b/lana/test/playwright/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/lana/test/playwright/web/headlessServer.ts b/lana/test/playwright/web/headlessServer.ts new file mode 100644 index 000000000..2b5797e78 --- /dev/null +++ b/lana/test/playwright/web/headlessServer.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises'; + +import { open } from '@vscode/test-web'; + +import { createLogWorkspace } from '../support/logWorkspace'; +import { extensionRoot, vscodeWebTestPath } from '../support/paths'; + +// Lana declares Salesforce Services as an extension dependency, but VS Code Web +// needs the test server to explicitly provision it for a development extension. +const SERVICES_EXTENSION_ID = 'salesforce.salesforcedx-vscode-services'; + +const start = async (): Promise => { + const workspaceDir = await createLogWorkspace(); + const server = await open({ + browserType: 'none', + quality: 'stable', + commit: process.env.PLAYWRIGHT_WEB_VSCODE_COMMIT, + port: Number(process.env.PORT) || 3001, + printServerLog: true, + verbose: true, + extensionDevelopmentPath: extensionRoot, + extensionIds: [{ id: SERVICES_EXTENSION_ID }], + folderPath: workspaceDir, + testRunnerDataDir: vscodeWebTestPath, + }); + + const shutdown = (): void => { + server.dispose(); + void fs.rm(workspaceDir, { recursive: true, force: true }).finally(() => process.exit(0)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +}; + +void start(); diff --git a/lana/tsconfig.json b/lana/tsconfig.json index de63b3700..2bcd39cee 100644 --- a/lana/tsconfig.json +++ b/lana/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "WebWorker"], "esModuleInterop": true, "skipLibCheck": true, "target": "es2022", diff --git a/log-viewer/package.json b/log-viewer/package.json index f63643459..c5e6f4732 100644 --- a/log-viewer/package.json +++ b/log-viewer/package.json @@ -8,20 +8,20 @@ "#vscode-elements/*.js": "@vscode-elements/elements/dist/*/index.js" }, "dependencies": { - "@apexdevtools/apex-parser": "5.1.0", + "@apexdevtools/apex-parser": "5.2.0", "@lit/context": "^1.1.6", "@vscode-elements/elements": "^2.5.1", "@vscode/codicons": "^0.0.45", "antlr4": "4.13.2", "lit": "^3.3.3", - "pixi.js": "^8.19.0", + "pixi.js": "^8.20.1", "tabulator-tables": "^6.5.2" }, "devDependencies": { "@types/jest": "^30.0.0", "@types/tabulator-tables": "^6.3.6", "lightningcss": "^1.33.0", - "sass": "^1.102.0", + "sass": "^1.103.1", "typescript": "npm:@typescript/typescript6@^6.0.2" } } diff --git a/log-viewer/src/components/CallStackDetail.ts b/log-viewer/src/components/CallStackDetail.ts index a240227d0..a8b5e041d 100644 --- a/log-viewer/src/components/CallStackDetail.ts +++ b/log-viewer/src/components/CallStackDetail.ts @@ -16,7 +16,7 @@ import { soqlInlineElement } from '../features/soql/format/inlineCell.js'; import { soqlSyntaxStyles } from '../features/soql/styles/soql-syntax.css.js'; import { eventBus } from '../core/events/EventBus.js'; import { SelectionEchoGuard } from '../core/events/SelectionEchoGuard.js'; -import { LOCATED_ROW_CLASS, LocatedRowMarker, rowIndexStamper } from './locatedRow.js'; +import { LocatedRowMarker, rowIndexStamper } from './locatedRow.js'; import { globalStyles } from '../styles/global.styles.js'; import { progressColumnWidth } from '../tabulator/format/measureWidth.js'; import dataGridStyles from '../tabulator/style/DataGrid.scss'; @@ -95,10 +95,6 @@ export class CallStackDetail extends LitElement { overflow: hidden; text-overflow: ellipsis; } - /* The frame under the pointer in the tab on screen. */ - #call-stack-table .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, ]; diff --git a/log-viewer/src/components/CallTreeDetail.ts b/log-viewer/src/components/CallTreeDetail.ts index 5e87f00da..3e344b9cb 100644 --- a/log-viewer/src/components/CallTreeDetail.ts +++ b/log-viewer/src/components/CallTreeDetail.ts @@ -38,17 +38,12 @@ import dataGridStyles from '../tabulator/style/DataGrid.scss'; import './ContextMenu.js'; import type { ContextMenu } from './ContextMenu.js'; import { dispatchInspectorLocate, dispatchInspectorReveal } from './inspectorReveal.js'; -import { - LOCATED_ROW_CLASS, - LocatedRowIds, - LocatedRowMarker, - rowId, - rowIndexStamper, -} from './locatedRow.js'; +import { LocatedRowIds, LocatedRowMarker, rowId, rowIndexStamper } from './locatedRow.js'; import { PANEL_ROW_MENU_ITEMS, runPanelRowAction } from './panelRowMenu.js'; import { buildScopedCallTree, buildWholeLogCallTree, + frameEventIndexes, locatableEventIndexes, revealableEventIndex, rowIdsByPath, @@ -368,10 +363,6 @@ export class CallTreeDetail extends LitElement { overflow: hidden; text-overflow: ellipsis; } - /* The frame under the pointer in the tab on screen. */ - .table-host .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, ]; @@ -618,8 +609,8 @@ export class CallTreeDetail extends LitElement { // Selecting a real frame reveals it in the tab on screen. Aggregated and // bottom-up rows merge occurrences behind a synthetic negative id, so // revealing one would misname which occurrence was clicked; the pick marks - // every occurrence instead, and holds until it is dropped — as the Chrome - // DevTools performance panel keeps a selected group's instances lit. + // every frame the row stands for instead, and holds until it is dropped, as + // the Chrome DevTools performance panel keeps a selected group's frames lit. table.on('rowSelectionChanged', (_data, rows) => { if (this._echoGuard.suppressed) { return; @@ -632,19 +623,20 @@ export class CallTreeDetail extends LitElement { // The same aggregate a merged row in the tab itself reports, so Details // reads the same either way. Built from the row: a scoped row carries no // key, which is what the tab's own rows are read through. - const instances = locatableEventIndexes(data); - dispatchInspectorLocate(this, instances, true, { + const frames = frameEventIndexes(data); + dispatchInspectorLocate(this, frames, true, { kind: 'aggregate', - instances, + instances: locatableEventIndexes(data), + frames, calledBy: this.viewMode === 'bottom-up' ? callerOfRow(rows[0]) : undefined, }); } }); // Hovering a row marks it in the tab on screen, so the user can see where it // sits before deciding to pick it. A grouped row cannot be revealed - there is - // no one frame to jump to - but every occurrence it merges can be marked. + // no one frame to jump to - but every frame it stands for can be marked. table.on('rowMouseEnter', (_e, row) => { - dispatchInspectorLocate(this, locatableEventIndexes(row.getData() as Partial)); + dispatchInspectorLocate(this, frameEventIndexes(row.getData() as Partial)); }); table.on('rowMouseLeave', () => { dispatchInspectorLocate(this, []); @@ -797,20 +789,11 @@ export class CallTreeDetail extends LitElement { } } -/** - * The frame that called a bottom-up row's own calls: the caller shown directly - * above the row's seed. A tree parent is the callee there, so the walk runs to - * the row one level below the top. - */ +/** What a picked bottom-up row's calls were reached through: the row's own frame, + * or nothing on a top-level row, which names its own calls. */ function callerOfRow(row: RowComponent | undefined): string | undefined { - let node = row; - let parent = node?.getTreeParent(); - if (!node || !parent) { + if (!row?.getTreeParent()) { return undefined; } - for (let above = parent.getTreeParent(); above; above = parent.getTreeParent()) { - node = parent; - parent = above; - } - return (node.getData() as Partial).text; + return (row.getData() as Partial).text; } diff --git a/log-viewer/src/components/CodeBlock.ts b/log-viewer/src/components/CodeBlock.ts index 314ec5179..a78eedd3b 100644 --- a/log-viewer/src/components/CodeBlock.ts +++ b/log-viewer/src/components/CodeBlock.ts @@ -48,7 +48,6 @@ export class CodeBlock extends LitElement { pre { margin: 0; font-family: var(--lana-font-mono); - font-size: var(--lana-text-mono); white-space: pre-wrap; word-break: break-word; } diff --git a/log-viewer/src/components/ColumnSettingsController.ts b/log-viewer/src/components/ColumnSettingsController.ts new file mode 100644 index 000000000..1ada0887f --- /dev/null +++ b/log-viewer/src/components/ColumnSettingsController.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { Tabulator } from 'tabulator-tables'; + +import type { ContextMenuItem } from './ContextMenu.js'; +import { getSettings, updateSetting, type LanaSettings } from '../features/settings/Settings.js'; +import { + applyColumnView, + buildColumnMenuItems, + getColumnView, + getTableFields, + resolveColumnView, + toggleField, + type ColumnView, +} from '../tabulator/ColumnViews.js'; + +/** What a grid keeps in settings: the view on show, and the views the user edited. */ +export interface ColumnSettings { + columnView?: string; + columnOverrides?: Record; +} + +export interface ColumnSettingsOptions { + /** Settings section holding the two keys, e.g. `database.soql`. */ + section: string; + /** That same section, read off the settings object. */ + read: (settings: LanaSettings) => ColumnSettings | undefined; + /** The presets to choose from; the first is the default. */ + views: ColumnView[]; + /** Fields shown whichever view is on, e.g. the Name column. */ + alwaysVisible: string[]; + /** Every built table the view applies to. The call tree has three. */ + tables: () => Tabulator[]; +} + +/** + * Owns a grid's column view: which preset is on show, the per-view overrides the + * user edited, and both halves of that in settings. + * + * The two keys are private globalState, not registered `lana.*` settings, so + * nothing pushes a change to them and one read per host is enough. Two hosts on + * one section each read their own copy, and neither hears the other's writes. + */ +export class ColumnSettingsController implements ReactiveController { + private readonly _host: ReactiveControllerHost; + private readonly _options: ColumnSettingsOptions; + private _view: string; + private _overrides: Record = {}; + private _read: Promise | null = null; + + constructor(host: ReactiveControllerHost, options: ColumnSettingsOptions) { + this._host = host; + this._options = options; + this._view = options.views[0]!.id; + host.addController(this); + } + + hostConnected(): void { + // Once per host: a re-attach comes back to the state it left with. + this._read ??= getSettings() + .then((settings) => this._adopt(settings)) + // No extension host to ask (standalone browser): the grid keeps its defaults. + .catch(() => {}); + } + + /** The preset on show. */ + get view(): string { + return this._view; + } + + /** The presets the user has edited, which are the ones a reset applies to. */ + get editedViews(): string[] { + return Object.keys(this._overrides); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private fieldsFor(id: string): string[] | null { + return this._overrides[id] ?? getColumnView(this._options.views, id)?.fields ?? null; + } + + /** Apply the view on show to a table that has just been built. */ + applyTo(table: Tabulator): void { + applyColumnView(table, this.fieldsFor(this._view), this._options.alwaysVisible); + } + + /** Show `id` and remember it. */ + choose(id: string): void { + this._show(id); + updateSetting(`${this._options.section}.columnView`, id); + } + + /** Add or remove one column from the view on show, and remember it. */ + toggle(table: Tabulator, field: string): void { + this._overrides = { + ...this._overrides, + [this._view]: toggleField(this.fieldsFor(this._view), field, getTableFields(table)), + }; + this._apply(); + this._host.requestUpdate(); + updateSetting(`${this._options.section}.columnOverrides`, this._overrides); + } + + /** Give a view back its built-in columns. Defaults to the one on show. */ + reset(id: string = this._view): void { + if (!this._overrides[id]) { + return; + } + const { [id]: _dropped, ...rest } = this._overrides; + this._overrides = rest; + if (id === this._view) { + this._apply(); + } + this._host.requestUpdate(); + updateSetting(`${this._options.section}.columnOverrides`, this._overrides); + } + + /** The column header menu for `table`, against the state now. */ + menuItems(table: Tabulator): ContextMenuItem[] { + return buildColumnMenuItems( + table, + this._view, + this._options.views, + this._options.alwaysVisible, + this.editedViews, + ); + } + + private _adopt(settings: LanaSettings): void { + const stored = this._options.read(settings); + this._overrides = stored?.columnOverrides ?? {}; + this._show(resolveColumnView(this._options.views, stored?.columnView)); + } + + private _show(id: string): void { + this._view = id; + this._apply(); + this._host.requestUpdate(); + } + + /** + * A table that has never been laid out throws on the redraw this ends in, and a + * hidden tab leaves one that way. Such a table takes the view on its next build + * instead, through {@link applyTo}. + */ + private _apply(): void { + for (const table of this._options.tables()) { + if (table.element?.clientHeight) { + applyColumnView(table, this.fieldsFor(this._view), this._options.alwaysVisible); + } + } + } +} diff --git a/log-viewer/src/components/DetailDock.ts b/log-viewer/src/components/DetailDock.ts index 6c08518db..9ebf297d6 100644 --- a/log-viewer/src/components/DetailDock.ts +++ b/log-viewer/src/components/DetailDock.ts @@ -34,8 +34,8 @@ export class DetailDock extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - @property({ attribute: false }) - paneSizes: Record = {}; + @property({ type: Number }) + layoutEpoch = 0; static styles = [ globalStyles, @@ -140,7 +140,7 @@ export class DetailDock extends LitElement { orientation=${this.dock === 'bottom' ? 'horizontal' : 'vertical'} .sections=${this.sections} .collapsed=${this.collapsed} - .paneSizes=${this.paneSizes} + .layoutEpoch=${this.layoutEpoch} >` : html`
${this.emptyText}
` } diff --git a/log-viewer/src/components/DockLayout.ts b/log-viewer/src/components/DockLayout.ts index 51f1a04bc..de9fad61e 100644 --- a/log-viewer/src/components/DockLayout.ts +++ b/log-viewer/src/components/DockLayout.ts @@ -43,8 +43,8 @@ export class DockLayout extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - @property({ attribute: false }) - paneSizes: Record = {}; + @property({ type: Number }) + layoutEpoch = 0; // Live drag state (transient); when set, overrides `size` while dragging. @state() @@ -133,7 +133,7 @@ export class DockLayout extends LitElement { .sections=${this.sections} .emptyText=${this.emptyText} .collapsed=${this.collapsed} - .paneSizes=${this.paneSizes} + .layoutEpoch=${this.layoutEpoch} dock=${this.dock} > diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 132e28b85..41298d644 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -112,19 +112,23 @@ export class EventVitals extends LitElement { color: var(--lana-fg-muted); font-size: var(--lana-text-sm); } + /* One hue carries the verdict through a tinted ground and its edge, as + SelfTimeSpreadView tints a row from its own hue property. Every variant + sets the hue; the word reads as the text around it. */ .pill { display: inline-block; - padding: 0 var(--lana-space-sm); - border-radius: var(--lana-radius-sm); + padding: 0 var(--lana-space-2xs); + border: var(--lana-stroke) solid color-mix(in srgb, var(--pill-hue) 30%, transparent); + border-radius: var(--lana-radius-md); + background-color: color-mix(in srgb, var(--pill-hue) 12%, transparent); font-size: var(--lana-text-xs); line-height: 1.4; - color: var(--lana-editor-bg); } .pill--yes { - background-color: var(--vscode-charts-green, #388a34); + --pill-hue: var(--lana-severity-ok); } .pill--no { - background-color: var(--vscode-charts-red, #d13438); + --pill-hue: var(--lana-severity-warning); } .empty { color: var(--lana-fg-muted); @@ -231,13 +235,14 @@ export class EventVitals extends LitElement { /** * One row per metric the grids expose as columns, so hiding a column never - * hides the data. Each reads `used / limit (self: n) pct%` — the denominator - * *is* the governor limit, so usage and limit are never reported twice. - * Metrics with no transaction limit show the count alone. Zero rows are - * omitted; `self` only appears when it adds something. + * hides the data. Each reads `used of the log's total (share, limit share, self)` — the + * denominator is what the transaction consumed, the question a selection is asked, so the row + * reads on every log whether or not one reported limits. A reported limit follows as a + * qualifier. Zero rows are omitted; `self` only appears when it adds something. */ private _metricRows(rows: TemplateResult[], events: LogEvent[]): void { - const limits = this.logStore?.log.governorLimits; + const apexLog = this.logStore?.log; + const limits = apexLog?.governorLimits; // A total nests and a self reading does not, so each sums the set that holds // it once. const outer = outermostEvents(events); @@ -261,7 +266,13 @@ export class EventVitals extends LitElement { this._row( rows, metric.label, - usage(total, limit, format, self > 0 && self !== total ? format(self) : null), + usage( + total, + apexLog ? metric.pick(apexLog).total : 0, + limit, + format, + self > 0 && self !== total ? format(self) : null, + ), ); } @@ -270,7 +281,13 @@ export class EventVitals extends LitElement { this._row( rows, HEAP_PEAK.label, - usage(heapPeak, limits ? HEAP_PEAK.limit(limits) : 0, formatBytes, null), + usage( + heapPeak, + apexLog ? HEAP_PEAK.pick(apexLog) : 0, + limits ? HEAP_PEAK.limit(limits) : 0, + formatBytes, + null, + ), ); } } @@ -330,11 +347,12 @@ function qualifier(...parts: Array): Template /** {@link usageParts} as the row renders it. */ function usage( total: number, + logTotal: number, limit: number, format: (value: number) => string, self: string | null, ): TemplateResult { - const { primary, qualifiers } = usageParts(total, limit, format, self); + const { primary, qualifiers } = usageParts(total, logTotal, limit, format, self); return html`${primary}${qualifier(...qualifiers)}`; } diff --git a/log-viewer/src/components/GovernorTrends.ts b/log-viewer/src/components/GovernorTrends.ts index 53bdd72ea..ac929a724 100644 --- a/log-viewer/src/components/GovernorTrends.ts +++ b/log-viewer/src/components/GovernorTrends.ts @@ -2,7 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { consume } from '@lit/context'; -import { LitElement, css, html, svg } from 'lit'; +import { LitElement, css, html, svg, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { eventBus } from '../core/events/EventBus.js'; @@ -23,7 +23,18 @@ import { type TrendPoint, type TrendSeries, } from './governorTrendData.js'; -import { NO_CUMULATIVE_LIMITS_TEXT } from './logOverviewMetrics.js'; +import { NO_GOVERNOR_USAGE_TEXT, NO_LOG_TEXT } from './governorCopy.js'; + +/** A placed cursor: the sample, and the chart it belongs to. */ +interface Cursor { + label: string; + point: TrendPoint; +} + +/** The cursor's sample, when it is this chart's. */ +function pointOn(cursor: Cursor | null, series: TrendSeries): TrendPoint | null { + return cursor?.label === series.label ? cursor.point : null; +} /** Chart-space size; the SVG stretches to fill its row. */ const VIEW_W = 100; @@ -53,7 +64,10 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry { return cached; } - const maxRatio = Math.max(100, ...series.points.map((p) => p.ratio)); + // At least 100%, so a safe line reads as safe. The highest point is already on the series: the + // ratio peaks at `finalRatio` where a limit was reported, and at exactly 100% where the + // denominator is the metric's own peak. + const maxRatio = Math.max(100, series.limit > 0 ? series.finalRatio : 100); const x = (t: number) => (logTotal > 0 ? (t / logTotal) * VIEW_W : 0); const y = (ratio: number) => VIEW_H - (ratio / maxRatio) * VIEW_H; @@ -88,11 +102,15 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry { */ @customElement('governor-trends') export class GovernorTrends extends LitElement { - /** The sample under the pointer or the arrow keys, on the one chart that holds - * it. `from` says which placed it: a pointer leaving takes its own cursor - * with it, never one the keys placed. */ + /** The sample under the pointer, on the chart it is over. Cleared when the + * pointer leaves, so it never outlives the hover that made it. */ @state() - private _cursor: { label: string; point: TrendPoint; from: 'pointer' | 'key' } | null = null; + private _hover: Cursor | null = null; + + /** Where the arrow keys left the cursor. Held apart from the hover because a + * pointer crossing any chart would otherwise erase a stepped position. */ + @state() + private _keyed: Cursor | null = null; /** The log on screen, from the app root. */ @consume({ context: logContext, subscribe: true }) @@ -152,22 +170,24 @@ export class GovernorTrends extends LitElement { display: block; width: 100%; border: 0; - border-bottom: 1px solid var(--lana-surface-border); + border-bottom: var(--lana-stroke) solid var(--lana-surface-border); padding: 0; background: none; - color: inherit; cursor: pointer; } + /* Overflow visible: a peak-scaled series tops out at exactly 100%, putting the vertex on + y=0, where half the non-scaling stroke would fall outside the viewBox and be clipped. */ .trend__plot { display: block; width: 100%; height: 44px; + overflow: visible; } .trend__chart:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: calc(-1 * var(--lana-stroke)); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .trend--safe { @@ -180,6 +200,11 @@ export class GovernorTrends extends LitElement { color: var(--lana-severity-error); } + /* No reported limit, so no severity: the shape is a level, drawn in the muted foreground. */ + .trend--level { + color: var(--lana-fg-muted); + } + .trend__area { fill: currentColor; opacity: 0.25; @@ -213,15 +238,13 @@ export class GovernorTrends extends LitElement { render() { const apexLog = this.logStore?.log; if (!apexLog) { - return html`

No log is loaded.

`; + return html`

${NO_LOG_TEXT}

`; } const series = governorTrendSeries(apexLimitTimeSeries(apexLog)); if (!series.length) { - return html`

${NO_CUMULATIVE_LIMITS_TEXT}

`; + return html`

${NO_GOVERNOR_USAGE_TEXT}

`; } - // With no cumulative snapshots the series draws from granular events and - // the default limits — the Log overview above carries the estimated note. const logTotal = apexLog.duration.total; return html``; } @@ -231,21 +254,36 @@ export class GovernorTrends extends LitElement { const cursor = this._cursorFor(series); const cursorX = cursor ? x(cursor.t).toFixed(2) : null; - return html`
+ // No reported limit: the denominator is the metric's own peak, so the figure is spelled "of" + // rather than "/", and the guide and the tier colour — both distances from a cap — come off. + const metered = series.limit > 0; + + // Only once a cursor names a moment: the whole-log figure *is* the peak, so with no cursor the + // denominator would restate the value beside it. Cursor presence, not the value — gating on the + // value would drop the suffix across the flat tail and change the readout's width mid-scrub. + const denominator = metered + ? `/ ${series.format(series.limit)}` + : cursor + ? `of ${series.format(series.used)}` + : ''; + + return html`
${series.label} ${cursor ? html`${formatDuration(cursor.t)} · ` : ''}${series.format( cursor ? cursor.used : series.used, - )} / ${series.format(series.limit)}${denominator}
`; } + /** A cursor belongs to the log that placed it; metric labels repeat. */ + protected willUpdate(changed: PropertyValues): void { + if (changed.has('logStore')) { + this._hover = null; + this._keyed = null; + } + } + private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number): void { const point = this._pointFrom(event, series, logTotal); - this._cursor = point ? { label: series.label, point, from: 'pointer' } : null; + this._hover = point ? { label: series.label, point } : null; } - private _onPointerLeave(): void { - if (this._cursor?.from === 'pointer') { - this._cursor = null; + /** Puts the cursor where the keys left it. A resting pointer would otherwise + * answer every step from the same sample, so the keys take this chart from + * it; moving the pointer takes it back. */ + private _hold(series: TrendSeries, point: TrendPoint | null | undefined): TrendPoint | null { + if (!point) { + return null; } + this._keyed = { label: series.label, point }; + if (this._hover?.label === series.label) { + this._hover = null; + } + return point; + } + + /** + * What an activation moves to: the sample given, else this chart's cursor, + * else the last sample, since consumption never falls inside a transaction + * and that is where the metric stands highest. + */ + private _target(series: TrendSeries, placed: TrendPoint | null): TrendPoint | null { + return placed ?? this._cursorFor(series) ?? series.points.at(-1) ?? null; + } + + private _onPointerLeave(): void { + this._hover = null; } private _onClick(event: PointerEvent, series: TrendSeries, logTotal: number): void { - // Where the pointer is wins: the cursor may sit where the arrow keys left it. - const point = this._pointFrom(event, series, logTotal) ?? this._cursorFor(series); + // Where the pointer is wins, but a click carrying no coordinates (assistive + // tech, or `click()`) reports x 0, which would seek the log's start. + const placed = event.detail === 0 ? null : this._pointFrom(event, series, logTotal); + // No cursor is kept: the pointer is still on the chart and holds its own, + // and a second chart reading at once says two things at the same time. + const point = this._target(series, placed); if (point) { this._seek(point.t); } @@ -295,28 +370,33 @@ export class GovernorTrends extends LitElement { private _onKeyDown(event: KeyboardEvent, series: TrendSeries, logTotal: number): void { const step = event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : undefined; + const activates = event.key === 'Enter' || event.key === ' '; + if (step === undefined && !activates) { + return; + } + event.preventDefault(); if (step !== undefined) { const from = this._cursorFor(series)?.t ?? 0; const t = Math.min(Math.max(from + step * logTotal, 0), logTotal); - const point = pointAt(series.points, t); - if (point) { - this._cursor = { label: series.label, point, from: 'key' }; - } - event.preventDefault(); + this._hold(series, pointAt(series.points, t)); return; } - if (event.key === 'Enter' || event.key === ' ') { - const point = this._cursorFor(series) ?? series.points.at(-1); - if (point) { - this._seek(point.t); - } - event.preventDefault(); + // Arrows repeat, so holding one scrubs the cursor. An activation must not: + // it would re-zoom the flame chart on every repeat. + if (event.repeat) { + return; + } + // The cursor stays where this landed, so the arrows carry on from it. + const point = this._hold(series, this._target(series, null)); + if (point) { + this._seek(point.t); } } - /** The cursor, when this chart is the one holding it. */ + /** This chart's cursor: the pointer's while it is over the chart, else the + * keys'. */ private _cursorFor(series: TrendSeries): TrendPoint | null { - return this._cursor?.label === series.label ? this._cursor.point : null; + return pointOn(this._hover, series) ?? pointOn(this._keyed, series); } /** The series' value at the pointer, in the log's own time. */ diff --git a/log-viewer/src/components/HotPath.ts b/log-viewer/src/components/HotPath.ts index e6802a579..988911e13 100644 --- a/log-viewer/src/components/HotPath.ts +++ b/log-viewer/src/components/HotPath.ts @@ -19,6 +19,7 @@ import { import { globalStyles } from '../styles/global.styles.js'; import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; import { revealRowStyles } from '../styles/revealRow.styles.js'; +import { severityStyles } from '../styles/severity.styles.js'; import { CategoryPaletteController, categoryLabel } from './categoryTime.js'; import { dispatchInspectorLocate, dispatchInspectorReveal } from './inspectorReveal.js'; import { revealRowMeter, revealRowTitle } from './revealRowMeter.js'; @@ -58,6 +59,7 @@ export class HotPath extends LitElement { globalStyles, inspectorSectionStyles, revealRowStyles, + severityStyles, css` /* An actionable data-quality caveat, tinted so it reads apart from the rows. */ .caveat-row { @@ -66,7 +68,6 @@ export class HotPath extends LitElement { margin-bottom: var(--lana-space-2xs); padding: var(--lana-space-2xs) var(--lana-space-xs); background: var(--lana-callout-warning-bg); - color: var(--lana-severity-warning); white-space: normal; } @@ -74,6 +75,7 @@ export class HotPath extends LitElement { background: var(--lana-callout-warning-bg-hover); } + /* The icon carries the warning, not the sentence. */ .caveat-row vscode-icon { flex: 0 0 auto; } @@ -243,7 +245,7 @@ export class HotPath extends LitElement { title="Show the first truncated call in the tree" @click=${() => dispatchInspectorReveal(this, truncation.firstEventIndex)} > - + ${text} `; } diff --git a/log-viewer/src/components/InspectorTabController.ts b/log-viewer/src/components/InspectorTabController.ts new file mode 100644 index 000000000..b3f803bea --- /dev/null +++ b/log-viewer/src/components/InspectorTabController.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import type { DetailSource } from '../core/events/EventBus.js'; +import { InspectorEmphasis } from './inspectorEmphasis.js'; +import { wireInspectorTab, type InspectorTabSync } from './inspectorTab.js'; + +/** + * Subscribes a Lit view to the inspector for as long as it is connected, and + * holds the emphasis the two of them share. + * + * The emphasis belongs here rather than to the view because nothing outside the + * subscription decides what it holds: the view only ever drops a pick that went + * with its own selection, through {@link dropPick}. {@link wireInspectorTab} is + * still the way in for a view that is no `ReactiveControllerHost` — the flame + * chart, which is a plain class and rests the emphasis on a frame of its own. + */ +export class InspectorTabController implements ReactiveController { + private readonly _emphasis = new InspectorEmphasis(); + private readonly _source: DetailSource; + private readonly _sync: InspectorTabSync; + private _off: (() => void) | null = null; + + constructor(host: ReactiveControllerHost, source: DetailSource, sync: InspectorTabSync) { + this._source = source; + this._sync = sync; + host.addController(this); + } + + hostConnected(): void { + this._off = wireInspectorTab(this._source, this._emphasis, this._sync); + // A pick outlives a detach, and the view came back with nothing lit. + const emphasised = this._emphasis.current(); + if (emphasised.length) { + this._sync.mark(emphasised); + } + } + + hostDisconnected(): void { + this._off?.(); + this._off = null; + } + + /** + * Drop a pick a picked inspector row left in the view, and mark what is left. + * For a view whose own selection has gone: the mark was never a selection of + * its own. + */ + dropPick(): void { + this._sync.mark(this._emphasis.pick([])); + } +} diff --git a/log-viewer/src/components/LogInspector.ts b/log-viewer/src/components/LogInspector.ts index 9ae779631..da7b951e8 100644 --- a/log-viewer/src/components/LogInspector.ts +++ b/log-viewer/src/components/LogInspector.ts @@ -2,7 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { LitElement, css, html, type PropertyValues } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { TAB_TO_SOURCE, @@ -16,10 +16,23 @@ import { debounce } from '../core/utility/Util.js'; import { getSettings, updateSetting } from '../features/settings/Settings.js'; import { emptyTextFor } from './detailEmptyText.js'; import { buildDetailSections } from './detailSections.js'; +import { + hiddenIds, + keepUnbuilt, + layoutKey, + mergeOrder, + orderSections, + scopedKey, + scopedRecord, + withoutScope, +} from './inspectorLayout.js'; +import { RESET_SECTIONS_ID, buildSectionMenuItems, sectionIdFor } from './sectionMenu.js'; import { globalStyles } from '../styles/global.styles.js'; +import './ContextMenu.js'; +import type { ContextMenu } from './ContextMenu.js'; import type { DockPosition } from './DetailDock.js'; import './DockLayout.js'; -import type { PaneOrientation, PaneSection } from './PaneView.js'; +import type { PaneSection } from './PaneView.js'; import './ViewModeSwitch.js'; import type { ViewModeOption } from './ViewModeSwitch.js'; @@ -36,7 +49,13 @@ const SCOPE_OPTIONS: readonly ViewModeOption[] = [ * via a forwarded `main` slot) so it crosscuts every tab. It follows the active * tab: each source's latest selection is remembered, and it shows the active * tab's selection. Persists dock position/size (public settings) plus its - * open/closed state, section collapse and pane sizes (private globalState). + * open/closed state and its section layout (private globalState). + * + * Collapse, order and which sections show are remembered per section list — per + * tab and per scope, see {@link layoutKey} — because one id means different + * content in two lists. Section sizes are remembered nowhere: a section takes + * the space its content and the panel allow, and a size dragged for one log is + * the wrong one for the next. */ @customElement('log-inspector') export class LogInspector extends LitElement { @@ -51,11 +70,37 @@ export class LogInspector extends LitElement { @state() private panelSize = 500; - // Keyed by section id and shared by every tab — one panel, one layout. + // Keyed `::
`, so the same section keeps its own + // state in each list it appears in. @state() private collapsedSections: Record = {}; + // The order the user arranged each list in, keyed `:`. + @state() + private sectionOrder: Record = {}; + // The sections a list is set to hide, keyed like the collapse record. + @state() + private hiddenSections: Record = {}; + // Bumped by a reset, which hands the panes' sizes back to automatic. @state() - private paneSizes: Record = {}; + private _layoutEpoch = 0; + + // What the builder produced for the list on screen. Kept in its own order, + // because that is what a reset goes back to. + private _builtSections: PaneSection[] = []; + // Everything else about that list, derived together in `_applyLayout` so the + // four can never disagree: which list it is, every section of it in the order + // the user arranged (hidden ones included, so the header menu can offer them + // back), the ones it hides, and its collapse record by plain section id, which + // is what `` takes. + private _layout: { + key: string; + ordered: PaneSection[]; + hidden: ReadonlySet; + collapsed: Record; + } = { key: '', ordered: [], hidden: new Set(), collapsed: {} }; + + @query('context-menu') + private _menu?: ContextMenu; // Latest selection per source; the bar renders the active tab's entry. private _selections = new Map(); @@ -107,7 +152,11 @@ export class LogInspector extends LitElement { this.dock = panel.position; this.panelSize = panel.size; this.collapsedSections = panel.collapsed ?? {}; - this.paneSizes = panel.paneSizes ?? {}; + this.sectionOrder = panel.sectionOrder ?? {}; + this.hiddenSections = panel.hiddenSections ?? {}; + // A hidden section's build skips work, so the list is rebuilt rather + // than filtered. + void this._rebuild(); } }) .catch(() => { @@ -166,21 +215,23 @@ export class LogInspector extends LitElement { .size=${this.panelSize} ?visible=${this._visible} .sections=${this.sections} - .collapsed=${this.collapsedSections} - .paneSizes=${this.paneSizes} + .collapsed=${this._layout.collapsed} + .layoutEpoch=${this._layoutEpoch} emptyText=${emptyTextFor(this._activeSource)} @dock-position-change=${this._onDockPositionChange} @dock-resize=${this._onDockResize} @dock-hide=${this._hidePanel} @dock-collapse=${this._hidePanel} @pane-toggle=${this._onPaneToggle} - @pane-resize=${this._onPaneResize} + @pane-reorder=${this._onPaneReorder} + @pane-menu=${this._onPaneMenu} @inspector-reveal=${this._onReveal} @inspector-locate=${this._onLocate} > ${this._scopeSwitch()} + `; } @@ -343,17 +394,64 @@ export class LogInspector extends LitElement { private async _rebuild(): Promise { const epoch = ++this._rebuildEpoch; const source = this._activeSource; + const selection = source ? this._scopedSelection(source) : null; + const key = source ? layoutKey(source, selection ? 'detail' : 'summary') : ''; + // What this build skips. Read before the await, which is why `_applyLayout` + // reads the store again rather than trusting it. + const hidden = hiddenIds(this.hiddenSections, key); const sections = source ? await buildDetailSections( source, - this._scopedSelection(source), + selection, this._active.get(source) ?? null, this._sourceViews.get(source), + hidden, ) : []; // Drop a slow build that a newer selection already superseded. if (epoch === this._rebuildEpoch) { - this.sections = sections; + this._builtSections = sections; + this._applyLayout(key); + } + } + + /** + * The built sections in this list's own order, and only the ones it shows. + * Reads the stored set itself rather than taking one: a build's set is a + * snapshot from before it awaited, and the user can hide a section while it + * runs. + */ + private _applyLayout(key = this._layout.key): void { + const ordered = orderSections(this._builtSections, this.sectionOrder[key]); + let hidden = hiddenIds(this.hiddenSections, key); + // A stored set that hides every section — a list whose sections have changed + // since — would leave no header to right-click, and that menu is the only way + // back. Forgetting it is the way out, so the store agrees with the screen. + if (ordered.length && ordered.every((section) => hidden.has(section.id))) { + this.hiddenSections = withoutScope(this.hiddenSections, key); + updateSetting('inspector.hiddenSections', this.hiddenSections); + hidden = new Set(); + // They were built as hidden, so the work their build skipped is missing — + // a badge, or anything else the section resolves up front. + void this._rebuild(); + } + this._layout = { + key, + ordered, + hidden, + collapsed: scopedRecord(this.collapsedSections, key), + }; + this.sections = ordered.filter((section) => !hidden.has(section.id)); + } + + /** After a change to what this list shows: only a section coming back needs + * the work its build skipped. Settles before it resolves, so a caller can + * read the layout it produced. */ + private async _relayout(needsBuild: boolean): Promise { + if (needsBuild) { + await this._rebuild(); + } else { + this._applyLayout(); } } @@ -373,24 +471,90 @@ export class LogInspector extends LitElement { private _onPaneToggle = (e: CustomEvent<{ collapsed: Record }>) => { this._userAdjusted = true; - this.collapsedSections = { ...this.collapsedSections, ...e.detail.collapsed }; + // The pane names sections by id; the panel remembers them per list. + const scoped = Object.entries(e.detail.collapsed).map(([id, value]) => [ + scopedKey(this._layout.key, id), + value, + ]); + this.collapsedSections = { ...this.collapsedSections, ...Object.fromEntries(scoped) }; + this._layout = { ...this._layout, collapsed: e.detail.collapsed }; updateSetting('inspector.collapsed', this.collapsedSections); }; - // `pane-resize` fires on pointer-up, so this write lands on interaction-end. - private _onPaneResize = ( - e: CustomEvent<{ sizes: Record; orientation: PaneOrientation }>, - ) => { + private _onPaneReorder = (e: CustomEvent<{ ids: string[] }>) => { this._userAdjusted = true; - // Every size for the dragged axis arrives together, so that axis is - // replaced, not merged: a pane reset to its content's size has no entry to - // merge. The other axis' sizes are untouched. - const prefix = `${e.detail.orientation}:`; - const otherAxis = Object.entries(this.paneSizes).filter(([key]) => !key.startsWith(prefix)); - this.paneSizes = { ...Object.fromEntries(otherAxis), ...e.detail.sizes }; - updateSetting('inspector.paneSizes', this.paneSizes); + const ids = this._layout.ordered.map((section) => section.id); + const arranged = mergeOrder(ids, this._layout.hidden, e.detail.ids); + // What this build never produced is off screen like a hidden section, not + // gone: a reorder under a DML row must not drop where they put SOQL issues. + const order = keepUnbuilt(this.sectionOrder[this._layout.key] ?? [], arranged); + this.sectionOrder = { ...this.sectionOrder, [this._layout.key]: order }; + updateSetting('inspector.sectionOrder', this.sectionOrder); + this._applyLayout(); }; + /** Offers every section of the list, hidden ones included, so any can come back. */ + private _onPaneMenu = (e: CustomEvent<{ x: number; y: number }>) => { + this._menu?.show(this._sectionMenuItems(), e.detail.x, e.detail.y); + }; + + /** The menu stays open through a toggle, so its ticks are refreshed in place. */ + private _refreshSectionMenu(): void { + if (this._menu?.isVisible()) { + this._menu.items = this._sectionMenuItems(); + } + } + + private _sectionMenuItems() { + return buildSectionMenuItems(this._layout.ordered, this._layout.hidden); + } + + private _onSectionMenuSelect = (e: CustomEvent<{ itemId: string }>) => { + if (e.detail.itemId === RESET_SECTIONS_ID) { + this._resetSections(); + return; + } + const id = sectionIdFor(e.detail.itemId); + if (id) { + void this._toggleSection(id); + } + }; + + private async _toggleSection(id: string): Promise { + this._userAdjusted = true; + const key = scopedKey(this._layout.key, id); + const hidden = { ...this.hiddenSections }; + const bringingBack = !!hidden[key]; + if (bringingBack) { + delete hidden[key]; + } else { + hidden[key] = true; + } + this.hiddenSections = hidden; + updateSetting('inspector.hiddenSections', this.hiddenSections); + // Re-ticked from the layout the toggle produced, so a slow rebuild cannot + // leave a row showing the state before the click. + await this._relayout(bringingBack); + this._refreshSectionMenu(); + } + + /** + * This list back to its defaults: the order it is built in, every section + * showing, and the panes' sizes automatic again. Collapse is left alone — it is + * a live reading choice, and one click undoes it. + */ + private _resetSections(): void { + this._userAdjusted = true; + const { [this._layout.key]: _cleared, ...order } = this.sectionOrder; + this.sectionOrder = order; + const hadHidden = this._layout.hidden.size > 0; + this.hiddenSections = withoutScope(this.hiddenSections, this._layout.key); + updateSetting('inspector.sectionOrder', this.sectionOrder); + updateSetting('inspector.hiddenSections', this.hiddenSections); + this._layoutEpoch++; + void this._relayout(hadHidden); + } + private _hidePanel = () => { this._setVisible(false); }; diff --git a/log-viewer/src/components/LogOverview.ts b/log-viewer/src/components/LogOverview.ts index e76cc0087..2c45b7387 100644 --- a/log-viewer/src/components/LogOverview.ts +++ b/log-viewer/src/components/LogOverview.ts @@ -9,11 +9,8 @@ import { logContext } from '../core/log/logContext.js'; import type { LogStore } from '../core/log/LogStore.js'; import { apexLimitTimeSeries } from '../features/timeline/optimised/apex-limit-series.js'; import { globalStyles } from '../styles/global.styles.js'; -import { - ESTIMATED_LIMITS_TEXT, - NO_CUMULATIVE_LIMITS_TEXT, - seriesGauges, -} from './logOverviewMetrics.js'; +import { NO_GOVERNOR_USAGE_TEXT, NO_LOG_TEXT } from './governorCopy.js'; +import { seriesGauges } from './logOverviewMetrics.js'; // web components import '../features/database/components/GovernorSummary.js'; @@ -54,16 +51,16 @@ export class LogOverview extends LitElement { render() { const apexLog = this.logStore?.log; - const gauges = apexLog ? seriesGauges(apexLimitTimeSeries(apexLog)) : []; - if (!apexLog || !gauges.length) { - return html`

${NO_CUMULATIVE_LIMITS_TEXT}

`; + if (!apexLog) { + return html`

${NO_LOG_TEXT}

`; + } + const gauges = seriesGauges(apexLimitTimeSeries(apexLog)); + if (!gauges.length) { + return html`

${NO_GOVERNOR_USAGE_TEXT}

`; } - // Snapshots correct the series where they exist; without any, the figures - // are estimated from granular events — say so. - const estimated = apexLog.governorLimits.snapshots.length === 0; - return html` - ${estimated ? html`

${ESTIMATED_LIMITS_TEXT}

` : ''}`; + // A gauge with no reported limit says so on hover, so the strip needs no note beneath it. + return html``; } } diff --git a/log-viewer/src/components/LogTitle.ts b/log-viewer/src/components/LogTitle.ts index 196c3b600..49daeb01a 100644 --- a/log-viewer/src/components/LogTitle.ts +++ b/log-viewer/src/components/LogTitle.ts @@ -85,6 +85,6 @@ export class LogTitle extends LitElement { } _goToLog() { - vscodeMessenger.send('openPath', this.logPath); + vscodeMessenger.send('openPath'); } } diff --git a/log-viewer/src/components/OverflowList.ts b/log-viewer/src/components/OverflowList.ts index b5a97d83e..c11dacbd8 100644 --- a/log-viewer/src/components/OverflowList.ts +++ b/log-viewer/src/components/OverflowList.ts @@ -103,10 +103,10 @@ export class OverflowList extends LitElement { .overflow { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 6px; - border: 1px solid var(--lana-control-border); - border-radius: 4px; + gap: var(--lana-space-2xs); + padding: var(--lana-space-3xs) var(--lana-space-xs); + border: var(--lana-stroke) solid var(--lana-control-border); + border-radius: var(--lana-radius-sm); background-color: var(--lana-control-bg); color: var(--lana-fg); font: inherit; @@ -123,8 +123,8 @@ export class OverflowList extends LitElement { } .overflow:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: 1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } .overflow__count { diff --git a/log-viewer/src/components/PaneView.ts b/log-viewer/src/components/PaneView.ts index c8024e19e..26b83d8e3 100644 --- a/log-viewer/src/components/PaneView.ts +++ b/log-viewer/src/components/PaneView.ts @@ -5,6 +5,9 @@ import '#vscode-elements/vscode-icon.js'; import '#vscode-elements/vscode-badge.js'; import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; // styles import { globalStyles } from '../styles/global.styles.js'; @@ -18,25 +21,60 @@ export interface PaneSection { /** Default flex-grow weight when open, seeded on first render (default 1). */ weight?: number; /** - * How the open pane takes space (default `'fill'`). A `'content'` pane sizes - * to its content and shrinks — scrolling inside — when space runs out, it - * never stretches to soak up leftovers, and it leaves the open fill panes a - * share of the space. Dragging its sash pins it to a size instead, which a - * double-click hands back to the content. + * How the open pane takes space (default `'fill'`). A `'content'` pane asks + * for its content and never for more, so it does not stretch to soak up + * leftovers — but it asks for no more than an equal share of the panel + * either, growing back towards its content only as far as the room the other + * sections leave and scrolling inside beyond that. So no one section can take + * the panel and hold every other one at its floor. Dragging its sash gives it + * whatever room the drag asks for. * A `'fill'` pane shares the remaining space by weight. + * + * Only for content that does not change with what the consumer is showing. + * A section that says something different about each selection sizes itself + * with {@link PaneSection.height} instead: sizing that content would move + * every boundary in the stack each time the reader steps to the next one. */ fit?: 'content' | 'fill'; + /** + * A steady height, in place of the pane's content or its share: the choice for + * a section whose content changes with the selection. The tier names a + * `--lana-pane-*` token, so the height lives in CSS — a share of the panel + * between two bounds, which scales with the room there is and depends on + * nothing the selection changes. A dragged size still wins, and the section + * scrolls inside when it has more to show. + * + * Read while the stack is vertical, the only axis on which a section has a + * height; laid out side by side it shares the width like any fill pane. + */ + height?: 'sm' | 'md'; } export type PaneOrientation = 'vertical' | 'horizontal'; -const MIN_PANE_PX = 44; +/** An open pane as a sash drag found it: what it measured, and its floor. */ +interface SashPane { + id: string; + size: number; + min: number; +} /** * A VS Code sidebar-style PaneView: a stack of titled sections that (when * vertical) collapse via a twistie and share the available space, with a - * draggable sash between adjacent open sections that redistributes their size. + * draggable sash between adjacent open sections. Dragging one gives room to the + * sections on one side of it and takes it from the other, nearest first: each + * gives up room down to `--lana-pane-min` and then the next one does, so the + * sash keeps following the pointer until the whole giving side is at its floor. + * A drag fixes the size of every open section, not only the two it moved: + * `layoutEpoch` is what hands the whole stack back, and a double-click on a + * sash re-splits the pair beside it. * Horizontal mode lays the sections side by side with resize-only sashes. + * + * Dragging a header — or `Alt+Arrow` on a focused one — reorders the stack. The + * consumer owns the order, and hears the new one through `pane-reorder`. + * Right-clicking a header reports `pane-menu`: the consumer knows what sections + * there are, so it owns the menu. */ @customElement('pane-view') export class PaneView extends LitElement { @@ -50,28 +88,40 @@ export class PaneView extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - /** - * Pane sizes from the last drag (px), keyed `:
`. - * Relative only, and per axis — a width dragged in the horizontal dock says - * nothing about heights in the vertical one, so each orientation keeps its own - * sizes and the other's are left untouched. - */ - @property({ attribute: false }) - paneSizes: Record = {}; + /** Bump to drop the sizes the panes were dragged to, back to automatic. */ + @property({ type: Number }) + layoutEpoch = 0; - // The current axis' weights, keyed by section id: seeded from `paneSizes`, - // then edited in place by a live drag until `pane-resize` hands the result - // back to the consumer. + // Sizes a drag has given the panes (px), keyed by section id. The panel keeps + // them while it is open and never persists them: sections size + // themselves from their content and the space there is, and a layout dragged + // for one log is the wrong one for the next. @state() private _weights: Record = {}; + // The header being dragged, and where it would land: an insertion point in + // `sections`, so one boundary is one mark however it was reached. + @state() + private _dragId: string | null = null; + @state() + private _dropIndex: number | null = null; + + // The header to put focus back on once a keyboard move has re-rendered. + private _focusId: string | null = null; + private _sash: { - aId: string; - bId: string; + /** The panes each way from the sash, nearest it first, and the room each side has. */ + above: SashPane[]; + below: SashPane[]; + slackAbove: number; + slackBelow: number; + /** Every open pane at the size it was measured at, keyed by id. */ + measured: Record; start: number; - startA: number; - startB: number; - moved: boolean; + /** The last delta applied, so a move that changes nothing renders nothing. */ + delta: number; + /** The dragged sizes as the gesture found them, so a cancel puts them back. */ + weights: Record; } | null = null; static styles = [ @@ -102,8 +152,64 @@ export class PaneView extends LitElement { .pane-view[data-orientation='horizontal'] { flex-direction: row; } + .pane-view[data-orientation='horizontal'] .pane[data-open] { + min-width: var(--lana-pane-min); + } + + /* How a section takes space, in one place. The element carries only the + numbers behind it: --pane-grow for a fill pane's weight, --pane-count + for the share, and --pane-size for a size the reader dragged to. + + A drag beats every default here, and one mechanism says so: --pane-size + is set only on a dragged pane, so every rule below reads it first and + falls back to what it would otherwise have used. */ + .pane { + flex: 0 0 auto; + } + /* Open: it shrinks — scrolling inside — when the space runs out. Each + sizing mode below sets its own basis. */ + .pane[data-open] { + flex-shrink: 1; + } + .pane[data-open][data-sizing='fill'] { + flex-grow: var(--pane-grow, 1); + flex-basis: var(--pane-size, 0); + } + /* An equal share as the basis, so the panes shrink alongside each other + rather than by size. From a basis of zero a fill pane can only grow + into free space, and a stack of sized-to-content panes leaves none — it + would sit at the floor; a content pane sized to its content is the one + holding it there, because flexbox shrinks by basis and the biggest + keeps the most. A tier is exempt: a bounded slot already, and a share + here would over-subscribe the panel and flatten the weights. */ + .pane-view[data-content] .pane[data-open][data-sizing='fill'], + .pane[data-open][data-sizing='content'] { + flex-basis: var(--pane-size, calc(100% / var(--pane-count))); + } + /* A content pane is then a weight-1 fill pane capped at its content: it + never stretches past what it has to show, and scrolls when the share is + all it gets. */ + .pane[data-open][data-sizing='content'] { + flex-grow: 1; + } + .pane-view[data-orientation='vertical'] .pane[data-open][data-sizing='content'] { + max-height: var(--pane-size, max-content); + } + .pane-view[data-orientation='horizontal'] .pane[data-open][data-sizing='content'] { + max-width: var(--pane-size, max-content); + } + + /* A steady height, so walking the selection does not resize the stack. + Only a vertical stack is given a tier, so no rule here re-checks it. */ + .pane[data-open][data-tier='sm'] { + flex-basis: var(--pane-size, var(--lana-pane-sm)); + } + .pane[data-open][data-tier='md'] { + flex-basis: var(--pane-size, var(--lana-pane-md)); + } .pane { + position: relative; display: flex; flex-direction: column; min-height: 0; @@ -117,6 +223,44 @@ export class PaneView extends LitElement { border-right: none; } + /* Where a dragged section would land, on the edge it would land against. + Drawn over the content: the header paints its own background, so a + shadow cast by the pane behind it would not show. */ + .pane--drop-before::after, + .pane--drop-after::after { + content: ''; + position: absolute; + z-index: 2; + background-color: var(--lana-focus-border); + } + .pane-view[data-orientation='vertical'] .pane--drop-before::after, + .pane-view[data-orientation='vertical'] .pane--drop-after::after { + left: 0; + right: 0; + height: var(--lana-space-3xs); + } + .pane-view[data-orientation='vertical'] .pane--drop-before::after { + top: 0; + } + .pane-view[data-orientation='vertical'] .pane--drop-after::after { + bottom: 0; + } + .pane-view[data-orientation='horizontal'] .pane--drop-before::after, + .pane-view[data-orientation='horizontal'] .pane--drop-after::after { + top: 0; + bottom: 0; + width: var(--lana-space-3xs); + } + .pane-view[data-orientation='horizontal'] .pane--drop-before::after { + left: 0; + } + .pane-view[data-orientation='horizontal'] .pane--drop-after::after { + right: 0; + } + .pane--dragging { + opacity: 0.6; + } + .pane-header { display: flex; align-items: center; @@ -130,7 +274,7 @@ export class PaneView extends LitElement { letter-spacing: var(--lana-text-caps-tracking); color: var(--vscode-sideBarSectionHeader-foreground); background-color: var(--vscode-sideBarSectionHeader-background); - border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); + border-top: var(--lana-stroke) solid var(--vscode-sideBarSectionHeader-border, transparent); user-select: none; white-space: nowrap; overflow: hidden; @@ -142,9 +286,11 @@ export class PaneView extends LitElement { .pane-header--button:hover { background-color: var(--lana-row-hover-bg); } - .pane-header:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: -1px; + /* Focus, not focus-visible: the focused header is the one Alt+Arrow moves, + so which one that is has to show even when a click put it there. */ + .pane-header:focus { + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .pane-header vscode-icon { color: var(--lana-icon-fg); @@ -198,106 +344,150 @@ export class PaneView extends LitElement { ]; willUpdate(changed: PropertyValues): void { - // Adopt whatever the consumer stored for this axis; a drag then edits this - // copy. Re-docking flips the orientation on the same element, so that has to - // re-seed too or the previous axis' sizes would carry over. - if (changed.has('paneSizes') || changed.has('orientation')) { - const prefix = `${this.orientation}:`; - this._weights = Object.fromEntries( - Object.entries(this.paneSizes) - .filter(([key]) => key.startsWith(prefix)) - .map(([key, size]) => [key.slice(prefix.length), size]), - ); + // Re-docking flips the orientation on the same element, and a height dragged + // down the side says nothing about a width along the bottom. + if (changed.has('orientation') || changed.has('layoutEpoch')) { + this._weights = {}; + } + // A section that opens after a drag has no size of its own, and the panes + // holding pixels leave it no share to take — it would come up at its floor. + // So the stack goes back to sizing itself. + if (this._mixesSizes()) { + this._weights = {}; } } - render() { - const weights = this._flexWeights(); - const basis = this._fillBasis(); - const items: TemplateResult[] = []; - this.sections.forEach((section, index) => { - items.push(this._renderPane(section, weights.get(section.id) ?? 1, basis)); - const next = this.sections[index + 1]; - // A sash trades space between the two panes beside it, so it exists - // wherever both neighbours are open. A content pane starts at its - // content's size and then holds the size it is dragged to. - if (next && this._isOpen(section.id) && this._isOpen(next.id)) { - items.push(this._renderSash(section.id, next.id)); - } - }); + /** + * Whether some open section holds a dragged size while another has none. The + * two cannot be mixed: a size is taken out of the panel before the panes that + * share it are given anything. + */ + private _mixesSizes(): boolean { + const open = this.sections.filter((section) => this._isOpen(section.id)); + const sized = open.filter((section) => this._weights[section.id] !== undefined).length; + return sized > 0 && sized < open.length; + } + + updated(): void { + // A keyboard move re-renders the stack under the pointer of the keyboard, so + // the moved section keeps the focus and a second press moves the same one. + if (this._focusId) { + this._headerFor(this._focusId)?.focus(); + this._focusId = null; + } + } - return html`
${items}
`; + render() { + const isOpen = this.sections.map((section) => this._isOpen(section.id)); + const open = this.sections.filter((_section, index) => isOpen[index]); + const dragged = this._draggedWeights(open); + + // Keyed on the section, so a reorder — or a collapse that adds a sash above + // one — moves the panes that are already there. Rendered by position they + // would be rebuilt in place instead, re-mounting each body's grid and + // losing its scroll, its expanded rows and the row the user picked. + return html`
+ ${repeat( + this.sections, + (section) => section.id, + (section, index) => { + const next = this.sections[index + 1]; + // A sash trades space between the two panes beside it, so it exists + // wherever both neighbours are open, and travels with the pane above. + const sash = + next && isOpen[index] && isOpen[index + 1] + ? this._renderSash(section.id, next.id) + : nothing; + return html`${this._renderPane( + section, + dragged.get(section.id) ?? section.weight ?? 1, + this._dropEdge(index), + )}${sash}`; + }, + )} +
`; } /** - * Flex weights for the open panes, all on one scale. Stored sizes are pixel - * snapshots taken during a drag while `section.weight` is a small unit share, - * so the stored set is rescaled onto the unit scale: mixing the two would - * render a section with no stored size — one added after the drag, or - * collapsed during it — as a sliver beside its pixel-sized siblings. + * The dragged fill panes' weights, rescaled from pixels onto the unit scale + * `section.weight` uses. A dragged pane takes its size from its basis, not + * from this, so what the rescale decides is how the panes split whatever is + * left over — after the dock is resized, or a section is collapsed. Mixing + * the two scales would hand nearly all of it to the panes that were dragged. */ - private _flexWeights(): Map { - const open = this.sections.filter( - (section) => this._isOpen(section.id) && this._isFill(section), + private _draggedWeights(open: PaneSection[]): Map { + const fill = open.filter( + (section) => this._sizingOf(section) === 'fill' && this._weights[section.id] !== undefined, ); - let storedPx = 0; - let storedUnits = 0; - for (const section of open) { - const stored = this._weights[section.id]; - if (stored !== undefined) { - storedPx += stored; - storedUnits += section.weight ?? 1; - } + let px = 0; + let units = 0; + for (const section of fill) { + px += this._weights[section.id] ?? 0; + units += section.weight ?? 1; } - // px → units, and 0 when nothing is stored so every pane falls back to units. - const scale = storedPx > 0 ? storedUnits / storedPx : 0; - return new Map( - open.map((section) => { - const stored = this._weights[section.id]; - const weight = stored !== undefined && scale > 0 ? stored * scale : (section.weight ?? 1); - return [section.id, weight]; - }), - ); + if (px === 0) { + return new Map(); + } + const scale = units / px; + return new Map(fill.map((section) => [section.id, (this._weights[section.id] ?? 0) * scale])); } /** - * A fill pane's flex basis: an equal share of the panel, so a stack of - * sized-to-content panes cannot squeeze it to `--lana-pane-min`. From a basis - * of zero it can only grow into free space, and once the content panes fill - * the panel there is none; from a share it shrinks alongside them instead, and - * keeps its natural size while the panel is roomy. Zero while every open pane - * fills, where a share each would take the whole panel and flatten the - * weights. + * Whether the fill panes need a share of the panel rather than a basis of + * zero — which the stylesheet decides from `data-content`. True while some + * open pane is sized by its content, the one size nothing here bounds; see + * the rule itself for what the share buys and what it costs. Only down the + * side: laid out along the bottom the sections share the width already. */ - private _fillBasis(): string { - const open = this.sections.filter((section) => this._isOpen(section.id)); - const content = open.filter((section) => !this._isFill(section)).length; - return this.orientation === 'vertical' && content > 0 ? `calc(100% / ${open.length})` : '0'; + private _needsShare(open: PaneSection[]): boolean { + return ( + this.orientation === 'vertical' && + open.some((section) => this._sizingOf(section) === 'content') + ); } - private _renderPane(section: PaneSection, weight: number, basis: string) { + private _renderPane(section: PaneSection, weight: number, drop: 'before' | 'after' | null) { const open = this._isOpen(section.id); const collapsible = this._collapsible; - // An open content pane sizes to its content, or to the size it was dragged - // to, but stays shrinkable, so when space runs out it scrolls instead of - // pushing the fill panes off screen. + const sizing = this._sizingOf(section); const dragged = this._weights[section.id]; - const style = !open - ? 'flex: 0 0 auto' - : this._isFill(section) - ? `flex: ${weight} 1 ${basis}` - : dragged !== undefined - ? `flex: 0 1 ${dragged}px` - : 'flex: 0 1 auto'; - - return html`
+ + return html`
this._toggle(section.id) : undefined} - @keydown=${collapsible ? (e: KeyboardEvent) => this._onHeaderKey(e, section.id) : undefined} + @keydown=${(e: KeyboardEvent) => this._onHeaderKey(e, section.id)} + @contextmenu=${(e: MouseEvent) => this._onHeaderMenu(e, section.id)} + @dragstart=${(e: DragEvent) => this._onDragStart(e, section.id)} + @dragend=${this._endDrag} > ${ collapsible @@ -311,10 +501,23 @@ export class PaneView extends LitElement {
`; } + /** + * The edge of this pane a dragged section would land against, if any. Every + * boundary but the last is the near edge of the pane below it, so only the + * final position marks a pane's far edge. + */ + private _dropEdge(index: number): 'before' | 'after' | null { + if (this._dropIndex === index) { + return 'before'; + } + const last = this.sections.length - 1; + return this._dropIndex === last + 1 && index === last ? 'after' : null; + } + private _renderSash(aId: string, bId: string) { return html`
this._startSash(e, aId, bId)} + @pointerdown=${(e: PointerEvent) => this._startSash(e, aId)} @dblclick=${() => this._resetSash(aId, bId)} >
`; } @@ -327,8 +530,24 @@ export class PaneView extends LitElement { return this._collapsible ? !this.collapsed[id] : true; } - private _isFill(section: PaneSection) { - return (section.fit ?? 'fill') === 'fill'; + /** + * Which of the three ways this pane takes space. One derivation, so the modes + * stay mutually exclusive and the stylesheet, the share and the drag all read + * the same answer. + */ + private _sizingOf(section: PaneSection): 'fill' | 'content' | 'tier' { + if (section.height && this.orientation === 'vertical') { + return 'tier'; + } + return (section.fit ?? 'fill') === 'content' ? 'content' : 'fill'; + } + + private _paneEl(id: string): HTMLElement | null { + return this.renderRoot?.querySelector(`.pane[data-id="${id}"]`) ?? null; + } + + private _headerFor(id: string): HTMLElement | null { + return this._paneEl(id)?.querySelector('.pane-header') ?? null; } private _toggle(id: string) { @@ -344,46 +563,200 @@ export class PaneView extends LitElement { } private _onHeaderKey(e: KeyboardEvent, id: string) { - if (e.key === 'Enter' || e.key === ' ') { + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + // Fires once: each move is a layout the consumer persists. + if (!e.repeat) { + this._moveBy(id, e.key === 'ArrowUp' ? -1 : 1); + } + return; + } + if (this._collapsible && (e.key === 'Enter' || e.key === ' ')) { + // Consumed on a repeat too, so Space never scrolls the stack. e.preventDefault(); - this._toggle(id); + // Fires once: a repeat would flap the pane, and each toggle persists a setting. + if (!e.repeat) { + this._toggle(id); + } + } + } + + /** The keyboard's reach for the header drag: one place per press. */ + private _moveBy(id: string, step: number) { + const from = this.sections.findIndex((section) => section.id === id); + const to = from + step; + if (from < 0 || to < 0 || to >= this.sections.length) { + return; } + this._focusId = id; + this._emitReorder(this._reorderedIds(from, to)); + } + + /** The consumer owns which sections there are, so it owns the menu too. */ + private _onHeaderMenu(e: MouseEvent, id: string) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('pane-menu', { + detail: { id, x: e.clientX, y: e.clientY }, + bubbles: true, + composed: true, + }), + ); + } + + private _onDragStart(e: DragEvent, id: string) { + this._dragId = id; + if (e.dataTransfer) { + // The payload is the id, so a drop outside the stack carries something + // meaningful rather than nothing. + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + } + } + + private _onDragOver = (e: DragEvent) => { + if (!this._dragId) { + return; + } + // Claiming the drag is what makes the drop land here at all, and it is + // claimed for the whole stack — headers, bodies, sashes and the section + // being dragged — or crossing any of them reads as leaving. + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + this._dropIndex = this._insertionIndex(e); + }; + + /** + * Where the dragged section would land: the first pane whose middle the + * pointer has not passed, or the end of the stack. Measured against the whole + * pane rather than its header, so the target is the section the pointer is + * over, not a strip at the top of it. + */ + private _insertionIndex(e: DragEvent): number | null { + const vertical = this.orientation === 'vertical'; + const pos = vertical ? e.clientY : e.clientX; + let index = this.sections.length; + for (const [i, section] of this.sections.entries()) { + const rect = this._paneEl(section.id)?.getBoundingClientRect(); + if (!rect) { + continue; + } + const middle = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2; + if (pos < middle) { + index = i; + break; + } + } + const from = this.sections.findIndex((section) => section.id === this._dragId); + // Either edge of where it already sits moves nothing, so nothing is marked. + return index === from || index === from + 1 ? null : index; + } + + // Leaving the stack drops the mark. Moving between headers inside it does not: + // the next `dragover` sets the mark again, and clearing here would flicker. + private _onStackDragLeave = (e: DragEvent) => { + const to = e.relatedTarget as Node | null; + if (!to || !(e.currentTarget as HTMLElement).contains(to)) { + this._dropIndex = null; + } + }; + + private _onDrop = (e: DragEvent) => { + e.preventDefault(); + const from = this.sections.findIndex((section) => section.id === this._dragId); + const index = this._dropIndex; + this._endDrag(); + if (from >= 0 && index !== null) { + // Lifting the section out shifts every later boundary up one. + this._emitReorder(this._reorderedIds(from, index > from ? index - 1 : index)); + } + }; + + private _endDrag = () => { + this._dragId = null; + this._dropIndex = null; + }; + + /** The section ids with the one at `from` lifted out and put back at `to`. */ + private _reorderedIds(from: number, to: number): string[] { + const ids = this.sections.map((section) => section.id); + ids.splice(to, 0, ...ids.splice(from, 1)); + return ids; + } + + /** Fires with the whole order, so the consumer stores it rather than + * reconstructing the move. */ + private _emitReorder(ids: string[]) { + this.dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids }, + bubbles: true, + composed: true, + }), + ); + } + + /** + * The floor a pane cannot be dragged below, read from the pane itself so the + * drag stops exactly where CSS would: one token sets it, every section shares + * it, and the stack's arithmetic keeps matching what is on screen. Zero where + * there is no layout to read (jsdom), which leaves CSS the only floor. + */ + private _paneMin(id: string): number { + const el = this._paneEl(id); + if (!el) { + return 0; + } + const style = getComputedStyle(el); + return parseFloat(this.orientation === 'vertical' ? style.minHeight : style.minWidth) || 0; } private _paneSize(id: string): number { - const el = this.renderRoot?.querySelector(`.pane[data-id="${id}"]`) as HTMLElement | null; + const el = this._paneEl(id); if (!el) { return 0; } return this.orientation === 'vertical' ? el.offsetHeight : el.offsetWidth; } - private _startSash(e: PointerEvent, aId: string, bId: string) { + /** `aId` is the pane above the sash (left of it, side by side). */ + private _startSash(e: PointerEvent, aId: string) { e.preventDefault(); const sash = e.currentTarget as HTMLElement; sash.setPointerCapture(e.pointerId); sash.classList.add('pane-sash--active'); - // Snapshot every open pane's rendered size as its weight, so weights are in - // pixels and only the two dragged panes change (their sum stays constant). - for (const section of this.sections) { - if (this._isOpen(section.id)) { - this._weights[section.id] = this._paneSize(section.id); - } - } - + // Snapshot the whole open stack as pixels: the drag gives the panes on one + // side of the sash to the panes on the other, and once the nearest of them + // is at its floor the next one gives up room in its place. Nothing is + // written yet, so a sash click that never moves changes no size at all. + // + // One token sets the floor and every section shares it, so it is resolved + // once rather than per pane. + const ids = this.sections.filter((section) => this._isOpen(section.id)).map(({ id }) => id); + const min = this._paneMin(ids[0] ?? ''); + const panes = ids.map((id) => ({ id, size: this._paneSize(id), min })); + // Nearest the sash first, so that pane gives up room until it is at its + // floor, then the one beyond it does, out to the end of the stack. + const index = panes.findIndex((pane) => pane.id === aId); + const above = panes.slice(0, index + 1).reverse(); + const below = panes.slice(index + 1); this._sash = { - aId, - bId, - start: this.orientation === 'vertical' ? e.clientY : e.clientX, - startA: this._weights[aId] ?? 0, - startB: this._weights[bId] ?? 0, - moved: false, + above, + below, + slackAbove: this._slack(above), + slackBelow: this._slack(below), + measured: Object.fromEntries(panes.map((pane) => [pane.id, pane.size])), + start: this._pointerPos(e), + delta: 0, + weights: { ...this._weights }, }; sash.addEventListener('pointermove', this._onSashMove); sash.addEventListener('pointerup', this._endSash); sash.addEventListener('pointercancel', this._cancelSash); - sash.addEventListener('lostpointercapture', this._onLostCapture); + sash.addEventListener('lostpointercapture', this._endSash); } private _onSashMove = (e: PointerEvent) => { @@ -391,42 +764,75 @@ export class PaneView extends LitElement { if (!sash) { return; } - const pos = this.orientation === 'vertical' ? e.clientY : e.clientX; - const total = sash.startA + sash.startB; - const delta = pos - sash.start; - const newA = Math.max(MIN_PANE_PX, Math.min(sash.startA + delta, total - MIN_PANE_PX)); - sash.moved = true; - this._weights = { ...this._weights, [sash.aId]: newA, [sash.bId]: total - newA }; + // The stack's size is fixed, so the room one side takes is the room the + // other gives up: the drag stops once the giving side is all at its floor, + // and a move past that changes nothing worth rendering. + const delta = Math.max( + -sash.slackAbove, + Math.min(this._pointerPos(e) - sash.start, sash.slackBelow), + ); + if (delta === sash.delta) { + return; + } + sash.delta = delta; + + // Every open pane holds its measured size, through the gesture and after it, + // so the bases add up to the panel and flexbox has nothing to shrink. Leave + // one sizing itself and its basis is its content — larger than it renders at + // once the panel is over-subscribed — so the whole stack would shrink and the + // boundary would lag the pointer. Hand one back at the end and the room it + // holds becomes room the sized panes grow into, shrinking a section the drag + // never named. A double-click or `layoutEpoch` is what hands sizes back. + const weights = { ...sash.weights, ...sash.measured }; + this._distribute(sash.above, delta, weights); + this._distribute(sash.below, -delta, weights); + this._weights = weights; }; + private _slack(side: SashPane[]): number { + return side.reduce((room, pane) => room + pane.size - pane.min, 0); + } + + /** Gives `room` to `side`, nearest the sash first, each pane down to its floor. */ + private _distribute(side: SashPane[], room: number, into: Record): void { + let left = room; + for (const pane of side) { + const size = Math.max(pane.min, pane.size + left); + left -= size - pane.size; + if (size !== pane.size) { + into[pane.id] = size; + } + } + } + + private _pointerPos(e: PointerEvent): number { + return this.orientation === 'vertical' ? e.clientY : e.clientX; + } + + // Also the handler for a capture lost without a pointerup (window blur, + // another element capturing): either way the gesture is over, or a later move + // would resize with no button held. private _endSash = (e: PointerEvent) => { - const moved = this._sash?.moved ?? false; - // A click that never moved changed no size, so it isn't worth persisting — - // and a double-click reset would otherwise emit three times. - if (this._teardownSash(e.currentTarget as HTMLElement, e.pointerId) && moved) { - this._emitResize(); + // A gesture that ends where it began is not a drag: the sections it held for + // the drag go back to sizing themselves. + if (this._sash?.delta === 0) { + this._weights = this._sash.weights; } + this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); }; /** An interrupted gesture (OS gesture, touch cancel) undoes the drag. */ private _cancelSash = (e: PointerEvent) => { - const sash = this._sash; - if (sash) { - this._weights = { ...this._weights, [sash.aId]: sash.startA, [sash.bId]: sash.startB }; + if (this._sash) { + this._weights = this._sash.weights; } this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); }; - // Capture lost without a pointerup (window blur, another element capturing): - // stop tracking, or a later move would resize with no button held. - private _onLostCapture = (e: PointerEvent) => { - this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); - }; - - /** Detaches the drag; false if it had already ended. */ - private _teardownSash(sashEl: HTMLElement, pointerId: number): boolean { + /** Detaches the drag, unless it had already ended. */ + private _teardownSash(sashEl: HTMLElement, pointerId: number): void { if (!this._sash) { - return false; + return; } this._sash = null; if (sashEl.hasPointerCapture(pointerId)) { @@ -436,17 +842,21 @@ export class PaneView extends LitElement { sashEl.removeEventListener('pointermove', this._onSashMove); sashEl.removeEventListener('pointerup', this._endSash); sashEl.removeEventListener('pointercancel', this._cancelSash); - sashEl.removeEventListener('lostpointercapture', this._onLostCapture); - return true; + sashEl.removeEventListener('lostpointercapture', this._endSash); } /** * Back to each pane's default: a content pane returns to its content's size, - * and a pair of fill panes splits the space between them evenly. + * and a pair of fill panes splits the space between them evenly. Only the two + * beside the sash — a drag that cascaded past them leaves those at the sizes + * it gave them, and `layoutEpoch` is what hands the whole stack back. */ private _resetSash(aId: string, bId: string) { const weights = { ...this._weights }; - const dragged = [aId, bId].filter((id) => this._isContent(id)); + const dragged = [aId, bId].filter((id) => { + const section = this.sections.find((candidate) => candidate.id === id); + return !!section && this._sizingOf(section) !== 'fill'; + }); if (dragged.length) { for (const id of dragged) { delete weights[id]; @@ -457,27 +867,5 @@ export class PaneView extends LitElement { weights[bId] = total / 2; } this._weights = weights; - this._emitResize(); - } - - private _isContent(id: string): boolean { - const section = this.sections.find((candidate) => candidate.id === id); - return !!section && !this._isFill(section); - } - - /** Fires on interaction-end only, so the consumer's write isn't per-frame. */ - private _emitResize() { - // Every size this axis holds, named by it, so the consumer can replace the - // axis: a pane reset to its content's size has no entry left to merge. - const sizes = Object.fromEntries( - Object.entries(this._weights).map(([id, size]) => [`${this.orientation}:${id}`, size]), - ); - this.dispatchEvent( - new CustomEvent('pane-resize', { - detail: { sizes, orientation: this.orientation }, - bubbles: true, - composed: true, - }), - ); } } diff --git a/log-viewer/src/components/VariablesDetail.ts b/log-viewer/src/components/VariablesDetail.ts new file mode 100644 index 000000000..90930c355 --- /dev/null +++ b/log-viewer/src/components/VariablesDetail.ts @@ -0,0 +1,997 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { consume } from '@lit/context'; +import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { + aggregateVariablesFor, + cachedAggregateVariables, + MAX_MARKED_PER_VALUE, + MAX_VALUES_PER_NAME, + type AggregateVariables, + type SpreadValue, + type VariableSpread, +} from '../core/log/aggregateVariables.js'; +import { + frameVariablesFor, + recordsVariables, + variableIndexFor, + type FrameVariables, + type VariableIndex, +} from '../core/log/frameVariables.js'; +import { logContext } from '../core/log/logContext.js'; +import type { LogStore } from '../core/log/LogStore.js'; +import { previewOf, RAW_CLAMP_CHARS, type VariableValue } from '../core/log/variableValue.js'; +import { formatInteger } from '../core/utility/Util.js'; +import { globalStyles } from '../styles/global.styles.js'; +import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; +import { bleedRowStyles } from '../styles/revealRow.styles.js'; +import { dispatchInspectorLocate } from './inspectorReveal.js'; +import { + parentOf, + toSpreadRows, + toTreeRows, + type Lookups, + type Shown, + type VariableTreeRow, +} from './variableTree.js'; + +// web components +import './CodeBlock.js'; + +/** Any row that shows a value: a variable, a property, or the `this` group. */ +type Valued = Shown & { open: boolean }; + +/** + * What Apex could reach from the selected frame: its locals, its instance fields + * and the statics, each value as the frame saw it. + * + * Only the log's own text is shown. A value is never re-serialised, so a + * duplicate Map key, a truncation marker and an `{}` the log wrote all reach the + * screen as the log wrote them. The log records one level, so one level opens. + * + * A keyboard tree: the arrow keys walk every row, the properties inside an + * opened value included. + */ +@customElement('variables-detail') +export class VariablesDetail extends LitElement { + @property({ type: Number }) + eventIndex = -1; + + /** The frames a merged row is, where the selection is one: the scope every + * call it counts held. Null for a single frame, which `eventIndex` names. */ + @property({ attribute: false }) + frames: number[] | null = null; + + /** The log on screen, from the app root. */ + @consume({ context: logContext, subscribe: true }) + @property({ attribute: false }) + logStore: LogStore | null = null; + + @state() + private _index: VariableIndex | null = null; + + /** What the selected calls held, compared, or null while the walk runs. */ + @state() + private _spread: AggregateVariables | null = null; + + /** The frames `_spread` describes, so a render for any other reason does not + * walk again, and a new selection does. */ + private _spreadKey?: readonly number[] | null; + + /** The comparison in flight; a new selection aborts it, and so does a + * disconnect. */ + private _walk: AbortController | null = null; + + /** Which rows the user has opened or closed, by their stable id, so disclosure + * survives walking the call stack. */ + @state() + private _disclosure: ReadonlyMap = new Map(); + + /** The row holding the tab stop. One tab stop for the whole tree, as the tree + * pattern wants: the arrows move within it. */ + @state() + private _focused: string | null = null; + + /** The whole-log walk threw, so there is nothing to read and nothing to + * retry from here: the section says so instead of reading forever. */ + @state() + private _readError = false; + + /** The rows on screen, kept so a key finds the next one without walking the + * DOM. */ + private _rows: readonly VariableTreeRow[] = []; + + /** Row id to its place in {@link _rows}, so a key is a lookup. */ + private _at: ReadonlyMap = new Map(); + + /** A row on screen shows an object read through an address, so the reading is + * one call's. Fixed by {@link _rows}, so render never rescans them. */ + private _resolved = false; + + /** A value in the comparison holds more calls than its mark can name. Read + * from the spread, not the rows: a closed name would hide it. */ + private _partlyMarked = false; + + /** The scope as read for the current selection. + * + * Read once per selection, never per render: reading it back through a frame + * of hundreds of thousands of lines costs tens of ms, and opening a row must + * not pay that again. */ + private _frame: FrameVariables | null = null; + + /** One index view per point the section reads at: one for a frame, one per + * compared value's own call. Every row is built again whenever anything + * opens, so the views are held rather than remade. */ + private _views = new Map(); + + /** Set when a key moved the tab stop, so `updated` moves focus with it. */ + private _takeFocus = false; + + static styles = [ + globalStyles, + inspectorSectionStyles, + bleedRowStyles, + css` + .tree { + display: flex; + flex-direction: column; + } + + /* One row is one line at any width: the name holds, the value gives way. + Depth is a variable, so every row shares one indent step. */ + .row { + display: flex; + align-items: center; + gap: var(--lana-space-2xs); + padding-left: calc(var(--depth, 0) * var(--lana-space-md)); + } + + .lead { + display: flex; + align-items: baseline; + gap: var(--lana-space-2xs); + flex: 1 1 auto; + min-width: 0; + } + + .chevron { + flex: 0 0 auto; + align-self: center; + color: var(--lana-fg-muted); + transition: transform 150ms ease-out; + } + + .row[aria-expanded='true'] > .chevron { + transform: rotate(90deg); + } + + /* A row that opens on nothing still takes the chevron's width, so every + name at one depth starts on the same edge. */ + .chevron-gap { + flex: 0 0 auto; + width: var(--lana-space-md); + } + + .group-name { + flex: 0 0 auto; + font-weight: 600; + } + + /* Whose frame, or whose class: metadata, so it gives way before the name. */ + .group-of, + .type { + min-width: 0; + overflow: hidden; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-size: var(--lana-text-sm); + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-of { + flex: 0 1 auto; + } + + .type { + flex: 0 0 auto; + margin-left: auto; + } + + .name { + flex: 0 0 auto; + font-family: var(--lana-font-mono); + } + + /* The value is the log's own text: truncated rather than wrapped, so one + variable stays one row. */ + .value { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* The log wrote no value here, and its absence is the reading: the name + was declared and never written, or the object was only ever an address. */ + .missing { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-size: var(--lana-text-sm); + font-style: italic; + } + + .key { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + } + + /* The object's class leads its value, as Chrome does it: what the object + *is* carries more than the muted text describing it. */ + .cls { + color: var(--lana-fg); + } + + /* Where the value was only an address, the object it names leads and the + address trails it, so the row reads as the object with its provenance. */ + .ref { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-size: var(--lana-text-sm); + } + + .count { + flex: 0 0 auto; + min-width: 1.4em; + border-radius: var(--lana-radius-md); + padding: 0 var(--lana-space-2xs); + background-color: var(--lana-badge-bg); + color: var(--lana-badge-fg); + font-size: var(--lana-text-sm); + font-variant-numeric: tabular-nums; + text-align: center; + } + + /* What the log said about the value rather than the value itself. */ + .chip { + flex: 0 0 auto; + border: var(--lana-stroke) solid var(--lana-surface-border); + border-radius: var(--lana-radius-md); + padding: 0 var(--lana-space-2xs); + color: var(--lana-fg-muted); + font-size: var(--lana-text-sm); + } + + .row.is-note { + color: var(--lana-fg-muted); + cursor: default; + } + + code-block { + min-width: 0; + flex: 1 1 auto; + } + `, + ]; + + /** The selection merges calls, so the section compares them rather than + * reading one frame. */ + private get _isAggregate(): boolean { + return (this.frames?.length ?? 0) > 1; + } + + /** + * The frame one reading is of. + * + * A merged row that comes down to a single frame is that frame, never the + * calls it counts: a bottom-up caller row counts its callee's calls, so + * `eventIndex` names one of those and reading it would show the called + * method's scope under a row that names the caller. + */ + private get _readIndex(): number { + return this.frames?.length === 1 ? this.frames[0]! : this.eventIndex; + } + + /** The index, but only once the log has a write in it. A log captured at + * FINEST can still record none, and reading every call to find that out + * costs seconds; one derivation, so the note and the walk cannot disagree. */ + private get _readable(): VariableIndex | null { + return this._index?.sawAnyWrite ? this._index : null; + } + + override willUpdate(changed: PropertyValues): void { + // Only what the selection is made of, so a disclosure or a key does not + // re-read the log. + const reselected = + changed.has('eventIndex') || + changed.has('frames') || + changed.has('logStore') || + changed.has('_index'); + if (reselected) { + this._views.clear(); + // An aggregate answers with none of these: reading the frame back through + // it costs tens of ms on a huge frame, and render() would throw it away + // unread. + this._frame = + !this._isAggregate && this.logStore && this._index + ? frameVariablesFor(this.logStore, this._readIndex, this._index) + : null; + // The comparison is resolved here, not in `updated`: a walk that runs a + // step later would let this pass rebuild and render the *previous* row's + // spread first, and repopulate the views with its cuts. + this._spread = this._compareKey() ? this._held() : null; + } + // A key press moves the tab stop and nothing else, so the rows it walks are + // rebuilt only when the scope or what is open changes. + if (reselected || changed.has('_spread') || changed.has('_disclosure')) { + this._rebuild(); + } + } + + /** The frames to compare, or null where the section reads one frame or the + * log records nothing to compare. */ + private _compareKey(): readonly number[] | null { + return this._isAggregate && this.logStore && this._readable ? this.frames : null; + } + + /** A comparison already walked, so a re-selection shows no placeholder. */ + private _held(): AggregateVariables | null { + const frames = this._compareKey(); + return frames ? (cachedAggregateVariables(frames) ?? null) : null; + } + + /** The rows on screen, and where each one sits, from the scope and what is + * open. Scanning a value is the cost here, so it is paid once. */ + private _rebuild(): void { + const isOpen = (id: string, byDefault: boolean): boolean => + this._disclosure.get(id) ?? byDefault; + const frame = this._frame; + this._rows = this._isAggregate + ? // Read through what is held: a walk that has yet to answer shows its + // placeholder, never the last row's spread. + this._spread + ? toSpreadRows(this._spread, isOpen, (cut) => this._viewAt(cut)) + : [] + : frame + ? toTreeRows(frame, isOpen, this._viewAt(frame.cut)) + : []; + this._at = new Map(this._rows.map((row, at) => [row.id, at])); + // Fixed here, so neither is rescanned on every render. + this._resolved = this._rows.some((row) => 'resolved' in row && row.resolved); + const spread = this._spread; + this._partlyMarked = + (spread?.locals.some(partlyMarked) || spread?.fields.some(partlyMarked)) ?? false; + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + // Comparing on into a detached host wastes frames and answers nobody. + this._walk?.abort(); + } + + /** The log bound to one point in it, held per point. */ + private _viewAt(cut: number): Lookups { + let held = this._views.get(cut); + if (!held) { + held = this._index?.viewAt(cut) ?? {}; + this._views.set(cut, held); + } + return held; + } + + override updated(changed: PropertyValues): void { + if (changed.has('logStore')) { + this._index = null; + this._readError = false; + this._disclosure = new Map(); + this._focused = null; + this._spreadKey = undefined; + this._spread = null; + void this._read(); + } + // Comparing reads every call, so only a changed selection - or one we have + // yet to resolve - earns the walk. + if (changed.has('frames') || changed.has('_index') || this._spreadKey === undefined) { + void this._compare(); + } + if (this._takeFocus) { + this._takeFocus = false; + // By place, not by id: an id embeds names the log wrote, and rows render + // in `_rows` order, so the place is exact and needs no escaping. + const at = this._focused !== null ? this._at.get(this._focused) : undefined; + if (at !== undefined) { + this.renderRoot.querySelectorAll('.row')[at]?.focus(); + } + } + } + + render() { + const log = this.logStore?.log; + if (!log) { + return nothing; + } + if (!recordsVariables(log)) { + return note('Variables available with the Apex Code log level at FINEST.'); + } + if (this._readError) { + return note('Could not read the log for variables.'); + } + if (!this._index) { + return note('Reading the log…'); + } + // A log can be captured at FINEST and still record no write, so this is not + // the same as a frame that had nothing in scope. + const index = this._readable; + if (!index) { + return note('This log records no variable assignments.'); + } + return this._isAggregate ? this._spreadTree(index) : this._frameTree(index); + } + + /** One frame: its locals, its object's fields and the statics in scope. */ + private _frameTree(index: VariableIndex): TemplateResult { + const frame = this._frame; + if (!frame || !this._rows.length) { + return note('The log records no variables in scope here.'); + } + return this._tree([frame.truncated && TRUNCATED_NOTE, index.capped && HELD_NOTE]); + } + + /** + * A merged row: what its calls held, compared. Which name varied is the + * reading, and every value it lists names the calls that held it. + */ + private _spreadTree(index: VariableIndex): TemplateResult { + const spread = this._spread; + if (!spread) { + return note(`Comparing ${callsHeld(this.frames?.length ?? 0)}…`); + } + return this._tree([ + spread.truncated && TRUNCATED_NOTE, + index.capped && HELD_NOTE, + spread.capped && `Over ${formatInteger(MAX_VALUES_PER_NAME)} values, so some are not listed.`, + this._partlyMarked && + `A value held by over ${formatInteger(MAX_MARKED_PER_VALUE)} calls marks that many of them.`, + this._resolved && RESOLVED_NOTE, + STATICS_NOTE, + ]); + } + + /** The rows, under whatever the scope has to say about them. */ + private _tree(notes: readonly (string | false)[]): TemplateResult { + // The tab stop follows the tree: a row that has gone hands it back. + const focused = + this._focused !== null && this._at.has(this._focused) + ? this._focused + : (this._rows[0]?.id ?? null); + + return html` + ${notes.map((text) => (text ? note(text) : ''))} +
+ ${this._rows.map((row) => this._render(row, row.id === focused))} +
+ `; + } + + /** Builds the index, which is the only walk of the whole log. */ + private async _read(): Promise { + const log = this.logStore?.log; + if (!log || !recordsVariables(log)) { + return; + } + try { + const index = await variableIndexFor(log); + // The log may have changed while the walk ran. + if (this.logStore?.log === log) { + this._index = index; + } + } catch { + // Left "Reading the log…" forever otherwise, with no error shown and no + // way to retry. + if (this.logStore?.log === log) { + this._readError = true; + } + } + } + + /** + * Compares the selected calls in frame-sized slices, so a wide selection never + * blocks the panel, and abandons a walk the selection has moved past. + */ + private async _compare(): Promise { + const store = this.logStore; + const index = this._readable; + const frames = this._compareKey(); + if (frames === this._spreadKey) { + return; + } + this._spreadKey = frames; + this._walk?.abort(); + const walk = (this._walk = new AbortController()); + if (!frames || !store || !index) { + return; + } + // `willUpdate` already answered from the memo, so only an unwalked + // selection reaches the walk. + if (this._spread) { + return; + } + const spread = await aggregateVariablesFor(store, frames, index, { signal: walk.signal }); + if (this._walk !== walk) { + return; + } + if (spread) { + this._spread = spread; + } else { + // Abandoned while the selection still stands - a disconnected host. Forget + // the key, so a later render compares again instead of waiting on a dead + // walk. + this._spreadKey = undefined; + } + } + + private _render(row: VariableTreeRow, focused: boolean): TemplateResult { + // A note is prose about the row above it, so it is read but never opened. + const isNote = row.kind === 'note'; + return html`
this._pick(row)} + @pointerenter=${() => this._hover(row, true)} + @pointerleave=${() => this._hover(row, false)} + > + ${row.expandable ? CHEVRON : html``}${this._body(row)} +
`; + } + + private _body(row: VariableTreeRow): TemplateResult | string { + switch (row.kind) { + case 'group': + return html` + ${row.name} + ${row.of ? html`${row.of}` : ''} + ${row.self ? this._value({ ...row.self, open: row.open }, row.self.declaredType) : ''} + + ${row.count} + ${typeColumn(row.self?.declaredType ?? null)}`; + case 'class': + return html` + ${row.className} + + ${row.count}`; + case 'variable': + return this._variable(row); + case 'spread': + return this._agreed(row); + case 'spread-many': + return this._varied(row); + case 'spread-value': + return html`${this._value(row, null)} + ${runsChip(row.held)} + ${formatInteger(row.held.calls)}`; + case 'entry': + return html` + ${row.key === null ? '·' : `${row.key}:`} + ${this._value(row, null)} + + ${partCount(row)}${chipFor(row.value)}`; + case 'text': + return html``; + case 'note': + return html`${row.text}`; + } + } + + private _variable(row: Extract): TemplateResult { + const variable = row.row; + return html` + ${variable.assigned ? `${variable.name}:` : variable.name} + ${ + variable.assigned + ? this._value(row, variable.declaredType) + : html`not assigned` + } + + ${partCount(row)}${chipFor(row.value)}${typeColumn(variable.declaredType)}`; + } + + /** A name every call agreed on: the value itself, so it reads and opens + * exactly as a single frame's row does. */ + private _agreed(row: Extract): TemplateResult { + const { name, declaredType } = row.row; + return html` + ${name}: + ${this._value(row, declaredType)} + + ${partCount(row)}${chipFor(row.value)}${this._spreadCounts(row.row)}${typeColumn( + declaredType, + )}`; + } + + /** A name the calls disagreed on: how many values they held, opening on them. */ + private _varied(row: Extract): TemplateResult { + const { name, declaredType, values, capped } = row.row; + return html` + ${name} + ${ + values.length + ? html`${capped ? `over ${formatInteger(MAX_VALUES_PER_NAME)}` : formatInteger(values.length)} + values` + : html`not assigned` + } + + ${this._spreadCounts(row.row)}${typeColumn(declaredType)}`; + } + + /** What the calls did with a name, beside the value: how many had it in scope, + * and how many declared it and never wrote it. */ + private _spreadCounts(spread: VariableSpread): TemplateResult { + const { calls, unassigned, values } = spread; + return html`${ + // Assigned by some calls and not by others, which neither the value nor + // the count says. + unassigned && values.length + ? html`${formatInteger(unassigned)} unassigned` + : '' + }${formatInteger(calls)}`; + } + + /** + * A value, and where it came from. + * + * One rule for an address: where the row's own text was only an address, that + * address trails the row, every time. What leads is the object the log + * recorded for it, or why it could not. + * + * Open, the rows below carry the value, so a preview here as well would print + * the same value twice. + */ + private _value(row: Valued, declaredType: string | null): TemplateResult { + const missing = this._missing(row); + return html`${this._slot(row, declaredType, missing)}${ + row.address + ? html`→ ${row.address}` + : '' + }`; + } + + /** What leads the row: nothing when open, why not where there is no object, + * else the object's class and the log's own text. */ + private _slot( + row: Valued, + declaredType: string | null, + missing: Missing | null, + ): TemplateResult | string { + if (row.open) { + return ''; + } + if (missing) { + return html`${missing.text}`; + } + // Left out where it matches the declared type: the type column says it. + const className = row.className && row.className !== declaredType ? row.className : null; + // An object the log wrote as `{}` previews the parts it opens on, or the row + // would read as empty while holding eight fields. + const shows = row.assembled ?? row.value; + return html`${ + className ? html`${lastSegment(className)} ` : '' + }${previewOf(shows)}`; + } + + /** + * Why a row shows no object, or null where it shows one. + * + * The address is only the identity the runtime printed for the reference. The + * object's contents reach the log as a separate event, and only where Apex + * assigned that object to a variable it could serialise. That event may land + * after the frame the reader picked, and often far from it, so the frame that + * holds it is named. + */ + private _missing(row: Valued): Missing | null { + // The log wrote no value for the whole object and still recorded its parts, + // which are what the row shows: saying it holds nothing would be false. + if (row.address === null || row.resolved || row.assembled) { + return null; + } + if (row.laterAt === null) { + return { + text: 'no value recorded', + why: 'The log holds no value for this address, at any point.', + }; + } + const stack = this.logStore?.stackByEventIndex(row.laterAt) ?? []; + const where = stack[stack.length - 1]?.text; + return { + text: 'recorded later', + why: `The log describes this object after this frame${where ? `, in ${where}` : ''}, so it may differ from the value here.`, + }; + } + + private _pick(row: VariableTreeRow): void { + if (row.kind === 'note') { + return; + } + this._focused = row.id; + // A picked value holds its mark while the pointer is elsewhere. No + // selection rides with it, so the panel keeps comparing: every other + // section answers a merged row with aggregated figures, and dropping onto + // one of its calls would throw away the reading the reader came for. + if (row.kind === 'spread-value') { + dispatchInspectorLocate(this, row.held.at, true); + } + if (row.expandable) { + this._toggle(row.id, !row.open); + } + } + + /** The calls a value row stands for, marked in the tab while the pointer is + * over it. Nothing is selected and no section changes. */ + private _hover(row: VariableTreeRow, over: boolean): void { + if (row.kind === 'spread-value') { + dispatchInspectorLocate(this, over ? row.held.at : []); + } + } + + /** The tree keyboard pattern: the arrows walk and open, nothing tabs away. */ + private _onKeyDown(event: KeyboardEvent): void { + const rows = this._rows; + // Nothing focused yet means the tab stop is on the first row, so a key moves + // from there rather than spending itself arriving. + const at = (this._focused !== null ? this._at.get(this._focused) : undefined) ?? 0; + const row = rows[at]; + if (!row) { + return; + } + // A note is read, never focused: it carries no tabindex, so landing the + // tab stop on one would leave the tree with none at all. + const move = (to: number): void => { + const found = nearestFocusable(rows, to, to >= at ? 1 : -1); + if (found) { + this._focused = found.id; + this._takeFocus = true; + } + }; + + switch (event.key) { + case 'ArrowDown': + move(at + 1); + break; + case 'ArrowUp': + move(at - 1); + break; + case 'ArrowRight': + // Open what is closed, then step into what is already open. + if (row.expandable && !row.open) { + this._toggle(row.id, true); + } else if (row.expandable) { + move(at + 1); + } + break; + case 'ArrowLeft': + // Close what is open, then step out to what holds it. + if (row.expandable && row.open) { + this._toggle(row.id, false); + } else { + const above = parentOf(rows, at); + if (above >= 0) { + move(above); + } + } + break; + case 'Home': + move(0); + break; + case 'End': + move(rows.length - 1); + break; + case 'Enter': + case ' ': + // What a click does: mark the calls that held the value, and open the + // row where it opens. A value is often a scalar with nothing to open, + // so toggling alone leaves the mark out of a keyboard's reach. + // Fires once: a repeat would flap an expandable row open and shut. + if (!event.repeat) { + this._pick(row); + } + break; + case '*': + // Fires once: every row at this depth is open after the first press. + if (!event.repeat) { + this._openAll(row.depth); + } + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } + + /** Opens every row at one depth, which is what `*` means in a tree. */ + private _openAll(depth: number): void { + const next = new Map(this._disclosure); + for (const row of this._rows) { + if (row.expandable && row.depth === depth) { + next.set(row.id, true); + } + } + this._disclosure = next; + } + + private _toggle(id: string, open: boolean): void { + const next = new Map(this._disclosure); + next.set(id, open); + this._disclosure = next; + this._focused = id; + this._takeFocus = true; + } +} + +/** + * The nearest row to `to` that can hold the tab stop, walking `step` first and + * the far end of the list only if that runs out. + * + * A note is read, never focused: it carries no tabindex, so a move that lands + * on one would leave the tree with no tab stop at all. + */ +function nearestFocusable( + rows: readonly VariableTreeRow[], + to: number, + step: 1 | -1, +): VariableTreeRow | undefined { + const clamped = Math.max(0, Math.min(rows.length - 1, to)); + for (let at = clamped; at >= 0 && at < rows.length; at += step) { + if (rows[at]!.kind !== 'note') { + return rows[at]; + } + } + for (let at = step > 0 ? rows.length - 1 : 0; at >= 0 && at < rows.length; at -= step) { + if (rows[at]!.kind !== 'note') { + return rows[at]; + } + } + return undefined; +} + +const CHEVRON = html``; + +/** Why a row shows no object: what it reads, and the sentence behind it. */ +interface Missing { + text: string; + why: string; +} + +/** + * Whether a value was a phase the calls passed through or one that came and + * went. + * + * Counts alone mislead: 328 of 340 calls reads as noise, and if those 328 are + * one unbroken run it is a state the calls were in. One call is trivially one + * run, so it says nothing. + */ +function runsChip(value: SpreadValue): TemplateResult | string { + if (value.calls < 2) { + return ''; + } + return value.runs === 1 + ? html`one run` + : html`${value.runs} runs`; +} + +/** A call count as prose. The same shape `SelfTimeSpreadView` uses, which is + * where a shared `plural` helper would live if a third caller wants one. */ +const callsHeld = (calls: number): string => + calls === 1 ? '1 call' : `${formatInteger(calls)} calls`; + +/** The declared type, in its own column, where the log gave one. */ +function typeColumn(declaredType: string | null): TemplateResult | string { + return declaredType ? html`${declaredType}` : ''; +} + +/** How many parts the row opens into, on every row that opens into parts, as a + * group row already carries. */ +function partCount(row: Shown & { expandable: boolean }): TemplateResult | string { + // A cycle, or the depth bound, leaves a row that cannot open: a count would + // promise rows the tree will not give. + if (!row.expandable || !row.parts.length) { + return ''; + } + // Assembled from writes of their own, or written on this line: the reader is + // owed the difference, since only the first can be as this frame stood. + const title = row.assembled + ? 'Fields the log recorded for this object, with any keys its own value held.' + : 'Properties the log wrote for this value.'; + return html`${row.parts.length}`; +} + +/** A qualified class as its own name: the row has no width for the namespace, + * and the hover carries it whole. */ +function lastSegment(className: string): string { + return className.slice(className.lastIndexOf('.') + 1); +} + +/** A name holds a value whose mark names fewer calls than held it. */ +function partlyMarked(row: VariableSpread): boolean { + return row.values.some((value) => value.at.length < value.calls); +} + +/** The frame or a caller ran past the end of a truncated log. Shared: it is the + * same fact at either scope. */ +const TRUNCATED_NOTE = + 'The log is truncated here, so a write may be unrecorded rather than absent.'; + +/** The index dropped writes past a cap, so an answer may be short of what the + * log recorded. Shared: it is the same fact at either scope. */ +const HELD_NOTE = 'Too many assignments to hold them all, so some values are missing.'; + +/** A comparison reads each value at the point its first call stood, so an + * object it opens is that one call's reading. */ +const RESOLVED_NOTE = + 'An object is shown as the first call that held it recorded it, so another call may have held a different one.'; + +/** Why a merged row's comparison stops at the locals and the fields. */ +export const STATICS_NOTE = + 'Statics are not compared: a static lives for the whole transaction, so it moves for reasons this row does not own.'; + +const WHY_RESOLVED = (address: string): string => + `The log wrote no value here. This is what it recorded for ${address}.`; + +function note(text: string): TemplateResult { + return html`

${text}

`; +} + +/** What the log said about a value that its text alone does not show. */ +function chipFor(value: VariableValue): TemplateResult | string { + // Read out of a string, so the rows below are not what the log serialised. + if (value.kind === 'container' && value.fromString) { + return html`json`; + } + if (value.kind === 'string' && value.toStringLike) { + return html`toString`; + } + if (value.kind === 'string' && value.truncated) { + return html`truncated`; + } + return ''; +} + +declare global { + interface HTMLElementTagNameMap { + 'variables-detail': VariablesDetail; + } +} diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 954865419..173cfac11 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -38,16 +38,17 @@ jest.mock('../scopedCallTree.js', () => ({ // Keep the real row readers: the hover test is about which rows name a frame. revealableEventIndex: jest.requireActual('../scopedCallTree.js').revealableEventIndex, locatableEventIndexes: jest.requireActual('../scopedCallTree.js').locatableEventIndexes, + frameEventIndexes: jest.requireActual('../scopedCallTree.js').frameEventIndexes, rowIdsByPath: jest.requireActual('../scopedCallTree.js').rowIdsByPath, })); -import { Tabulator } from 'tabulator-tables'; +import { Tabulator, type RowComponent } from 'tabulator-tables'; import type { CallTreeDetail } from '../CallTreeDetail.js'; import '../CallTreeDetail.js'; import { buildScopedCallTree, type ScopedCallTree, type ScopedRow } from '../scopedCallTree.js'; import { INSPECTOR_LOCATE_EVENT, type InspectorLocateEvent } from '../inspectorReveal.js'; -import { eventBus } from '../../core/events/EventBus.js'; +import { eventBus, type DetailSelection } from '../../core/events/EventBus.js'; import type { ProgressParams } from '../../tabulator/format/ProgressMS.js'; import { LOCATED_ROW_CLASS } from '../locatedRow.js'; import type { ApexLog } from 'apex-log-parser'; @@ -295,9 +296,8 @@ describe('CallTreeDetail scoped build', () => { const event = { eventIndex: 8, type: 'METHOD_ENTRY', namespace: '', text: 'm' }; const log = { eventsById: [] as unknown[], children: [] }; log.eventsById[8] = { ...event, parent: log, children: [] }; - const pathId = logStoreFor(log as unknown as ApexLog) - .keyPathIds() - .pathId(ROOT_PATH_ID, 'METHOD_ENTRY||m'); + const paths = logStoreFor(log as unknown as ApexLog).keyPathIds(); + const pathId = paths.step(ROOT_PATH_ID, paths.keyId('METHOD_ENTRY||m')); const merged = [ { id: -3, eventIndexes: [8, 12], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; @@ -339,9 +339,8 @@ describe('CallTreeDetail scoped build', () => { const log = { eventsById: [] as unknown[], children: [] }; log.eventsById[8] = { ...event, parent: log, children: [] }; log.eventsById[12] = { ...event, eventIndex: 12, parent: log, children: [] }; - const pathId = logStoreFor(log as unknown as ApexLog) - .keyPathIds() - .pathId(ROOT_PATH_ID, 'METHOD_ENTRY||m'); + const paths = logStoreFor(log as unknown as ApexLog).keyPathIds(); + const pathId = paths.step(ROOT_PATH_ID, paths.keyId('METHOD_ENTRY||m')); const merged = [ { id: -3, eventIndexes: [8], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; @@ -368,6 +367,34 @@ describe('CallTreeDetail scoped build', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); }); + it('names the picked bottom-up row, so one caller depth reads apart from the next', async () => { + build.mockImplementation((eventIndex) => Promise.resolve(tree(eventIndex * 1000))); + const el = await mount(5, 'callees'); + await frame(el); + const pick = tables.instances[0]!.on.mock.calls.find( + (call) => call[0] === 'rowSelectionChanged', + )?.[1] as ((...args: unknown[]) => void) | undefined; + + const seen: Array = []; + const located = (e: Event) => seen.push((e as InspectorLocateEvent).detail.selection); + document.addEventListener(INSPECTOR_LOCATE_EVENT, located); + + // A seed row and the two caller depths above it, which hold the same call. + const fakeRow = (data: unknown, parent?: unknown) => + ({ getData: () => data, getTreeParent: () => parent ?? false }) as unknown as RowComponent; + const seed = fakeRow({ id: -1, text: 'seed', eventIndexes: [8] }); + const depth2 = fakeRow({ id: -2, text: 'B', eventIndexes: [8] }, seed); + pick?.(null, [seed]); + pick?.(null, [depth2]); + pick?.(null, [fakeRow({ id: -3, text: 'A', eventIndexes: [8] }, depth2)]); + + document.removeEventListener(INSPECTOR_LOCATE_EVENT, located); + // The seed names its own calls; each caller depth names itself. + expect( + seen.map((selection) => (selection?.kind === 'aggregate' ? selection.calledBy : null)), + ).toEqual([undefined, 'B', 'A']); + }); + it('reports every occurrence the row under the pointer stands for', async () => { build.mockImplementation((eventIndex) => Promise.resolve(tree(eventIndex * 1000))); const el = await mount(5); diff --git a/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts new file mode 100644 index 000000000..decf37188 --- /dev/null +++ b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts @@ -0,0 +1,194 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { Tabulator } from 'tabulator-tables'; + +// The controller reads and writes settings through the extension host, which this +// suite answers for. +jest.mock('../../features/settings/Settings.js', () => ({ + getSettings: () => { + reads++; + return Promise.resolve(stored); + }, + updateSetting: (section: string, value: unknown) => written.push([section, value]), +})); + +import { ColumnSettingsController } from '../ColumnSettingsController.js'; +import { getVisibleFields, type ColumnView } from '../../tabulator/ColumnViews.js'; +import { FakeHost } from './controllerHostStub.js'; + +/** What the extension host would answer with. */ +let stored: object = {}; +/** How many times the host was asked. */ +let reads = 0; +/** Every `updateSetting` the controller made, in order. */ +let written: [string, unknown][] = []; + +const VIEWS: ColumnView[] = [ + { id: 'General', fields: ['text', 'namespace', 'rowCount'] }, + { id: 'Timing', fields: ['text', 'timeTaken'] }, +]; +const ALWAYS_VISIBLE = ['text']; +const FIELDS = ['text', 'namespace', 'rowCount', 'timeTaken']; + +class FakeColumn { + visible = true; + field: string; + + constructor(field: string) { + this.field = field; + } + + getField(): string { + return this.field; + } + isVisible(): boolean { + return this.visible; + } + show(): void { + this.visible = true; + } + hide(): void { + this.visible = false; + } + getDefinition(): { title: string } { + return { title: this.field }; + } +} + +/** @param laidOut a table a hidden tab never rendered reports no height. */ +function fakeTable(laidOut = true): Tabulator { + const columns = FIELDS.map((field) => new FakeColumn(field)); + return { + getColumns: () => columns, + redraw: () => {}, + element: { clientHeight: laidOut ? 100 : 0 }, + } as unknown as Tabulator; +} + +/** A connected controller over `table`, with settings already read. */ +async function connected( + table: Tabulator | null = fakeTable(), +): Promise<{ host: FakeHost; columns: ColumnSettingsController }> { + const host = new FakeHost(); + const columns = new ColumnSettingsController(host, { + section: 'database.soql', + read: (settings) => (settings as { database?: { soql?: object } }).database?.soql, + views: VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (table ? [table] : []), + }); + host.connect(); + await Promise.resolve(); + return { host, columns }; +} + +describe('ColumnSettingsController', () => { + beforeEach(() => { + stored = {}; + reads = 0; + written = []; + }); + + it('opens on the first view where nothing is stored', async () => { + const { columns } = await connected(); + + expect(columns.view).toBe('General'); + expect(columns.editedViews).toEqual([]); + }); + + it('opens on the stored view, under the stored overrides', async () => { + stored = { + database: { soql: { columnView: 'Timing', columnOverrides: { Timing: ['timeTaken'] } } }, + }; + const table = fakeTable(); + + const { columns } = await connected(table); + + expect(columns.view).toBe('Timing'); + expect(columns.editedViews).toEqual(['Timing']); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + }); + + it('falls back to the first view where the stored one is gone', async () => { + stored = { database: { soql: { columnView: 'Retired' } } }; + + const { columns } = await connected(); + + expect(columns.view).toBe('General'); + }); + + it('shows and remembers a chosen view', async () => { + const table = fakeTable(); + const { columns } = await connected(table); + + columns.choose('Timing'); + + expect(columns.view).toBe('Timing'); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + expect(written).toEqual([['database.soql.columnView', 'Timing']]); + }); + + it('takes a column out of the view on show, and remembers it', async () => { + const table = fakeTable(); + const { columns } = await connected(table); + + columns.toggle(table, 'rowCount'); + + expect(columns.editedViews).toEqual(['General']); + expect(getVisibleFields(table)).toEqual(['text', 'namespace']); + expect(written).toEqual([ + ['database.soql.columnOverrides', { General: ['text', 'namespace'] }], + ]); + }); + + it('gives a view back its built-in columns', async () => { + stored = { + database: { soql: { columnOverrides: { General: ['text'] } } }, + }; + const table = fakeTable(); + const { columns } = await connected(table); + expect(getVisibleFields(table)).toEqual(['text']); + + columns.reset(); + + expect(columns.editedViews).toEqual([]); + expect(getVisibleFields(table)).toEqual(['text', 'namespace', 'rowCount']); + expect(written).toEqual([['database.soql.columnOverrides', {}]]); + }); + + it('writes nothing for a reset of a view the user never edited', async () => { + const { columns } = await connected(); + + columns.reset(); + + expect(written).toEqual([]); + }); + + it('asks the host once, however often the view comes and goes', async () => { + const { host, columns } = await connected(); + expect(reads).toBe(1); + + columns.choose('Timing'); + host.disconnect(); + host.connect(); + await Promise.resolve(); + + // The keys are private globalState, so nothing pushes a change to re-read. + expect(reads).toBe(1); + expect(columns.view).toBe('Timing'); + }); + + it('leaves a table no tab has laid out to its next build', async () => { + const table = fakeTable(false); + const { columns } = await connected(table); + + columns.choose('Timing'); + + // Redrawing one of those throws, so the view waits for `applyTo`. + expect(getVisibleFields(table)).toEqual(FIELDS); + columns.applyTo(table); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + }); +}); diff --git a/log-viewer/src/components/__tests__/EventVitals.test.ts b/log-viewer/src/components/__tests__/EventVitals.test.ts index e43718f44..be3de3b91 100644 --- a/log-viewer/src/components/__tests__/EventVitals.test.ts +++ b/log-viewer/src/components/__tests__/EventVitals.test.ts @@ -114,16 +114,16 @@ describe('EventVitals', () => { expect(valueFor(el, 'Time')).toMatch(/^-?\d+\.\d{3} ms \(self -?\d+\.\d{3} ms\)$/); }); - it('reports a metric once, as used / limit with a percentage', async () => { - // SOSL rows are capped per query, so a single SOSL statement has a limit. + // SOSL rows are capped per query, so a single SOSL statement has a limit to qualify with. + it('reports a metric once, against the log with the limit as a qualifier', async () => { const el = await mount(store, { eventIndex: soslIndex, type: 'sosl' }); - expect(valueFor(el, 'SOSL Rows')).toBe('5 / 2,000 (0.25%)'); - // The limit is the denominator — never a second row repeating it. + expect(valueFor(el, 'SOSL Rows')).toBe('5 (0.25% of the 2,000 limit)'); + // The limit qualifies the reading — never a second row repeating it. expect(labels(el).filter((l) => /limit/i.test(l))).toEqual([]); expect(new Set(labels(el)).size).toBe(labels(el).length); }); - it('omits the limit when the metric has no transaction total', async () => { + it('gives the count alone where the selection is all the log consumed', async () => { const el = await mount(store, { eventIndex: dmlIndex, type: 'dml' }); expect(valueFor(el, 'DML Rows')).toBe('2'); }); @@ -145,6 +145,35 @@ describe('EventVitals', () => { expect(shown).not.toContain('Object rows'); }); + it('gives the selectivity verdict a chip, which the tier colours', async () => { + // The log above records no query plan, so the verdict needs one of its own. + const explained = parse( + '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + + '17:33:36.2 (1672655920)|SOQL_EXECUTE_BEGIN|[198]|Aggregations:0|SELECT Id FROM Account\n' + + '17:33:36.2 (1672700000)|SOQL_EXECUTE_EXPLAIN|[198]|Index on Account : [Id], cardinality: 1, sobjectCardinality: 1, relativeCost 0.65\n' + + '17:33:36.2 (1680000000)|SOQL_EXECUTE_BEGIN|[199]|Aggregations:0|SELECT Id FROM Contact\n' + + '17:33:36.2 (1680100000)|SOQL_EXECUTE_EXPLAIN|[199]|TableScan on Contact : [], cardinality: 9, sobjectCardinality: 9, relativeCost 2.5\n' + + '09:18:22.6 (7400000)|EXECUTION_FINISHED\n', + ); + const explainStore = logStoreFor(explained); + const indexOf = (query: string) => + explained.eventsById.find((e) => e.text === query)!.eventIndex; + + const selective = await mount(explainStore, { + eventIndex: indexOf('SELECT Id FROM Account'), + type: 'soql', + }); + const notSelective = await mount(explainStore, { + eventIndex: indexOf('SELECT Id FROM Contact'), + type: 'soql', + }); + + expect( + [selective, notSelective].map((el) => el.shadowRoot?.querySelector('.pill')?.className), + ).toEqual(['pill pill--yes', 'pill pill--no']); + expect(notSelective.shadowRoot?.querySelector('.pill')?.textContent).toBe('Not selective'); + }); + it('omits fields with no value', async () => { const el = await mount(store, { eventIndex: dmlIndex, type: 'dml' }); // A DML statement allocates no heap and throws nothing in this log. diff --git a/log-viewer/src/components/__tests__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts index 235075844..0068b4d1f 100644 --- a/log-viewer/src/components/__tests__/GovernorTrends.test.ts +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -25,8 +25,8 @@ import '../GovernorTrends.js'; const LOG_NS = 1_000; -const trend = (): TrendSeries => ({ - label: 'SOQL queries', +const trend = (label = 'SOQL queries'): TrendSeries => ({ + label, points: [ { t: 0, ratio: 0, used: 0 }, { t: 400, ratio: 40, used: 40 }, @@ -38,24 +38,37 @@ const trend = (): TrendSeries => ({ format: String, }); +const aLog = () => ({ log: { duration: { total: LOG_NS } } }) as unknown as LogStore; + async function mount(): Promise { const element = document.createElement('governor-trends'); // No provider in the test, so the consumed store is assigned straight on. - (element as unknown as { logStore: LogStore }).logStore = { - log: { duration: { total: LOG_NS } }, - } as unknown as LogStore; + (element as unknown as { logStore: LogStore }).logStore = aLog(); document.body.append(element); await element.updateComplete; return element; } /** The chart, given a width so a pointer x maps to a time. */ -function chartOf(element: LitElement): HTMLButtonElement { - const chart = element.shadowRoot!.querySelector('.trend__chart') as HTMLButtonElement; +function chartOf(element: LitElement, at = 0): HTMLButtonElement { + const chart = element.shadowRoot!.querySelectorAll('.trend__chart')[at] as HTMLButtonElement; chart.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 44 }) as DOMRect; return chart; } +/** A key press on a chart. */ +const press = (chart: HTMLButtonElement, key: string, init: KeyboardEventInit = {}) => + chart.dispatchEvent(new KeyboardEvent('keydown', { key, ...init })); + +/** A pointer over a chart, at an x the chart's width maps to a time. */ +const hover = (chart: HTMLButtonElement, clientX: number) => + chart.dispatchEvent(new MouseEvent('pointermove', { clientX })); + +/** A click. `detail` is 1 for a real pointer and 0 where there are no + * coordinates: assistive tech, or `click()`. */ +const click = (chart: HTMLButtonElement, clientX: number, detail = 1) => + chart.dispatchEvent(new MouseEvent('click', { clientX, detail })); + let seeks: { timestamp?: number; mode?: string }[]; let unsubscribe: () => void; @@ -73,7 +86,7 @@ describe('governor-trends', () => { it('moves the timeline to the instant clicked on a chart', async () => { const element = await mount(); - chartOf(element).dispatchEvent(new MouseEvent('click', { clientX: 60 })); + click(chartOf(element), 60); expect(seeks).toEqual([{ timestamp: 600, mode: 'seek' }]); }); @@ -131,10 +144,155 @@ describe('governor-trends', () => { expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); }); + it('reads the cursor when a click carries no coordinates', async () => { + const element = await mount(); + const chart = chartOf(element); + + press(chart, 'ArrowRight'); + click(chart, 0, 0); + + expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]); + }); + + it('answers the last sample when a click carries neither coordinates nor a cursor', async () => { + const element = await mount(); + + click(chartOf(element), 0, 0); + + // x 0 is the log's start, which is never where a limit stands highest. + expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); + }); + + it('holds a stepped cursor against a pointer on another chart', async () => { + series = [trend(), trend('DML statements')]; + const element = await mount(); + const [first, second] = [chartOf(element), chartOf(element, 1)]; + + press(first, 'ArrowRight'); + // The pointer rests on the second chart: its hover is not this chart's cursor. + hover(second, 40); + press(first, 'Enter'); + + expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]); + + // And losing that hover leaves the stepped cursor where it was. + second.dispatchEvent(new MouseEvent('pointerleave')); + press(first, 'Enter'); + + expect(seeks).toEqual([ + { timestamp: 20, mode: 'seek' }, + { timestamp: 20, mode: 'seek' }, + ]); + }); + + it('keeps stepping while the pointer rests on the chart', async () => { + const element = await mount(); + const chart = chartOf(element); + + hover(chart, 40); + press(chart, 'ArrowRight'); + press(chart, 'ArrowRight'); + press(chart, 'Enter'); + + // Two steps on from the hover, not the hover answered twice. + expect(seeks).toEqual([{ timestamp: 440, mode: 'seek' }]); + }); + + it('leaves one chart reading at a time after a click', async () => { + series = [trend(), trend('DML statements')]; + const element = await mount(); + const [first, second] = [chartOf(element), chartOf(element, 1)]; + + hover(first, 30); + click(first, 30); + first.dispatchEvent(new MouseEvent('pointerleave')); + hover(second, 40); + await element.updateComplete; + + expect(element.shadowRoot?.querySelectorAll('.trend__cursor')).toHaveLength(1); + }); + + it('steps on from the sample Enter answered', async () => { + const element = await mount(); + const chart = chartOf(element); + + press(chart, 'Enter'); + press(chart, 'ArrowLeft'); + press(chart, 'Enter'); + + // One step back from the last sample, not from the log's start. + expect(seeks).toEqual([ + { timestamp: 800, mode: 'seek' }, + { timestamp: 780, mode: 'seek' }, + ]); + }); + + it('drops the cursor when another log arrives', async () => { + const element = await mount(); + + press(chartOf(element), 'ArrowRight'); + (element as unknown as { logStore: LogStore }).logStore = aLog(); + await element.updateComplete; + press(chartOf(element), 'Enter'); + + // The label repeats across logs, so a kept cursor would seek the old point. + expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); + }); + + it('ignores a held Enter, so the flame chart is not re-zoomed', async () => { + const element = await mount(); + + press(chartOf(element), 'Enter', { repeat: true }); + + expect(seeks).toEqual([]); + }); + // A button, so the focus ring only shows for keyboard focus, never a click. it('gives every chart keyboard reach', async () => { const element = await mount(); expect(chartOf(element).tagName).toBe('BUTTON'); }); + + describe('a metric the log reported no limit for', () => { + beforeEach(() => { + series = [{ ...trend(), limit: 0, finalRatio: 0 }]; + }); + + // The whole-log figure is the peak, so a denominator would read "90 of 90". + it('names no denominator until the cursor names a moment', async () => { + const element = await mount(); + + expect(element.shadowRoot?.querySelector('.trend__limit')?.textContent?.trim()).toBe(''); + }); + + it('reads the sample under the cursor against the log\'s own peak, spelled "of"', async () => { + const element = await mount(); + + chartOf(element).dispatchEvent(new MouseEvent('pointermove', { clientX: 40 })); + await element.updateComplete; + + expect(element.shadowRoot?.querySelector('.trend__limit')?.textContent?.trim()).toBe('of 90'); + }); + + it('drops the 80% guide and the severity colour', async () => { + const element = await mount(); + + expect(element.shadowRoot?.querySelector('.trend__guide')).toBeNull(); + expect(element.shadowRoot?.querySelector('.trend--level')).not.toBeNull(); + expect(element.shadowRoot?.querySelector('.trend--danger')).toBeNull(); + }); + + it('still draws the shape', async () => { + const element = await mount(); + + expect(element.shadowRoot?.querySelector('.trend__line')?.getAttribute('d')).toBeTruthy(); + }); + + it('names the peak rather than a share of a limit', async () => { + const element = await mount(); + + expect(chartOf(element).getAttribute('aria-label')).toContain('no limit reported'); + }); + }); }); diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index b8df20b42..0f9f3b5d4 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -93,7 +93,6 @@ describe('hot-path', () => { 'self 0.001 ms (50.0%) \u00b7 0.001 ms (50.0%) below this frame \u00b7 the hot spot', ); // The hue is decorative, so the category is named in text a reader can hear. - expect(row?.querySelector('.reveal-row__swatch')).toBeNull(); expect(row?.querySelector('.reveal-row__sr')?.textContent).toBe('Apex'); }); @@ -290,6 +289,16 @@ describe('hot-path', () => { ]); }); + it('heads a truncated log with a caveat the shared warning glyph marks', async () => { + highlights = { ...pathOf(1), truncation: { regionCount: 2, firstEventIndex: 5 } }; + + const element = await hotPath(); + + const caveat = element.shadowRoot!.querySelector('.caveat-row')!; + expect(caveat.textContent).toContain('2 truncated calls'); + expect(caveat.querySelector('vscode-icon')?.className).toBe('sev-warning'); + }); + it('marks every merged instance of the row under the pointer', async () => { highlights = pathOf(1); highlights.hotPath[0]!.eventIndexes = [4, 9]; diff --git a/log-viewer/src/components/__tests__/HotSpots.test.ts b/log-viewer/src/components/__tests__/HotSpots.test.ts index 81a3ef9df..cd1ef73c4 100644 --- a/log-viewer/src/components/__tests__/HotSpots.test.ts +++ b/log-viewer/src/components/__tests__/HotSpots.test.ts @@ -68,7 +68,6 @@ describe('hot-spots', () => { // The bar runs to the 40% total share; half of it — the 20% self share — is solid. expect(row?.style.getPropertyValue('--self-pct')).toBe('50%'); // The hue is decorative, so the category is named in text a reader can hear. - expect(row?.querySelector('.reveal-row__swatch')).toBeNull(); expect(row?.querySelector('.reveal-row__sr')?.textContent).toBe('Apex'); expect( element.shadowRoot?.querySelector('.reveal-row__meter-fill')?.style.width, diff --git a/log-viewer/src/components/__tests__/InspectorTabController.test.ts b/log-viewer/src/components/__tests__/InspectorTabController.test.ts new file mode 100644 index 000000000..ad98721fd --- /dev/null +++ b/log-viewer/src/components/__tests__/InspectorTabController.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { eventBus } from '../../core/events/EventBus.js'; +import { InspectorTabController } from '../InspectorTabController.js'; +import { FakeHost } from './controllerHostStub.js'; + +/** Every mark the view was asked for, in order. */ +let marked: readonly number[][] = []; +let cleared = 0; + +function controllerFor(host: FakeHost): InspectorTabController { + return new InspectorTabController(host, 'analysis', { + mark: (eventIndexes) => { + marked = [...marked, [...eventIndexes]]; + }, + reveal: () => {}, + clear: () => { + cleared++; + }, + }); +} + +describe('InspectorTabController', () => { + let host: FakeHost; + let inspector: InspectorTabController; + + beforeEach(() => { + marked = []; + cleared = 0; + host = new FakeHost(); + inspector = controllerFor(host); + }); + + // The bus outlives the host, so a test that connects has to let go. + afterEach(() => { + host.disconnect(); + }); + + it('hears nothing until the host connects', () => { + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([]); + }); + + it('marks the frames the inspector points at', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1, 2], sticky: false }); + + expect(marked).toEqual([[1, 2]]); + }); + + it('keeps a picked row lit while the pointer is elsewhere', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + // The pointer leaves, which reports no frames of its own. + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [], sticky: false }); + + expect(marked).toEqual([[3], [3]]); + }); + + it('drops a pick the view no longer holds a selection for', () => { + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + + inspector.dropPick(); + + expect(marked).toEqual([[3], []]); + }); + + it('answers another tab for nothing', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([]); + }); + + it('stops at a detach and hears again after a re-attach', () => { + host.connect(); + host.disconnect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + expect(marked).toEqual([]); + + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([[1]]); + }); + + it('lights a pick again for a view that comes back', () => { + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + host.disconnect(); + marked = []; + + host.connect(); + + // The inspector still shows the row picked, so the view has to show it too. + expect(marked).toEqual([[3]]); + }); + + it('marks nothing for a view connecting with no pick to show', () => { + host.connect(); + + expect(marked).toEqual([]); + }); + + it('clears the view where the app-wide clear reaches its tab', () => { + host.connect(); + + eventBus.emit('selection:clear', { source: 'analysis' }); + + expect(cleared).toBe(1); + // The pick goes with the selection, so the mark goes out too. + expect(marked).toEqual([[]]); + }); +}); diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index a384a6630..333fdc0ca 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -38,13 +38,18 @@ jest.mock('../../features/settings/Settings.js', () => ({ // every other test leaves it false and gets an immediately-resolved build. let deferSections = false; const pendingSections: Array<() => void> = []; +// What each build was told to hide, so skipped work is provable and not just +// filtered away afterwards. +const builtHiding: string[][] = []; jest.mock('../detailSections.js', () => ({ buildDetailSections: ( _source: string, selection: { eventIndex?: number } | null, active: { kind: string; eventIndex?: number; instances?: number[] } | null, sourceView?: string, + hidden: ReadonlySet = new Set(), ) => { + builtHiding.push([...hidden]); const walked = active?.kind === 'event' ? String(active.eventIndex) : '-'; const counted = active?.kind === 'aggregate' ? (active.instances?.join(',') ?? '-') : '-'; // The markers carry the anchor and the active frame through to the rendered @@ -63,6 +68,8 @@ jest.mock('../detailSections.js', () => ({ { id: 'callstack', title: 'Call stack', content: html`
c
` }, ] : []; + // The real builder returns a hidden section too, so the header menu can + // still offer it; the panel leaves out what it does not show. if (!deferSections) { return Promise.resolve(sections); } @@ -78,6 +85,8 @@ import type { PaneView } from '../PaneView.js'; import type { ViewModeSwitch } from '../ViewModeSwitch.js'; import '../LogInspector.js'; import { dispatchInspectorLocate, dispatchInspectorReveal } from '../inspectorReveal.js'; +import type { ContextMenu } from '../ContextMenu.js'; +import { RESET_SECTIONS_ID } from '../sectionMenu.js'; /** * Settles the async section build and the render chain through the nested @@ -96,6 +105,11 @@ async function flush(el: LogInspector): Promise { await settle(el); } +/** The stored panel, open and docked right, plus whatever the test is about. */ +function inspectorSettings(overrides: Record = {}): Record { + return { position: 'right', size: 400, collapsed: {}, visible: true, ...overrides }; +} + async function mount(activeTab: string): Promise { const el = document.createElement('log-inspector') as LogInspector; el.activeTab = activeTab; @@ -140,6 +154,31 @@ function select(source: DetailSource, eventIndex: number): void { eventBus.emit('detail:select', { source, selection: { kind: 'event', eventIndex } }); } +/** The menu the inspector owns; the pane only reports the right-click. */ +function sectionMenu(el: LogInspector): ContextMenu { + const found = el.shadowRoot?.querySelector('context-menu'); + if (!found) { + throw new Error('context-menu not rendered'); + } + return found; +} + +function openSectionMenu(el: LogInspector, id = 'vitals'): void { + paneView(el).dispatchEvent( + new CustomEvent('pane-menu', { detail: { id, x: 10, y: 20 }, bubbles: true, composed: true }), + ); +} + +function pickMenuItem(el: LogInspector, itemId: string): void { + sectionMenu(el).dispatchEvent( + new CustomEvent('menu-select', { detail: { itemId }, bubbles: true, composed: true }), + ); +} + +function sectionIds(el: LogInspector): string[] { + return paneView(el).sections.map((section) => section.id); +} + function marker(el: LogInspector): string | null { return paneView(el).shadowRoot?.querySelector('.marker')?.textContent ?? null; } @@ -182,29 +221,26 @@ describe('LogInspector', () => { releaseSettings = null; deferSections = false; pendingSections.length = 0; + builtHiding.length = 0; document.body.replaceChildren(); }); - it('applies the persisted collapse, and keeps it when the tab changes', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: { callstack: true }, - paneSizes: {}, - visible: true, - }; + it('applies the persisted collapse to the list it was made in', async () => { + settings.inspector = inspectorSettings({ + collapsed: { 'database:detail:callstack': true }, + }); const el = await mount('database-tab'); select('database', 3); await flush(el); expect(paneView(el).collapsed).toEqual({ callstack: true }); - // One panel, one layout: a section's collapse follows it across tabs. + // One id means different content in two lists, so the collapse stays in its own. el.activeTab = 'timeline-tab'; select('timeline', 9); await flush(el); - expect(paneView(el).collapsed).toEqual({ callstack: true }); + expect(paneView(el).collapsed).toEqual({}); }); it('persists a collapse', async () => { @@ -220,7 +256,255 @@ describe('LogInspector', () => { }), ); - expect(written).toEqual([{ section: 'inspector.collapsed', value: { callstack: true } }]); + expect(written).toEqual([ + { section: 'inspector.collapsed', value: { 'timeline:detail:callstack': true } }, + ]); + }); + + it('persists a reorder, and applies it to the list on screen', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['vitals', 'callstack']); + + paneView(el).dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids: ['callstack', 'vitals'] }, + bubbles: true, + composed: true, + }), + ); + await settle(el); + + expect(written).toEqual([ + { section: 'inspector.sectionOrder', value: { 'timeline:detail': ['callstack', 'vitals'] } }, + ]); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['callstack', 'vitals']); + }); + + it('keeps a section this selection never built in the order it stores', async () => { + // Arranged while a SOQL statement was selected, so the store names a + // section the timeline's list does not build. + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['issues', 'vitals', 'callstack'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + paneView(el).dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids: ['callstack', 'vitals'] }, + bubbles: true, + composed: true, + }), + ); + await settle(el); + + expect(written).toEqual([ + { + section: 'inspector.sectionOrder', + value: { 'timeline:detail': ['issues', 'callstack', 'vitals'] }, + }, + ]); + }); + + it('hides a section from the menu, and stops building it', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + + openSectionMenu(el); + pickMenuItem(el, 'section:callstack'); + await flush(el); + + expect(written).toEqual([ + { section: 'inspector.hiddenSections', value: { 'timeline:detail:callstack': true } }, + ]); + expect(sectionIds(el)).toEqual(['vitals']); + + // Hiding only drops it from the list; every build from here is told to skip + // it, so its content is never built again. + select('timeline', 2); + await flush(el); + expect(builtHiding.at(-1)).toEqual(['callstack']); + }); + + it('offers a hidden section back, and hides it in that list only', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { 'timeline:detail:callstack': true }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals']); + + // Another list keeps the section. + el.activeTab = 'database-tab'; + select('database', 2); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + + // The menu still offers the hidden one, so it can come back. + el.activeTab = 'timeline-tab'; + await flush(el); + openSectionMenu(el); + expect(sectionMenu(el).items.map((item) => [item.id, item.checked])).toEqual([ + [RESET_SECTIONS_ID, undefined], + ['section-sep', undefined], + ['section:vitals', true], + ['section:callstack', false], + ]); + + pickMenuItem(el, 'section:callstack'); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(written.at(-1)).toEqual({ section: 'inspector.hiddenSections', value: {} }); + }); + + it('shows every section again when the stored set would hide them all', async () => { + settings.inspector = inspectorSettings({ + // A list whose sections have changed since: the panel must not end up with + // no header to right-click, since that menu is the only way back. + hiddenSections: { + 'timeline:detail:vitals': true, + 'timeline:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + openSectionMenu(el); + expect(sectionMenu(el).items.filter((item) => item.checked === false)).toEqual([]); + }); + + it('keeps a section hidden when the toggle lands mid-build', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + // A build is in flight, holding the set it was told to skip from before it + // awaited; the toggle must not be undone when that build lands. + deferSections = true; + select('timeline', 2); + await new Promise((resolve) => requestAnimationFrame(resolve)); + openSectionMenu(el); + pickMenuItem(el, 'section:callstack'); + await settle(el); + expect(sectionIds(el)).toEqual(['vitals']); + + pendingSections[0]!(); + await settle(el); + + expect(sectionIds(el)).toEqual(['vitals']); + }); + + it('re-ticks the menu from the layout a slow rebuild produced', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { 'timeline:detail:callstack': true }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + openSectionMenu(el); + + // Bringing it back rebuilds, because its build skipped work. The row must + // read from that rebuild, not from the state before the click. + deferSections = true; + pickMenuItem(el, 'section:callstack'); + await settle(el); + pendingSections[0]!(); + await settle(el); + + expect( + sectionMenu(el) + .items.filter((item) => item.id.startsWith('section:')) + .map((item) => [item.id, item.checked]), + ).toEqual([ + ['section:vitals', true], + ['section:callstack', true], + ]); + }); + + it('rebuilds the sections it un-hides when the stored set hid them all', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { + 'timeline:detail:vitals': true, + 'timeline:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + // Their first build was told to skip them, so what it left out has to be + // built again rather than shown empty. + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(builtHiding.at(-1)).toEqual([]); + }); + + it('resets a list that only has an order, back to the order it is built in', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['callstack', 'vitals']); + + openSectionMenu(el, 'callstack'); + pickMenuItem(el, RESET_SECTIONS_ID); + await flush(el); + + // Nothing was hidden, so nothing needs rebuilding — the built order still + // has to come back. + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(paneView(el).layoutEpoch).toBe(1); + }); + + it('resets this list only: built order, every section back, sizes automatic', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + hiddenSections: { + 'timeline:detail:vitals': true, + 'analysis:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['callstack']); + + openSectionMenu(el, 'callstack'); + pickMenuItem(el, RESET_SECTIONS_ID); + await flush(el); + + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(written).toEqual([ + { section: 'inspector.sectionOrder', value: {} }, + // The Analysis list's own choice is untouched. + { section: 'inspector.hiddenSections', value: { 'analysis:detail:callstack': true } }, + ]); + // The panes' dragged sizes go with it. + expect(paneView(el).layoutEpoch).toBe(1); + }); + + it('applies a persisted order to its own list only', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['callstack', 'vitals']); + + el.activeTab = 'database-tab'; + select('database', 2); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['vitals', 'callstack']); }); it('auto-opens on the first selection only while the user has never chosen', async () => { @@ -231,13 +515,9 @@ describe('LogInspector', () => { }); it('stays closed when the user closed it before, and remembers each choice', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, + settings.inspector = inspectorSettings({ visible: false, - }; + }); const el = await mount('timeline-tab'); select('timeline', 1); await el.updateComplete; @@ -250,13 +530,10 @@ describe('LogInspector', () => { }); it('keeps what the user did while the settings load was still in flight', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: { callstack: true }, - paneSizes: {}, + settings.inspector = inspectorSettings({ + collapsed: { 'timeline:detail:callstack': true }, visible: false, - }; + }); deferSettings = true; const el = document.createElement('log-inspector') as LogInspector; el.activeTab = 'timeline-tab'; @@ -395,6 +672,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); @@ -421,6 +699,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); @@ -436,6 +715,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); @@ -474,13 +754,7 @@ describe('LogInspector', () => { }); it('shows a source-specific empty state, and updates it as the active tab changes', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, - visible: true, - }; + settings.inspector = inspectorSettings(); const el = await mount('timeline-tab'); expect(emptyText(el)).toBe('Select a frame on the timeline to inspect it.'); @@ -498,13 +772,7 @@ describe('LogInspector', () => { }); it('returns to the whole-log empty state when a null selection clears the source', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, - visible: true, - }; + settings.inspector = inspectorSettings(); const el = await mount('timeline-tab'); select('timeline', 1); await flush(el); diff --git a/log-viewer/src/components/__tests__/LogOverview.test.ts b/log-viewer/src/components/__tests__/LogOverview.test.ts index ebaf39c23..e1a8dc78a 100644 --- a/log-viewer/src/components/__tests__/LogOverview.test.ts +++ b/log-viewer/src/components/__tests__/LogOverview.test.ts @@ -39,57 +39,45 @@ describe('log-overview', () => { mockSeries = timeSeries(); }); - it('says the totals are unknown while no log holds cumulative limits', async () => { + const noLog = { ...emptyLimits(), byNamespace: new Map(), snapshots: [] } as GovernorLimits; + + const seriesWithSoql = (limit: number): HeatStripTimeSeries => + timeSeries([seriesEvent(1_000, { soqlQueries: { used: 40, limit } })]); + + it('says no log is loaded, not that a log recorded nothing', async () => { const element = await overview(); - expect(element.shadowRoot?.querySelector('.note')?.textContent).toContain( - 'CUMULATIVE_LIMIT_USAGE', - ); + expect(element.shadowRoot?.querySelector('.note')?.textContent).toContain('No log is loaded'); expect(element.shadowRoot?.querySelector('governor-summary')).toBeNull(); }); - const seriesWithSoql = (): HeatStripTimeSeries => - timeSeries([seriesEvent(1_000, { soqlQueries: { used: 40, limit: 100 } })]); - - it('shows the series gauges without a note while the log holds snapshots', async () => { + it('shows the gauges without a note while the log reports a limit', async () => { const element = await overview(); - mockSeries = seriesWithSoql(); - await loadLog(element, { - ...emptyLimits(), - byNamespace: new Map(), - snapshots: [{ timestamp: 1_000, namespace: 'default', limits: emptyLimits() }], - } as GovernorLimits); + mockSeries = seriesWithSoql(100); + await loadLog(element, noLog); expect(element.shadowRoot?.querySelector('governor-summary')).not.toBeNull(); expect(element.shadowRoot?.querySelector('.note')).toBeNull(); }); - it('says the figures are estimated when cumulative limits are absent', async () => { + // Figures still show where the log reported no limit; the gauge says so on hover, so the strip + // carries no note of its own. + it('shows the gauges with no note when the log reported no limits', async () => { const element = await overview(); - mockSeries = seriesWithSoql(); - await loadLog(element, { - ...emptyLimits(), - byNamespace: new Map(), - snapshots: [], - } as GovernorLimits); + mockSeries = seriesWithSoql(0); + await loadLog(element, noLog); expect(element.shadowRoot?.querySelector('governor-summary')).not.toBeNull(); - expect(element.shadowRoot?.querySelector('.note')?.textContent).toContain('estimated'); + expect(element.shadowRoot?.querySelector('.note')).toBeNull(); }); - it('says the totals are unknown when the series itself is empty', async () => { + it('says nothing was recorded when the series itself is empty', async () => { const element = await overview(); - await loadLog(element, { - ...emptyLimits(), - byNamespace: new Map(), - snapshots: [], - } as GovernorLimits); + await loadLog(element, noLog); expect(element.shadowRoot?.querySelector('governor-summary')).toBeNull(); - expect(element.shadowRoot?.querySelector('.note')?.textContent).toContain( - 'CUMULATIVE_LIMIT_USAGE', - ); + expect(element.shadowRoot?.querySelector('.note')?.textContent).toContain('no governor usage'); }); }); diff --git a/log-viewer/src/components/__tests__/PaneView.test.ts b/log-viewer/src/components/__tests__/PaneView.test.ts index 3f5c2ba51..c12c7e108 100644 --- a/log-viewer/src/components/__tests__/PaneView.test.ts +++ b/log-viewer/src/components/__tests__/PaneView.test.ts @@ -22,36 +22,75 @@ const sections: PaneSection[] = [ * Collapse is controlled: the consumer owns the record and feeds it back. Mount * with that loop wired, the way the inspector does. */ -async function mount(orientation: PaneOrientation): Promise { +async function mountSections( + paneSections: PaneSection[], + props: Partial = {}, +): Promise { const el = document.createElement('pane-view') as PaneView; - el.sections = sections; - el.orientation = orientation; + Object.assign(el, { orientation: 'vertical', sections: paneSections }, props); + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +/** With the collapse loop wired, the way the inspector owns the record. */ +async function mount(orientation: PaneOrientation): Promise { + const el = await mountSections(sections, { orientation }); el.addEventListener('pane-toggle', (e) => { el.collapsed = (e as CustomEvent<{ collapsed: Record }>).detail.collapsed; }); - document.body.appendChild(el); - await el.updateComplete; return el; } -function header(el: PaneView, id: string): HTMLElement | null { - return el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-header`) ?? null; +function pane(el: PaneView, id: string): HTMLElement | null { + return el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`) ?? null; +} + +function paneIds(el: PaneView): string[] { + return [...(el.shadowRoot?.querySelectorAll('.pane') ?? [])].map( + (found) => found.dataset.id ?? '', + ); } function body(el: PaneView, id: string): HTMLElement | null { return el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-body`) ?? null; } -function sash(el: PaneView): HTMLElement { - const found = el.shadowRoot?.querySelector('.pane-sash'); +function sash(el: PaneView, index = 0): HTMLElement { + const found = el.shadowRoot?.querySelectorAll('.pane-sash')[index]; if (!found) { throw new Error('sash not rendered'); } return found as HTMLElement; } -function paneStyle(el: PaneView, id: string): string { - return el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`)?.getAttribute('style') ?? ''; +/** + * What the pane hands the stylesheet: the rule it selects, then its weight and + * the size it was dragged to. The flex itself is in CSS, which jsdom does not + * compute. + */ +function sizing(el: PaneView, id: string): string { + const found = pane(el, id); + if (!found) { + throw new Error(`no pane for ${id}`); + } + if (!found.hasAttribute('data-open')) { + return 'closed'; + } + const parts = [found.getAttribute('data-tier') ?? found.getAttribute('data-sizing') ?? '']; + for (const name of ['--pane-grow', '--pane-size']) { + const value = found.style.getPropertyValue(name); + if (value) { + parts.push(value); + } + } + return parts.join(' '); +} + +/** The share the fill panes resolve from, or null while they resolve from zero. */ +function share(el: PaneView): string | null { + const view = stack(el); + return view.hasAttribute('data-content') ? view.style.getPropertyValue('--pane-count') : null; } // jsdom has no PointerEvent; the handlers only read the coordinate and pointerId. @@ -61,6 +100,54 @@ function pointer(type: string, clientY: number): Event { }); } +function headerOf(el: PaneView, id: string): HTMLElement { + const found = el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-header`); + if (!found) { + throw new Error(`no header for ${id}`); + } + return found; +} + +// jsdom has no DragEvent; the handlers only read the coordinate, and treat a +// missing dataTransfer as nothing to carry. +function dragEvent(type: string, clientY = 0): Event { + return new MouseEvent(type, { clientY, bubbles: true, cancelable: true }); +} + +/** The headers are 20px tall here: above 10 drops before, below it drops after. */ +function stack(el: PaneView): HTMLElement { + const found = el.shadowRoot?.querySelector('.pane-view'); + if (!found) { + throw new Error('stack not rendered'); + } + return found; +} + +/** The whole stack takes the drop, so only where the pointer is decides. */ +async function dragSection(el: PaneView, fromId: string, clientY: number): Promise { + headerOf(el, fromId).dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', clientY)); + await el.updateComplete; + stack(el).dispatchEvent(dragEvent('drop', clientY)); + await el.updateComplete; +} + +function reorders(el: PaneView): string[][] { + const seen: string[][] = []; + el.addEventListener('pane-reorder', (e) => { + seen.push((e as CustomEvent<{ ids: string[] }>).detail.ids); + }); + return seen; +} + +/** Every pane measures 100px in jsdom, so a +20 drag makes the pair 120/80. */ +async function drag(el: PaneView, handle: HTMLElement, to = 120): Promise { + handle.dispatchEvent(pointer('pointerdown', 100)); + handle.dispatchEvent(pointer('pointermove', to)); + handle.dispatchEvent(pointer('pointerup', to)); + await el.updateComplete; +} + describe('PaneView', () => { beforeAll(() => { expect(customElements.get('pane-view')).toBeDefined(); @@ -73,6 +160,26 @@ describe('PaneView', () => { value: 100, configurable: true, }); + // jsdom performs no layout either, so the panes need boxes before a drop + // coordinate can name one. Stacked 100 tall, matching `offsetHeight`: `a` + // is 0-100, `b` 100-200, `c` 200-300. + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const isPane = this.classList.contains('pane'); + const top = isPane + ? [...(this.parentElement?.querySelectorAll('.pane') ?? [])].indexOf(this) * 100 + : 0; + const height = isPane ? 100 : 20; + return { + top, + left: 0, + width: 200, + height, + bottom: top + height, + right: 200, + x: 0, + y: top, + } as unknown as DOMRect; + }; }); it('renders a header per section with a twistie when vertical', async () => { @@ -92,7 +199,7 @@ describe('PaneView', () => { it('collapses a section on header click, removing its body and its sashes', async () => { const el = await mount('vertical'); - header(el, 'b')?.click(); + headerOf(el, 'b').click(); await el.updateComplete; expect(body(el, 'b')).toBeNull(); @@ -105,19 +212,47 @@ describe('PaneView', () => { it('toggles with the keyboard (Enter)', async () => { const el = await mount('vertical'); - const h = header(el, 'a'); - h?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const h = headerOf(el, 'a'); + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await el.updateComplete; + expect(body(el, 'a')).toBeNull(); + }); + + it('ignores a held Enter, so the pane does not flap', async () => { + const el = await mount('vertical'); + const h = headerOf(el, 'a'); + let toggles = 0; + el.addEventListener('pane-toggle', () => toggles++); + + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, repeat: true })); + h.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, repeat: true })); await el.updateComplete; + + expect(toggles).toBe(1); expect(body(el, 'a')).toBeNull(); }); + it('keeps a repeated Space from scrolling the stack', async () => { + const el = await mount('vertical'); + const event = new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + repeat: true, + }); + headerOf(el, 'a').dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + it('does not collapse in horizontal mode and keeps all panes open', async () => { const el = await mount('horizontal'); // No twistie, headers are not buttons. expect(el.shadowRoot?.querySelectorAll('.pane-header vscode-icon').length).toBe(0); expect(el.shadowRoot?.querySelector('.pane-header--button')).toBeNull(); - header(el, 'b')?.click(); + headerOf(el, 'b').click(); await el.updateComplete; expect(body(el, 'b')).not.toBeNull(); // All three open → two sashes between neighbours. @@ -131,7 +266,7 @@ describe('PaneView', () => { last = (e as CustomEvent<{ collapsed: Record }>).detail.collapsed; }); - header(el, 'a')?.click(); + headerOf(el, 'a').click(); await el.updateComplete; expect(last?.a).toBe(true); expect(body(el, 'a')).toBeNull(); @@ -144,63 +279,129 @@ describe('PaneView', () => { }); it('takes collapse from the collapsed property', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = sections; + const el = await mountSections(sections); el.collapsed = { b: true }; - document.body.appendChild(el); await el.updateComplete; expect(body(el, 'a')).not.toBeNull(); expect(body(el, 'b')).toBeNull(); }); it('does not collapse when the consumer ignores pane-toggle (fully controlled)', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = sections; - document.body.appendChild(el); - await el.updateComplete; + const el = await mountSections(sections); - header(el, 'a')?.click(); + headerOf(el, 'a').click(); await el.updateComplete; expect(body(el, 'a')).not.toBeNull(); }); - it('emits pane-resize on a sash drag, the pair sharing their combined size', async () => { + it('shares the pair their combined size on a sash drag', async () => { const el = await mount('vertical'); - let sizes: Record | undefined; - el.addEventListener('pane-resize', (e) => { - sizes = (e as CustomEvent<{ sizes: Record }>).detail.sizes; - }); + await drag(el, sash(el)); - const handle = sash(el); + // The dragged size is the basis, so the pane is that size however tight the + // panel gets; the weight rescaled onto the unit scale shares any free space. + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + expect(sizing(el, 'b')).toBe('fill 0.8 80px'); + }); + + it('holds every pane at its measured size, through the drag and after it', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + // Mid-gesture, with `a` nowhere near the b-c sash the drag names. + const handle = sash(el, 1); handle.dispatchEvent(pointer('pointerdown', 100)); handle.dispatchEvent(pointer('pointermove', 120)); + await el.updateComplete; + + // Its basis is its measured size, not its content: the bases add up to the + // panel, so flexbox shrinks nothing and the boundary tracks the pointer. + expect(sizing(el, 'a')).toBe('content 100px'); + handle.dispatchEvent(pointer('pointerup', 120)); await el.updateComplete; - // Keyed by axis: a height dragged here is not a width in the bottom dock. - expect(sizes?.['vertical:a']).toBe(120); - expect(sizes?.['vertical:b']).toBe(80); + // And keeps it: handing a pane back to sizing itself frees the room it holds + // to the panes the drag sized, which shrinks a section nobody dragged. + expect(sizing(el, 'a')).toBe('content 100px'); + }); + + it('leaves the panes away from the sash at the size they had', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + // The b-c sash, so the drag never names the content pane above it. + await drag(el, sash(el, 1)); + + expect(sizing(el, 'a')).toBe('content 100px'); + expect(sizing(el, 'b')).toBe('fill 1.2 120px'); + expect(sizing(el, 'c')).toBe('fill 0.8 80px'); + }); + + it('rescales the dragged sizes onto the unit scale the weights use', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, weight: 3 }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + await drag(el, sash(el, 1)); + + // Every open pane holds a size, so the sizes decide how free space splits; + // the weights are the scale those sizes land on. + expect(sizing(el, 'a')).toBe('fill 1.6666666666666667 100px'); + expect(sizing(el, 'b')).toBe('fill 2 120px'); + expect(sizing(el, 'c')).toBe('fill 1.3333333333333333 80px'); + }); + + it('leaves no size behind for a drag that ends where it began', async () => { + const el = await mount('vertical'); + const handle = sash(el); + handle.dispatchEvent(pointer('pointerdown', 100)); + handle.dispatchEvent(pointer('pointermove', 140)); + handle.dispatchEvent(pointer('pointermove', 100)); + handle.dispatchEvent(pointer('pointerup', 100)); + await el.updateComplete; + + // Nothing moved on screen, so nothing is pinned: the sections go on sizing + // themselves. + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'b')).toBe('fill 1'); + }); + + it('hands the stack back when a section opens with no size of its own', async () => { + const el = await mount('vertical'); + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + + el.sections = [...sections, { id: 'd', title: 'D', content: html`
D
` }]; + await el.updateComplete; + + // The newcomer has no size, and the sized panes leave it no share: it would + // open at its floor, so every section shares the panel again. + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'd')).toBe('fill 1'); }); - it('does not emit pane-resize for a sash click that never moved', async () => { + it('leaves the sizes alone for a sash click that never moved', async () => { const el = await mount('vertical'); - let emitted = 0; - el.addEventListener('pane-resize', () => emitted++); const handle = sash(el); handle.dispatchEvent(pointer('pointerdown', 100)); handle.dispatchEvent(pointer('pointerup', 100)); await el.updateComplete; - expect(emitted).toBe(0); + expect(sizing(el, 'a')).toBe(sizing(el, 'c')); }); - it('does not emit pane-resize when the drag is cancelled, and restores the sizes', async () => { + it('restores the sizes when the drag is cancelled', async () => { const el = await mount('vertical'); - let emitted = 0; - el.addEventListener('pane-resize', () => emitted++); const handle = sash(el); handle.dispatchEvent(pointer('pointerdown', 100)); @@ -208,149 +409,234 @@ describe('PaneView', () => { handle.dispatchEvent(pointer('pointercancel', 120)); await el.updateComplete; - expect(emitted).toBe(0); // Back to the measured 100/100, so a-and-b weigh the same again. - const style = (id: string) => - el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`)?.getAttribute('style'); - expect(style('a')).toBe(style('b')); + expect(sizing(el, 'a')).toBe(sizing(el, 'b')); // The gesture is over: a stray move can no longer resize. handle.dispatchEvent(pointer('pointermove', 200)); await el.updateComplete; - expect(style('a')).toBe(style('b')); + expect(sizing(el, 'a')).toBe(sizing(el, 'b')); }); - it('keeps the persisted ratio when every open pane has a size', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
A
` }, - { id: 'b', title: 'B', content: html`
B
` }, - ]; - el.paneSizes = { 'vertical:a': 300, 'vertical:b': 100 }; - document.body.appendChild(el); + // A height dragged down the side is not a width along the bottom, and a reset + // hands every size back: one guard in `willUpdate`, both its arms. + it.each([ + ['the panel is re-docked', (el: PaneView) => (el.orientation = 'horizontal')], + ['the layout is reset', (el: PaneView) => el.layoutEpoch++], + ])('drops the dragged sizes when %s', async (_name, change) => { + const el = await mount('vertical'); + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + + change(el); await el.updateComplete; - // 2 units over 400px → 3:1, the dragged ratio. - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 1.5 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 0.5 1 0'); + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'b')).toBe('fill 1'); }); - it('rescales persisted pane sizes onto the weight scale of a pane without one', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
A
`, weight: 3 }, + it('sizes a content pane to its content, shrinkable, and never stretches it', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, { id: 'b', title: 'B', content: html`
B
` }, - ]; - // Only a was on screen when the drag happened; b must not become a sliver. - el.paneSizes = { 'vertical:a': 120 }; - document.body.appendChild(el); - await el.updateComplete; + ]); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 3 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 0'); + expect(sizing(el, 'a')).toBe('content'); + expect(sizing(el, 'b')).toBe('fill 1'); + expect(share(el)).toBe('2'); }); - it('ignores sizes dragged on the other axis, and re-seeds when re-docked', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
A
` }, + it('renders a sash beside a content pane too', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, { id: 'b', title: 'B', content: html`
B
` }, - ]; - // Widths from the bottom dock; replaying them as heights is a layout the - // user never chose, so the vertical dock falls back to even weights. - el.paneSizes = { 'horizontal:a': 300, 'horizontal:b': 100 }; - document.body.appendChild(el); - await el.updateComplete; + { id: 'c', title: 'C', content: html`
C
` }, + ]); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 1 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 0'); + // a↔b and b↔c: a content pane holds the size it is dragged to. + expect(el.shadowRoot?.querySelectorAll('.pane-sash').length).toBe(2); + }); - // Re-docking flips orientation on the same element: its own sizes apply now. - el.orientation = 'horizontal'; - await el.updateComplete; - expect(pane('a')?.getAttribute('style')).toContain('flex: 1.5 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 0.5 1 0'); + it('pins a content pane to its dragged size, and keeps it out of the fill scale', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + await drag(el, sash(el)); + + // A basis, not a weight: it never stretches, and it still shrinks to scroll. + expect(sizing(el, 'a')).toBe('content 120px'); + // The content pane's size is no part of the fill panes' weights, so b and c + // split what is left between the two of them. + expect(sizing(el, 'b')).toBe('fill 0.888888888888889 80px'); + expect(sizing(el, 'c')).toBe('fill 1.1111111111111112 100px'); + expect(share(el)).toBe('3'); }); - it('sizes a content pane to its content, shrinkable, and never stretches it', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('takes a fill pane down to the same size a content pane goes to', async () => { + const el = await mountSections([ { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, { id: 'b', title: 'B', content: html`
B
` }, - ]; - document.body.appendChild(el); - await el.updateComplete; + { id: 'c', title: 'C', content: html`
C
`, weight: 4 }, + ]); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 auto'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 calc(100% / 2)'); + // Drag b down to 60 of the pair's 200px. + await drag(el, sash(el, 1), 60); + + // Both kinds end up sized by the same basis, so neither bottoms out sooner + // than the other — `--lana-pane-min` alone decides how small that is, and it + // is one value for every section. + expect(sizing(el, 'b')).toBe('fill 1.5 60px'); + expect(sizing(el, 'c')).toBe('fill 3.5 140px'); }); - it('renders a sash beside a content pane too', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('gives a content pane the room it was dragged to, past its content', async () => { + const el = await mountSections([ { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, { id: 'b', title: 'B', content: html`
B
` }, - { id: 'c', title: 'C', content: html`
C
` }, - ]; - document.body.appendChild(el); - await el.updateComplete; + ]); - // a↔b and b↔c: a content pane holds the size it is dragged to. - expect(el.shadowRoot?.querySelectorAll('.pane-sash').length).toBe(2); + await drag(el, sash(el), 160); + // The drag is what the user asked for; a double-click is how they take it back. + expect(sizing(el, 'a')).toBe('content 160px'); }); - it('pins a content pane to its dragged size, and keeps it out of the fill scale', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('drags every section to the same floor, whatever its neighbour asks for', async () => { + // A pane's floor must not depend on what its neighbour holds. + const el = await mountSections([ { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, + { id: 'b', title: 'B', content: html`
B
`, fit: 'content' }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + await drag(el, sash(el, 0), -500); + await drag(el, sash(el, 1), -500); + + // `--lana-pane-min` is the only floor, and jsdom resolves no layout, so both + // sections reach the same 0 — a content pane above a content pane, and one + // above a fill pane. + expect(sizing(el, 'a')).toBe('content 0px'); + expect(sizing(el, 'b')).toBe('content 0px'); + }); + + describe('height tier', () => { + const tiered = (): PaneSection[] => [ + { id: 'a', title: 'A', content: html`
A
`, height: 'md' }, { id: 'b', title: 'B', content: html`
B
` }, { id: 'c', title: 'C', content: html`
C
` }, ]; - el.paneSizes = { 'vertical:a': 500, 'vertical:b': 300, 'vertical:c': 100 }; - document.body.appendChild(el); - await el.updateComplete; - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - // A basis, not a weight: it never stretches, and it still shrinks to scroll. - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 500px'); - // The content pane's size is no part of the fill panes' weights. - expect(pane('b')?.getAttribute('style')).toContain('flex: 1.5 1 calc(100% / 3)'); - expect(pane('c')?.getAttribute('style')).toContain('flex: 0.5 1 calc(100% / 3)'); + it('leaves a tiered section to CSS, so the token is what sizes it', async () => { + const el = await mountSections(tiered()); + + // The tier and nothing else: no weight, no dragged size, so the token in + // the stylesheet is the whole answer. + expect(sizing(el, 'a')).toBe('md'); + }); + + it('leaves the fill panes sharing what is left, so their weights decide', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, height: 'md' }, + { id: 'b', title: 'B', content: html`
B
`, weight: 2 }, + { id: 'c', title: 'C', content: html`
C
`, weight: 4 }, + ]); + + // A tier is a bounded slot, so the fill panes resolve from zero and split + // the rest 2:4. A share each would over-subscribe the panel, and flexbox + // shrinks by basis alone — which would flatten 2 against 4. + expect(sizing(el, 'b')).toBe('fill 2'); + expect(sizing(el, 'c')).toBe('fill 4'); + expect(share(el)).toBeNull(); + }); + + it('still hands the fill panes a share beside a content pane', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
`, height: 'md' }, + { id: 'b', title: 'B', content: html`
B
`, fit: 'content' }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + // Nothing bounds a content pane, so the guard against it squeezing the + // grids to the floor stays. + expect(sizing(el, 'c')).toBe('fill 1'); + expect(share(el)).toBe('3'); + }); + + it('hands the tier over to a dragged size', async () => { + const el = await mountSections(tiered()); + + await drag(el, sash(el), 140); + + // Still on its tier; the dragged size is what every rule reads first. + expect(sizing(el, 'a')).toBe('md 140px'); + }); + + it('shares the width like any fill pane when the sections sit side by side', async () => { + const el = await mountSections(tiered(), { orientation: 'horizontal' }); + + // A height means nothing along this axis. + expect(pane(el, 'a')?.hasAttribute('data-tier')).toBe(false); + expect(sizing(el, 'a')).toBe('fill 1'); + }); + + it('drops the tier while the section is collapsed', async () => { + const el = await mountSections(tiered()); + el.collapsed = { a: true }; + await el.updateComplete; + + expect(sizing(el, 'a')).toBe('closed'); + }); + }); + + it('shrinks the pane above, then the one above that, out to the top', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
` }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + { id: 'd', title: 'D', content: html`
D
` }, + ]); + + // Each pane is 100 here. Dragging the b-c sash to the top makes c as large + // as the stack allows: b gives up its room first, then a. + await drag(el, sash(el, 1), -1000); + + expect(sizing(el, 'b')).toBe('fill 0 0px'); + expect(sizing(el, 'a')).toBe('fill 0 0px'); + expect(sizing(el, 'c')).toBe('fill 3 300px'); + // d is past the far end of the drag, and keeps its size: the room the + // cascade moved never comes out of a section the reader did not drag. + expect(sizing(el, 'd')).toBe('fill 1 100px'); + }); + + it('shrinks the pane below, then the one below that, out to the bottom', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
A
` }, + { id: 'b', title: 'B', content: html`
B
` }, + { id: 'c', title: 'C', content: html`
C
` }, + ]); + + await drag(el, sash(el, 0), 1000); + + expect(sizing(el, 'b')).toBe('fill 0 0px'); + expect(sizing(el, 'c')).toBe('fill 0 0px'); + expect(sizing(el, 'a')).toBe('fill 3 300px'); }); it('hands a content pane back to its content on a double-click', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + const el = await mountSections([ { id: 'a', title: 'A', content: html`
A
`, fit: 'content' }, { id: 'b', title: 'B', content: html`
B
` }, - ]; - el.paneSizes = { 'vertical:a': 500 }; - document.body.appendChild(el); - await el.updateComplete; + ]); + + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('content 120px'); - let detail: { sizes: Record; orientation: string } | undefined; - el.addEventListener('pane-resize', (e) => { - detail = (e as CustomEvent<{ sizes: Record; orientation: string }>).detail; - }); sash(el).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); await el.updateComplete; - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 auto'); - // The size is gone rather than zeroed, so the consumer replaces the axis. - expect(detail?.sizes['vertical:a']).toBeUndefined(); - expect(detail?.orientation).toBe('vertical'); + expect(sizing(el, 'a')).toBe('content'); }); describe('fill pane share', () => { @@ -360,51 +646,269 @@ describe('PaneView', () => { { id: 'c', title: 'C', content: html`
C
` }, ]; - async function mountMixed( - orientation: PaneOrientation, - props: Partial = {}, - ): Promise { - const el = document.createElement('pane-view') as PaneView; - Object.assign(el, { orientation, sections: mixed }, props); - document.body.appendChild(el); - await el.updateComplete; - return el; - } - it('gives the fill pane a share to shrink from, and leaves the content panes alone', async () => { - const el = await mountMixed('vertical'); + const el = await mountSections(mixed); // Three open panes, so the fill pane starts from a third and grows. - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 3)'); - expect(paneStyle(el, 'a')).toContain('flex: 0 1 auto'); - expect(paneStyle(el, 'b')).toContain('flex: 0 1 auto'); + expect(share(el)).toBe('3'); + expect(sizing(el, 'c')).toBe('fill 1'); + expect(sizing(el, 'a')).toBe('content'); + expect(sizing(el, 'b')).toBe('content'); }); it('widens the share as sections collapse', async () => { - const el = await mountMixed('vertical', { collapsed: { b: true } }); + const el = await mountSections(mixed, { collapsed: { b: true } }); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 2)'); + expect(share(el)).toBe('2'); }); it('shares by weight alone once no content pane is open', async () => { - const el = await mountMixed('vertical', { collapsed: { a: true, b: true } }); + const el = await mountSections(mixed, { collapsed: { a: true, b: true } }); // One section read on its own gets the whole panel either way, and with // several fill panes a share each would flatten their weights. - expect(paneStyle(el, 'c')).toContain('flex: 1 1 0'); + expect(share(el)).toBeNull(); + expect(sizing(el, 'c')).toBe('fill 1'); }); - it('leaves a dragged content pane at the size it was given', async () => { - const el = await mountMixed('vertical', { paneSizes: { 'vertical:a': 500 } }); + it('leaves a dragged content pane at the size it was dragged to', async () => { + const el = await mountSections(mixed); - expect(paneStyle(el, 'a')).toContain('flex: 0 1 500px'); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 3)'); + await drag(el, sash(el)); + + expect(sizing(el, 'a')).toBe('content 120px'); + expect(sizing(el, 'b')).toBe('content 80px'); + expect(sizing(el, 'c')).toBe('fill 1 100px'); + expect(share(el)).toBe('3'); }); it('shares nothing side by side, where the axis is the width', async () => { - const el = await mountMixed('horizontal'); + const el = await mountSections(mixed, { orientation: 'horizontal' }); + + expect(share(el)).toBeNull(); + expect(sizing(el, 'c')).toBe('fill 1'); + }); + }); + + describe('reorder', () => { + it('moves the panes it already has, rather than rebuilding them', async () => { + const el = await mount('vertical'); + const before = pane(el, 'a'); + + el.sections = [sections[2]!, sections[0]!, sections[1]!]; + await el.updateComplete; + + // Same element: a rebuilt pane would re-mount its body's grid and lose + // the scroll, the expanded rows and the row the user picked. + expect(pane(el, 'a')).toBe(before); + expect(paneIds(el)).toEqual(['c', 'a', 'b']); + }); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 0'); + it('drops a section before the one it is over the top half of', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + await dragSection(el, 'c', 20); + + expect(seen).toEqual([['c', 'a', 'b']]); + }); + + it('drops it after the one it is over the bottom half of', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + // Over `c`'s body, nowhere near a header: the section under the pointer is + // what the drop reads. + await dragSection(el, 'a', 280); + + expect(seen).toEqual([['b', 'c', 'a']]); + }); + + it('marks the edge the section would land on, and the one being dragged', async () => { + const el = await mount('vertical'); + + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 20)); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane[data-id="a"]')?.className).toContain( + 'pane--drop-before', + ); + expect(el.shadowRoot?.querySelector('.pane[data-id="c"]')?.className).toContain( + 'pane--dragging', + ); + + // The gesture ends: no mark is left behind. + headerOf(el, 'c').dispatchEvent(dragEvent('dragend')); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + expect(el.shadowRoot?.querySelector('.pane--dragging')).toBeNull(); }); + + it('marks the last edge when the section would land at the end', async () => { + const el = await mount('vertical'); + + headerOf(el, 'a').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 280)); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane[data-id="c"]')?.className).toContain( + 'pane--drop-after', + ); + // One boundary, one mark: nothing else is marked. + expect(el.shadowRoot?.querySelectorAll('.pane--drop-before').length).toBe(0); + }); + + it('marks one edge however the boundary was reached', async () => { + const el = await mount('vertical'); + + // The bottom of `a` and the top of `b` are the same boundary, and `c` + // would land on it either way. + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 80)); + await el.updateComplete; + const fromBelow = el.shadowRoot?.querySelector('.pane--drop-before')?.getAttribute('data-id'); + + stack(el).dispatchEvent(dragEvent('dragover', 120)); + await el.updateComplete; + + expect(fromBelow).toBe('b'); + expect(el.shadowRoot?.querySelector('.pane--drop-before')?.getAttribute('data-id')).toBe('b'); + }); + + it('marks neither edge of where the section already sits', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'b').dispatchEvent(dragEvent('dragstart')); + // Its own top edge, its own bottom edge, and the top of the next section: + // every one of them moves nothing. + for (const at of [120, 180, 220]) { + stack(el).dispatchEvent(dragEvent('dragover', at)); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + expect(el.shadowRoot?.querySelector('.pane--drop-after')).toBeNull(); + } + + stack(el).dispatchEvent(dragEvent('drop', 120)); + expect(seen).toEqual([]); + }); + + it('drops the mark when the drag leaves the stack', async () => { + const el = await mount('vertical'); + + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 20)); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).not.toBeNull(); + + stack(el).dispatchEvent(dragEvent('dragleave')); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + }); + + it('reports nothing for a section dropped on itself', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + await dragSection(el, 'b', 120); + + expect(seen).toEqual([]); + }); + + it('does not reorder itself when the consumer ignores pane-reorder', async () => { + const el = await mount('vertical'); + + await dragSection(el, 'c', 20); + + // Controlled, like collapse: the consumer owns the order. + expect(el.sections.map((section) => section.id)).toEqual(['a', 'b', 'c']); + }); + + it('moves a section one place with Alt+Arrow', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + headerOf(el, 'c').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true }), + ); + + expect(seen).toEqual([ + ['b', 'a', 'c'], + ['a', 'c', 'b'], + ]); + }); + + it('ignores Alt+Arrow off either end, and a held key', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true }), + ); + headerOf(el, 'c').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + altKey: true, + bubbles: true, + repeat: true, + }), + ); + + expect(seen).toEqual([]); + }); + + it('keeps Alt+Arrow off the collapse it shares a header with', async () => { + const el = await mount('vertical'); + let toggles = 0; + el.addEventListener('pane-toggle', () => toggles++); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + + expect(toggles).toBe(0); + }); + + it('reorders side by side too, where the axis is the width', async () => { + const el = await mount('horizontal'); + const seen = reorders(el); + + // Focusable on both axes, so the keyboard reaches the reorder in the + // bottom dock as well. + expect(headerOf(el, 'a').getAttribute('tabindex')).toBe('0'); + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + + expect(seen).toEqual([['b', 'a', 'c']]); + }); + }); + + it('reports a header right-click, so the consumer can offer the sections', async () => { + const el = await mount('vertical'); + const seen: Array<{ id: string; x: number; y: number }> = []; + el.addEventListener('pane-menu', (e) => { + seen.push((e as CustomEvent<{ id: string; x: number; y: number }>).detail); + }); + + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 40, + clientY: 12, + }); + headerOf(el, 'b').dispatchEvent(event); + + expect(seen).toEqual([{ id: 'b', x: 40, y: 12 }]); + // The host's own menu never opens over it. + expect(event.defaultPrevented).toBe(true); }); }); diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts new file mode 100644 index 000000000..d498043e7 --- /dev/null +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -0,0 +1,1058 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; +import { parse } from 'apex-log-parser'; + +import { MAX_MARKED_PER_VALUE } from '../../core/log/aggregateVariables.js'; +import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; + +// Avoid the heavy CodeBlock import chain (vscode-elements, soql formatter). The +// raw value it renders is covered by variableValue's own tests. +jest.mock('../CodeBlock.js', () => ({})); +// The chevron is a vscode-icon, and its connectedCallback throws under jsdom. +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); + +import { STATICS_NOTE, type VariablesDetail } from '../VariablesDetail.js'; +import '../VariablesDetail.js'; + +const FINEST = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; +const FINE = '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'; + +function logOf(body: string, settings = FINEST): LogStore { + return logStoreFor( + parse( + settings + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n', + ), + ); +} + +const FRAME = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|total|Integer|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[2]|total|42\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[3]|this.name|"Acme"\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[4]|ns.Cache.hits|{"a":1,"b":2}\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + +/** The eventIndex of the frame whose log text is `text`. */ +function indexOf(store: LogStore, text: string): number { + const found = store.log.eventsById.find((event) => event.text === text); + if (!found) { + throw new Error(`no event with text ${text}`); + } + return found.eventIndex; +} + +/** No provider in the test, so the consumed store is assigned straight on. */ +async function mount(store: LogStore, props: Partial): Promise { + const el = document.createElement('variables-detail') as VariablesDetail; + Object.assign(el, { logStore: store }, props); + document.body.appendChild(el); + await el.updateComplete; + // The statics index is built on the first ask, so the first paint is a note. + await el.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + await el.updateComplete; + return el; +} + +/** The notes on screen. A shadow root's textContent also holds its styles. */ +function notes(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.note') ?? []).map( + (node) => node.textContent?.replace(/\s+/g, ' ').trim() ?? '', + ); +} + +/** Every row's text, styles left out. */ +function rowText(el: VariablesDetail): string { + return Array.from(el.shadowRoot?.querySelectorAll('[role="treeitem"], .is-note, .note') ?? []) + .map((node) => node.textContent ?? '') + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Names as shown, without the colon that joins a name to its value. */ +function rowNames(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.name') ?? []).map( + (node) => node.textContent?.trim().replace(/:$/, '') ?? '', + ); +} + +function rowNamed(el: VariablesDetail, name: string): Element | undefined { + return treeRows(el).find( + (row) => row.querySelector('.name')?.textContent?.replace(/:$/, '') === name, + ); +} + +function groupNames(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.group-name') ?? []).map( + (node) => node.textContent?.trim() ?? '', + ); +} + +// A throw during the whole-log walk must not leave the section reading forever +// with no error and no way to retry. +describe('VariablesDetail read failure', () => { + it('shows a message rather than staying on "Reading the log…" forever', async () => { + const store = logOf(FRAME); + Object.defineProperty(store.log, 'children', { + get(): never { + throw new Error('boom'); + }, + }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toContain('Could not read the log for variables.'); + errorSpy.mockRestore(); + }); +}); + +// Reading an aggregate's frame back through the log costs tens of ms on a huge +// frame (frameVariables.ts), and render() throws it away unread. +describe('VariablesDetail skips the frame read for an aggregate', () => { + it('never asks for the frame when more than one instance is selected', async () => { + const store = logOf(FRAME); + const el = await mount(store, { + eventIndex: indexOf(store, 'ns.Outer.run()'), + frames: [indexOf(store, 'ns.Outer.run()'), indexOf(store, 'ns.Outer.run()')], + }); + + expect((el as unknown as { _frame: unknown })._frame).toBeNull(); + }); +}); + +describe('VariablesDetail empty states', () => { + // Telling a FINEST user to set FINEST is the worst answer available, so each + // case has to read differently. + it('names the log level that would fill it', async () => { + const store = logOf(FRAME, FINE); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toEqual(['Variables available with the Apex Code log level at FINEST.']); + }); + + it('says a FINEST log recorded no write at all', async () => { + const store = logOf('09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n'); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toEqual(['This log records no variable assignments.']); + }); + + it('says a frame had nothing in scope, where the log has writes elsewhere', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Quiet.run()\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Quiet.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Quiet.run()') }); + + // The statics are still visible from it, so this frame reports them. + expect(groupNames(el)).toContain('Static'); + }); +}); + +function treeRows(el: VariablesDetail): HTMLElement[] { + return Array.from(el.shadowRoot?.querySelectorAll('[role="treeitem"]') ?? []); +} + +function tree(el: VariablesDetail): HTMLElement { + const found = el.shadowRoot?.querySelector('[role="tree"]'); + if (!found) { + throw new Error('no tree'); + } + return found; +} + +/** The row holding the tree's one tab stop. */ +function tabStop(el: VariablesDetail): string | null { + return el.shadowRoot?.querySelector('[tabindex="0"]')?.getAttribute('data-id') ?? null; +} + +/** Returns the event, so a caller can read back whether the tree consumed it. */ +async function press( + el: VariablesDetail, + key: string, + options: Partial = {}, +): Promise { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + }); + tree(el).dispatchEvent(event); + await el.updateComplete; + return event; +} + +describe('VariablesDetail groups', () => { + it('shows Local, this and Static, in that order', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(groupNames(el)).toEqual(['Local', 'this', 'Static']); + }); + + it('opens Local and leaves the rest closed', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + const groups = treeRows(el).filter((row) => row.querySelector('.group-name')); + expect(groups.map((row) => row.getAttribute('aria-expanded'))).toEqual([ + 'true', + 'false', + 'false', + ]); + // Local is open, so its one row shows. + expect(rowNames(el)).toContain('total'); + }); + + // Everything that opens says so, groups included. + it('gives every row that opens a chevron, and every other row its gap', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + for (const row of treeRows(el)) { + const opens = row.getAttribute('aria-expanded') !== null; + expect(!!row.querySelector('.chevron')).toBe(opens); + expect(!!row.querySelector('.chevron-gap')).toBe(!opens); + } + }); + + it('shows the declared type the log recorded', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowText(el)).toContain('Integer'); + }); + + it('keeps a disclosure the user closed when the selection moves', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + treeRows(el)[0]?.click(); + await el.updateComplete; + el.eventIndex = indexOf(store, 'ns.Second.run()'); + await el.updateComplete; + + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); + + // A chevron that opened on nothing would teach a depth the log lacks. + it('offers no expander on a value a row can hold', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // `total` is 42, so its row opens on nothing. + const total = rowNamed(el, 'total'); + expect(total?.getAttribute('aria-expanded')).toBeNull(); + expect(total?.querySelector('.chevron-gap')).not.toBeNull(); + }); + + // Open, the rows below carry the value; a preview as well would print it twice. + it('drops the preview once the value is open below it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"a":1,"b":2}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const held = () => treeRows(el).find((row) => row.dataset.id === 'local/held'); + + const before = held()?.querySelector('.value')?.textContent?.trim(); + held()?.click(); + await el.updateComplete; + + expect(before).toContain('a: 1'); + expect(held()?.getAttribute('aria-expanded')).toBe('true'); + expect(held()?.querySelector('.value')).toBeNull(); + // The properties are rows of their own, so the arrows reach them. + expect(treeRows(el).some((row) => row.dataset.id === 'local/held/0')).toBe(true); + }); + + it('says so where the log declared a name and never wrote it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowText(el)).toContain('not assigned'); + }); + + // A colon joins a name to its value, and only where a value follows it. + it('joins a name to its value with a colon', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'total')?.querySelector('.name')?.textContent).toBe('total:'); + }); + + it('leaves the colon off a name with no value', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'never')?.querySelector('.name')?.textContent).toBe('never'); + }); + + // One rule for an address: it always trails the row, and the value slot says + // whether the log ever wrote the object down. + it('says the object is not recorded where the log wrote no value for it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.missing')?.textContent).toContain('no value recorded'); + expect(alias?.querySelector('.ref')?.textContent).toContain('0xd854c6b'); + expect(alias?.querySelector('.chip')).toBeNull(); + }); + + // The address is only the identity the runtime printed for the reference. The + // contents are a separate event, which may land after the frame. + it('says so where the log recorded the object only after this frame', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n' + + '09:18:22.6 (1400)|METHOD_ENTRY|[4]|01p|ns.Outer.after()\n' + + '09:18:22.6 (1500)|VARIABLE_ASSIGNMENT|[5]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1600)|METHOD_EXIT|[4]|ns.Outer.after()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.missing')?.textContent).toContain('recorded later'); + expect(alias?.querySelector('.ref')?.textContent).toContain('0xd854c6b'); + // Named, because that frame can be far from the one the reader picked. + expect(alias?.querySelector('.missing')?.getAttribute('title')).toContain('ns.Outer.after()'); + }); + + // An interface-typed variable holding a stateless instance reads as `{}`, and + // the class is the only thing that says what it is. + it('names the class of the object it shows', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[2]|01p|()|ns.Writer.WithoutSharing\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer.WithoutSharing|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_EXIT|[2]|01p|()|ns.Writer.WithoutSharing\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[3]|writer|ns.IWriter|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|writer|{}|0x12d441a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const writer = rowNamed(el, 'writer'); + + // The row has no width for the namespace, so the hover carries it whole. + expect(writer?.querySelector('.cls')?.textContent).toBe('WithoutSharing'); + expect(writer?.querySelector('.value')?.getAttribute('title')).toContain( + 'ns.Writer.WithoutSharing', + ); + // The declared type stays its own column, so both read together. + expect(writer?.querySelector('.type')?.textContent).toBe('ns.IWriter'); + }); + + // The type column already says it, so saying it twice is noise. + it('leaves the class out where it matches the declared type', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[3]|writer|ns.Writer|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|writer|{}|0x12d441a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'writer')?.querySelector('.cls')).toBeNull(); + }); + + // `this` is the instance the frame runs on. Listing it among the locals reads + // as a variable the method declared. + it('keeps this out of the locals', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Writer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|total|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Writer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Writer.run()') }); + + expect(rowNames(el)).not.toContain('this'); + expect(groupNames(el)).toContain('this'); + // A stateless class has nothing to open, and the group says so. + const group = treeRows(el).find((r) => r.getAttribute('data-id') === 'this'); + expect(group?.getAttribute('aria-expanded')).toBeNull(); + expect(group?.querySelector('.value')?.textContent).toContain('{}'); + }); + + it('marks an object it read out of a string', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|payload|"{\\"a\\":1}"\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'payload')?.querySelector('.chip')?.textContent).toBe('json'); + }); + + it('reads an address as the object it names', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.value')?.textContent).toContain('Id'); + // The arrow says the log wrote no value on this line. + const ref = alias?.querySelector('.ref'); + expect(ref?.textContent).toContain('0xd854c6b'); + expect(ref?.textContent).toContain('\u2192'); + }); +}); + +// The tree keyboard pattern: one tab stop, the arrows walk and open. +describe('VariablesDetail keyboard', () => { + // A note carries no tabindex, so a move that landed on one would leave the + // tree with no tab stop at all: focus and forth keys would then do nothing. + it('never lands the tab stop on a note', async () => { + const store = logOf( + '09:18:22.6 (900)|METHOD_ENTRY|[1]|01p|ns.Setup.run()\n' + + '09:18:22.6 (950)|VARIABLE_ASSIGNMENT|[1]|ns.Cache.hits|1\n' + + '09:18:22.6 (990)|METHOD_EXIT|[1]|ns.Setup.run()\n' + + '09:18:22.6 (1000)|METHOD_ENTRY|[2]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[2]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Local starts open with no locals, so it opens onto a note. + expect(tabStop(el)).toBe('local'); + await press(el, 'ArrowDown'); + + expect(tabStop(el)).not.toBeNull(); + expect(treeRows(el).find((row) => row.dataset.id === tabStop(el))?.classList).not.toContain( + 'is-note', + ); + }); + + it('gives the tree one tab stop', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(el.shadowRoot?.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + expect(tabStop(el)).toBe('local'); + }); + + it('walks down and up', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'ArrowDown'); + const second = tabStop(el); + await press(el, 'ArrowUp'); + + expect(second).not.toBe('local'); + expect(tabStop(el)).toBe('local'); + }); + + it('opens with the right arrow and closes with the left', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Walk to `this`, which starts closed. + while (tabStop(el) !== 'this') { + await press(el, 'ArrowDown'); + } + const fields = () => treeRows(el).find((row) => row.dataset.id === 'this'); + await press(el, 'ArrowRight'); + const opened = fields()?.getAttribute('aria-expanded'); + await press(el, 'ArrowLeft'); + + expect(opened).toBe('true'); + expect(fields()?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('steps out to the row that holds it', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Local is open, so the row below it is one of its own. + await press(el, 'ArrowDown'); + await press(el, 'ArrowLeft'); + + expect(tabStop(el)).toBe('local'); + }); + + it('reaches the first and last row', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'End'); + const last = tabStop(el); + await press(el, 'Home'); + + expect(last).toBe('static'); + expect(tabStop(el)).toBe('local'); + }); + + it('opens every group at one depth with a star', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, '*'); + + const groups = treeRows(el).filter((row) => row.querySelector('.group-name')); + expect(groups.map((row) => row.getAttribute('aria-expanded'))).toEqual([ + 'true', + 'true', + 'true', + ]); + }); + + it('toggles with Enter', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'Enter'); + + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); +}); + +describe('VariablesDetail keyboard, key repeat', () => { + it('holds a row where a held Enter left it, rather than flapping', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'Enter'); + await press(el, 'Enter', { repeat: true }); + const afterEnter = treeRows(el)[0]?.getAttribute('aria-expanded'); + await press(el, ' ', { repeat: true }); + + expect(afterEnter).toBe('false'); + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('leaves a group closed that a held star would re-open', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const local = () => treeRows(el).find((row) => row.dataset.id === 'local'); + + // Local starts open, and holds the tab stop, so Enter closes it. + await press(el, 'Enter'); + await press(el, '*', { repeat: true }); + + expect(local()?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the key consumed on a suppressed repeat', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + const event = await press(el, 'Enter', { repeat: true }); + + expect(event.defaultPrevented).toBe(true); + }); + + it('walks on every repeat, so holding an arrow scrubs', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'ArrowDown'); + const second = tabStop(el); + await press(el, 'ArrowDown', { repeat: true }); + + expect(second).not.toBe('local'); + expect(tabStop(el)).not.toBe(second); + }); +}); + +// Reading the scope back through a huge frame costs tens of ms, so opening a +// row must not pay it again. +describe('VariablesDetail reads the scope once per selection', () => { + /** The snapshot the section is rendering from. */ + const held = (el: VariablesDetail): unknown => (el as unknown as { _frame: unknown })._frame; + + it('keeps the same reading when a row opens', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = held(el); + + treeRows(el)[0]?.click(); + await el.updateComplete; + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + + expect(before).toBeTruthy(); + expect(held(el)).toBe(before); + }); + + /** The row list the keyboard walks. */ + const rows = (el: VariablesDetail): unknown => (el as unknown as { _rows: unknown })._rows; + + // Scanning a value is the cost, so a key that only moves the tab stop must not + // pay it again: a held arrow key fires ~20 times a second. + it('keeps the same rows when the tab stop moves', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = rows(el); + + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + + expect(before).toBeTruthy(); + expect(rows(el)).toBe(before); + }); + + it('builds the rows again when a row opens', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = rows(el); + + treeRows(el)[0]?.click(); + await el.updateComplete; + + expect(rows(el)).not.toBe(before); + }); + + it('reads again when the selection moves', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = held(el); + + el.eventIndex = indexOf(store, 'ns.Second.run()'); + await el.updateComplete; + + expect(held(el)).not.toBe(before); + }); +}); + +// A property is a row like any other, so the arrow keys reach it and it opens. +describe('VariablesDetail properties', () => { + it('opens a property that is an object in its own right', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"inner":{"a":1,"b":2},"n":3}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const at = (id: string) => treeRows(el).find((r) => r.dataset.id === id); + + at('local/outer')?.click(); + await el.updateComplete; + const before = at('local/outer/0')?.getAttribute('aria-expanded'); + at('local/outer/0')?.click(); + await el.updateComplete; + + expect(before).toBe('false'); + expect(at('local/outer/0')?.getAttribute('aria-expanded')).toBe('true'); + // Its own properties are rows, one level deeper. + expect(at('local/outer/0/0')?.getAttribute('aria-level')).toBe('4'); + }); + + it('leaves a property that opens on nothing without a chevron', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"n":3}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + treeRows(el) + .find((r) => r.dataset.id === 'local/outer') + ?.click(); + await el.updateComplete; + const property = treeRows(el).find((r) => r.dataset.id === 'local/outer/0'); + + expect(property?.getAttribute('aria-expanded')).toBeNull(); + expect(property?.querySelector('.chevron-gap')).not.toBeNull(); + }); +}); + +// A local holding an object reads `{}`, because the log could not serialise it. +// Its fields are lines of their own, and the badge is what says so. +describe('VariablesDetail object fields', () => { + const BUILT = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|CONSTRUCTOR_ENTRY|[9]|01p|()|ns.Holder\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Holder|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_EXIT|[9]|01p|()|ns.Holder\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[10]|holder|ns.Holder|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[10]|holder|{}|0xaaa\n' + + '09:18:22.6 (1080)|VARIABLE_SCOPE_BEGIN|[11]|plain|ns.Other|true|false\n' + + '09:18:22.6 (1090)|VARIABLE_ASSIGNMENT|[11]|plain|{}|0xzzz\n' + + '09:18:22.6 (1100)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('counts the fields beside a value the log wrote as {}', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const holder = rowNamed(el, 'holder'); + + expect(holder?.querySelector('.count')?.textContent?.trim()).toBe('1'); + expect(holder?.getAttribute('aria-expanded')).toBe('false'); + }); + + // `{}` beside a count of one reads as empty, so the row shows what it opens on. + it('previews the recorded fields on the closed row', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const holder = rowNamed(el, 'holder'); + + expect(holder?.querySelector('.value')?.textContent).toContain('sObj: "Account"'); + }); + + // The log wrote no value for the whole object and still recorded its parts. A + // row that shows those parts must not also claim the log holds nothing. + it('shows the recorded parts rather than "no value recorded"', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|this.rows|5|0xbbb\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[3]|big|ns.Big|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|big|0xbbb\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const big = rowNamed(el, 'big'); + + expect(big?.querySelector('.missing')).toBeNull(); + expect(big?.querySelector('.value')?.textContent).toContain('rows: 5'); + expect(big?.querySelector('.count')?.textContent?.trim()).toBe('1'); + }); + + // A cycle leaves a row that cannot open, so a count on it would promise rows + // the tree will not give. + it('leaves the count off a row that cannot open', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|this.me|0xddd|0xddd\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[3]|holder|ns.Loop|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|holder|{}|0xddd\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Loop.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Loop.run()') }); + + // The field names the object it belongs to, so it may be read but not opened. + treeRows(el) + .find((row) => row.dataset.id === 'local/holder') + ?.click(); + await el.updateComplete; + const me = treeRows(el).find((row) => row.dataset.id === 'local/holder/me'); + + expect(me).toBeDefined(); + expect(me?.getAttribute('aria-expanded')).toBeNull(); + expect(me?.querySelector('.count')).toBeNull(); + }); + + it('offers nothing to open where the log recorded no fields', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const plain = rowNamed(el, 'plain'); + + expect(plain?.querySelector('.count')).toBeNull(); + expect(plain?.getAttribute('aria-expanded')).toBeNull(); + }); +}); + +// A merged row has no single frame, so the section compares its calls: which +// name varied is the reading the grids beside it do not carry. +describe('VariablesDetail comparing a merged row', () => { + /** One call of `ns.Svc.run()`, writing `retry` and the constant `batchSize`. */ + function call(at: number, retry: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|retry|Boolean|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|retry|${retry}\n` + + `${t(30)}|VARIABLE_ASSIGNMENT|[3]|batchSize|200\n` + + `${t(40)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + } + + const CALLS = call(1000, 'true') + call(2000, 'false') + call(3000, 'false'); + + /** Every frame whose text is `text`: a METHOD_EXIT carries the entry's text. */ + function framesOf(store: LogStore, text: string): number[] { + return store.log.eventsById + .filter((event) => event.isParent && event.text === text) + .map((event) => event.eventIndex); + } + + /** The comparison on screen, once its walk has answered. */ + async function compared(body = CALLS): Promise { + const store = logOf(body); + const frames = framesOf(store, 'ns.Svc.run()'); + const el = await mount(store, { eventIndex: frames[0]!, frames }); + // The comparison is a walk of its own, after the index it reads through. + await new Promise((resolve) => setTimeout(resolve, 0)); + await el.updateComplete; + return el; + } + + it('lists every name the calls held, varying ones first', async () => { + const el = await compared(); + + expect(rowNames(el)).toEqual(['retry', 'batchSize']); + }); + + it('opens a name the calls disagreed on into its values and their counts', async () => { + const el = await compared(); + rowNamed(el, 'retry')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + + expect(rowText(el)).toContain('false'); + expect(rowText(el)).toContain('true'); + }); + + it('shows a name every call agreed on as its one value', async () => { + const el = await compared(); + + expect(rowNamed(el, 'batchSize')?.textContent).toContain('200'); + expect(rowNamed(el, 'batchSize')?.getAttribute('aria-expanded')).toBeNull(); + }); + + /** Every locate the section raised, in order. */ + function locates(el: VariablesDetail): { eventIndexes: readonly number[]; sticky: boolean }[] { + const seen: { eventIndexes: readonly number[]; sticky: boolean }[] = []; + el.addEventListener('inspector-locate', (event) => { + const { eventIndexes, sticky } = ( + event as CustomEvent<{ eventIndexes: readonly number[]; sticky: boolean }> + ).detail; + seen.push({ eventIndexes, sticky }); + }); + return seen; + } + + /** The value rows of an opened name. */ + async function valuesOf(el: VariablesDetail, name: string): Promise { + rowNamed(el, name)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + return treeRows(el).filter((row) => row.dataset.id?.startsWith(`local/${name}/`)); + } + + // Every other section answers a merged row with aggregated figures, so + // dropping onto one of its calls would throw away the reading. + it('never re-scopes the panel to one call', async () => { + const el = await compared(); + const revealed: number[] = []; + el.addEventListener('inspector-reveal', (event) => { + revealed.push((event as CustomEvent<{ eventIndex: number }>).detail.eventIndex); + }); + const values = await valuesOf(el, 'retry'); + + values[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + rowNamed(el, 'batchSize')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(revealed).toEqual([]); + }); + + it('marks the calls that held a value while the pointer is over it', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + values[0]?.dispatchEvent(new MouseEvent('pointerenter', { bubbles: true })); + values[0]?.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + + // `false` was the second and third calls; leaving hands the mark back. + expect(seen).toEqual([ + { eventIndexes: [calls[1], calls[2]], sticky: false }, + { eventIndexes: [], sticky: false }, + ]); + }); + + it('holds the mark once a value is picked', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + values[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + // `true` was the first call. Sticky, and no selection rides with it. + expect(seen).toEqual([{ eventIndexes: [calls[0]], sticky: true }]); + }); + + // A value is often a scalar with nothing to open, so a keyboard that only + // toggled could never reach the mark at all. + it('marks the calls that held a value from the keyboard', async () => { + const el = await compared(); + await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + // Opening `retry` left the tab stop on it, so one step down lands on its + // first value. + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await el.updateComplete; + + // `false` was the second and third calls, and it leads on count. + expect(seen).toEqual([{ eventIndexes: [calls[1], calls[2]], sticky: true }]); + }); + + // Every other cap in this section says so; a mark that stops short while the + // count beside it says thousands would read as a bug in the mark. + it('says a value holds more calls than its mark names', async () => { + const many = Array.from({ length: MAX_MARKED_PER_VALUE + 5 }, (_, at) => + call(1000 + at * 100, 'true'), + ).join(''); + const el = await compared(many); + + expect(notes(el)).toContain( + `A value held by over ${MAX_MARKED_PER_VALUE} calls marks that many of them.`, + ); + }); + + it('says nothing of the kind where every call is marked', async () => { + const el = await compared(); + + expect(notes(el).join(' ')).not.toContain('marks that many'); + }); + + // Counts alone mislead: an unbroken run is a state the calls were in. + it('says whether a value was one run or came and went', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + + expect(values[0]?.textContent).toContain('one run'); + }); + + it('counts the runs of a value the calls returned to', async () => { + const el = await compared(call(1000, 'true') + call(2000, 'false') + call(3000, 'true')); + const values = await valuesOf(el, 'retry'); + + expect(values[0]?.textContent).toContain('2 runs'); + }); + + // The panel stays on the comparison, so a value has to open where it stands. + it('opens a value that is an object into its fields', async () => { + const held = (at: number, name: string): string => { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_ASSIGNMENT|[2]|opts|{"name":"${name}","rows":5}\n` + + `${t(20)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + }; + const el = await compared(held(1000, 'A') + held(2000, 'B')); + + const values = await valuesOf(el, 'opts'); + expect(values[0]?.getAttribute('aria-expanded')).toBe('false'); + + values[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + + expect(rowText(el)).toContain('name'); + expect(rowText(el)).toContain('rows'); + }); + + // A static moves for reasons the row does not own, so leaving them out is a + // decision the reader is owed. + it('says the statics are not compared', async () => { + const el = await compared(); + + expect(notes(el)).toContain(STATICS_NOTE); + }); + + it('does not say the statics are not compared for a single frame', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).not.toContain(STATICS_NOTE); + }); + + // A row's frames are its own scope; its calls are a level below it. A bottom-up + // caller row that ran once comes down to one frame, and reading the calls it + // counts would show the called method's scope under the caller's name. + it('reads its own frame where a merged row comes down to one', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|outerLocal|"here"\n' + + '09:18:22.6 (1020)|METHOD_ENTRY|[3]|01p|ns.Svc.query()\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[4]|inner|1\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[3]|ns.Svc.query()\n' + + '09:18:22.6 (1050)|METHOD_ENTRY|[3]|01p|ns.Svc.query()\n' + + '09:18:22.6 (1060)|VARIABLE_ASSIGNMENT|[4]|inner|2\n' + + '09:18:22.6 (1070)|METHOD_EXIT|[3]|ns.Svc.query()\n' + + '09:18:22.6 (1080)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const calls = store.log.eventsById + .filter((event) => event.isParent && event.text === 'ns.Svc.query()') + .map((event) => event.eventIndex); + + // What a bottom-up caller row hands over: the calls it counts, and the one + // frame that made them. + const el = await mount(store, { + eventIndex: calls[0]!, + frames: [indexOf(store, 'ns.Outer.run()')], + }); + + expect(rowNames(el)).toContain('outerLocal'); + expect(rowNames(el)).not.toContain('inner'); + }); +}); diff --git a/log-viewer/src/components/__tests__/controllerHostStub.ts b/log-viewer/src/components/__tests__/controllerHostStub.ts new file mode 100644 index 000000000..a0779f9df --- /dev/null +++ b/log-viewer/src/components/__tests__/controllerHostStub.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** + * The least a `ReactiveController` needs of its host, plus the two calls a test + * makes for Lit: `connect` and `disconnect` run the controllers' lifecycle. + */ +export class FakeHost implements ReactiveControllerHost { + readonly controllers: ReactiveController[] = []; + + addController(controller: ReactiveController): void { + this.controllers.push(controller); + } + removeController(): void {} + requestUpdate(): void {} + get updateComplete(): Promise { + return Promise.resolve(true); + } + + connect(): void { + for (const controller of this.controllers) { + controller.hostConnected?.(); + } + } + disconnect(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.(); + } + } +} diff --git a/log-viewer/src/components/__tests__/detailSections.test.ts b/log-viewer/src/components/__tests__/detailSections.test.ts index c96945c9c..8fb0e46f1 100644 --- a/log-viewer/src/components/__tests__/detailSections.test.ts +++ b/log-viewer/src/components/__tests__/detailSections.test.ts @@ -58,14 +58,21 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'event', eventIndex: 4 }); expect(sections.map((s) => s.id)).toEqual([ 'vitals', + 'variables', 'namespace-time', 'callstack', 'calltree', ]); // The call tree gets the most room, so it is the section worth reading. expect(sections.find((s) => s.id === 'calltree')?.weight).toBe(4); - // The vitals are a fixed set of figures: they take their own height only. - expect(sections.find((s) => s.id === 'vitals')?.fit).toBe('content'); + // The figures about the frame take a steady height, so walking the selection + // does not resize the stack under the reader. + expect(sections.find((s) => s.id === 'vitals')?.height).toBe('md'); + // The variables have no natural size, so they take a share and scroll. + expect(sections.find((s) => s.id === 'variables')?.weight).toBe(3); + // Asked of a frame it empties while the figures are added up, so it keeps a + // steady height rather than flickering on every step. + expect(sections.find((s) => s.id === 'namespace-time')?.height).toBe('sm'); }); it('delegates a database statement to the richer database sections', async () => { @@ -130,25 +137,67 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('database', { kind: 'event', eventIndex: 9 }); expect(databaseCalls).toEqual([]); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('scopes an aggregate selection to its first occurrence', async () => { const sections = await buildDetailSections('analysis', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'findings', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual([ + 'vitals', + 'variables', + 'findings', + 'callstack', + 'calltree', + ]); expect( (rendered(sections, 'vitals', 'event-vitals') as HTMLElement & { instances: number[] | null }) .instances, ).toEqual([11, 12, 13]); }); + // A bottom-up caller row counts its callee's calls, so reading variables from + // those would show the called method's scope under a row that names the caller. + it('gives Variables the frames the row is, not the calls it counts', async () => { + const sections = await buildDetailSections('analysis', { + kind: 'aggregate', + instances: [11, 12, 13], + frames: [4, 5, 6], + }); + + expect( + ( + rendered(sections, 'variables', 'variables-detail') as HTMLElement & { + frames: number[] | null; + } + ).frames, + ).toEqual([4, 5, 6]); + }); + + it('gives Variables the calls themselves where the row sits at their depth', async () => { + const sections = await buildDetailSections('analysis', { + kind: 'aggregate', + instances: [11, 12, 13], + frames: [11, 12, 13], + }); + + expect( + ( + rendered(sections, 'variables', 'variables-detail') as HTMLElement & { + frames: number[] | null; + } + ).frames, + ).toEqual([11, 12, 13]); + }); + it('asks the findings which of them name the selection', async () => { const sections = await buildDetailSections('analysis', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); const findings = rendered(sections, 'findings', 'log-diagnostics') as HTMLElement & { @@ -162,7 +211,7 @@ describe('buildDetailSections', () => { it('scopes the findings to the frame being followed, not the aggregate it left', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, { kind: 'event', eventIndex: 8 }, ); @@ -176,6 +225,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'event', eventIndex: 4 }); expect(sections.map((s) => s.id)).toEqual([ 'vitals', + 'variables', 'namespace-time', 'callstack', 'calltree', @@ -201,6 +251,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); const bar = rendered(sections, 'namespace-time', 'namespace-time-bar') as HTMLElement & { @@ -211,13 +262,13 @@ describe('buildDetailSections', () => { it('leaves the namespace split out for a selection from another tab', async () => { const sections = await buildDetailSections('calltree', { kind: 'event', eventIndex: 4 }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('drops the aggregate once a single frame in its stack is the one being followed', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, { kind: 'event', eventIndex: 8 }, ); @@ -232,8 +283,8 @@ describe('buildDetailSections', () => { it('describes the calls a walked bucket counts, as a bucket picked in the tab is', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, - { kind: 'aggregate', instances: [21, 22], calledBy: 'Trigger1' }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, + { kind: 'aggregate', instances: [21, 22], frames: [21, 22], calledBy: 'Trigger1' }, ); const vitals = rendered(sections, 'vitals', 'event-vitals') as HTMLElement & { @@ -283,6 +334,10 @@ describe('buildDetailSections', () => { expect(sections.find((s) => s.id === 'calltree')?.weight).toBe(4); expect(sections.find((s) => s.id === 'calltree')?.fit ?? 'fill').toBe('fill'); expect(sections.find((s) => s.id === 'governor-trends')?.fit).toBe('content'); + // Worked out once for the whole log, so this one is snug rather than steady: + // the same section takes a height only when it answers about a frame. + expect(sections.find((s) => s.id === 'namespace-time')?.fit).toBe('content'); + expect(sections.find((s) => s.id === 'namespace-time')?.height).toBeUndefined(); // The tab draws the log top down, so the tree opens on where the time went. const tree = rendered(sections, 'calltree', 'call-tree-detail') as CallTreeDetail; expect(tree.sourceView).toBe('callees'); diff --git a/log-viewer/src/components/__tests__/governorTrendData.test.ts b/log-viewer/src/components/__tests__/governorTrendData.test.ts index f19307a85..34236320a 100644 --- a/log-viewer/src/components/__tests__/governorTrendData.test.ts +++ b/log-viewer/src/components/__tests__/governorTrendData.test.ts @@ -42,6 +42,24 @@ describe('governorTrendSeries', () => { ]); }); + // No limit to divide by, so the metric's own peak stands in: the shape still draws, and `limit` + // stays 0, which is how the chart knows to drop the guide and the tier colour. + it("charts against the metric's own peak when the log reported no limit", () => { + const series = governorTrendSeries( + timeSeries([ + seriesEvent(1_000, { queryRows: { used: 300, limit: 0 } }), + seriesEvent(2_000, { queryRows: { used: 1_200, limit: 0 } }), + ]), + ); + + expect(series[0]).toMatchObject({ label: 'Query Rows', used: 1_200, limit: 0, finalRatio: 0 }); + expect(series[0]?.points).toEqual([ + { t: 0, ratio: 0, used: 0 }, + { t: 1_000, ratio: 25, used: 300 }, + { t: 2_000, ratio: 100, used: 1_200 }, + ]); + }); + it('leaves out a metric whose final consumption is zero', () => { const series = governorTrendSeries( timeSeries([ diff --git a/log-viewer/src/components/__tests__/inspectorLayout.test.ts b/log-viewer/src/components/__tests__/inspectorLayout.test.ts new file mode 100644 index 000000000..847d36fb8 --- /dev/null +++ b/log-viewer/src/components/__tests__/inspectorLayout.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { html } from 'lit'; + +import type { PaneSection } from '../PaneView.js'; +import { + hiddenIds, + keepUnbuilt, + layoutKey, + mergeOrder, + orderSections, + scopedKey, + scopedRecord, + withoutScope, +} from '../inspectorLayout.js'; + +function sectionsOf(...ids: string[]): PaneSection[] { + return ids.map((id) => ({ id, title: id, content: html`
${id}
` })); +} + +function idsOf(sections: PaneSection[]): string[] { + return sections.map((section) => section.id); +} + +describe('layoutKey', () => { + it('names the tab and the scope, so one id is two lists', () => { + expect(layoutKey('timeline', 'summary')).toBe('timeline:summary'); + expect(layoutKey('timeline', 'detail')).not.toBe(layoutKey('timeline', 'summary')); + expect(layoutKey('analysis', 'detail')).not.toBe(layoutKey('calltree', 'detail')); + }); +}); + +describe('scopedKey', () => { + it('names one section inside its list', () => { + expect(scopedKey(layoutKey('timeline', 'detail'), 'callstack')).toBe( + 'timeline:detail:callstack', + ); + }); +}); + +describe('scopedRecord', () => { + const store = { + 'timeline:detail:callstack': true, + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }; + + it('takes one list’s entries, by plain section id', () => { + expect(scopedRecord(store, 'timeline:detail')).toEqual({ callstack: true }); + expect(scopedRecord(store, 'timeline:summary')).toEqual({ calltree: true }); + }); + + it('is empty for a list with nothing remembered', () => { + expect(scopedRecord(store, 'database:summary')).toEqual({}); + }); + + it('is empty without a key, so a list still building matches nothing', () => { + expect(scopedRecord(store, '')).toEqual({}); + }); +}); + +describe('withoutScope', () => { + it('drops one list’s entries and leaves every other list alone', () => { + const store = { + 'timeline:detail:callstack': true, + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }; + + expect(withoutScope(store, 'timeline:detail')).toEqual({ + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }); + }); +}); + +describe('orderSections', () => { + const built = sectionsOf('vitals', 'variables', 'callstack', 'calltree'); + + it('returns the builder order when the user has arranged nothing', () => { + expect(orderSections(built)).toBe(built); + expect(idsOf(orderSections(built, []))).toEqual(idsOf(built)); + }); + + it('applies the order the user arranged', () => { + const order = ['calltree', 'vitals', 'callstack', 'variables']; + expect(idsOf(orderSections(built, order))).toEqual(order); + }); + + it('leaves the built sections alone', () => { + orderSections(built, ['calltree', 'vitals']); + expect(idsOf(built)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); + }); + + it('keeps a section the order never named behind the one it follows', () => { + // `variables` arrived after this list was last arranged, and follows + // `vitals` in the builder's list. + const order = ['calltree', 'vitals', 'callstack']; + expect(idsOf(orderSections(built, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); + + it('leads with an unnamed section the order names nothing before', () => { + expect(idsOf(orderSections(built, ['calltree']))).toEqual([ + 'vitals', + 'variables', + 'callstack', + 'calltree', + ]); + }); + + it('places a section the arranged list did not have where the builder puts it', () => { + // Reordered while a DML row was selected, so the order never named + // `issues` — which the builder puts after `callstack` for a SOQL one. + const soql = sectionsOf('vitals', 'variables', 'callstack', 'issues', 'calltree'); + const order = ['calltree', 'vitals', 'variables', 'callstack']; + + expect(idsOf(orderSections(soql, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + 'issues', + ]); + }); + + it('ignores an id this list no longer has', () => { + const order = ['issues', 'calltree', 'vitals', 'variables', 'callstack']; + expect(idsOf(orderSections(built, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); +}); + +describe('hiddenIds', () => { + it('takes the ids this list hides', () => { + const store = { 'timeline:detail:calltree': true, 'analysis:detail:findings': true }; + + expect(hiddenIds(store, 'timeline:detail')).toEqual(new Set(['calltree'])); + expect(hiddenIds(store, 'timeline:summary')).toEqual(new Set()); + }); + + it('ignores an entry left behind by bringing a section back', () => { + expect(hiddenIds({ 'timeline:detail:calltree': false }, 'timeline:detail')).toEqual(new Set()); + }); +}); + +describe('mergeOrder', () => { + it('is the dragged order when the list hides nothing', () => { + expect(mergeOrder(['vitals', 'callstack'], new Set(), ['callstack', 'vitals'])).toEqual([ + 'callstack', + 'vitals', + ]); + }); + + it('keeps a hidden section behind the one it follows', () => { + // `variables` sits behind `vitals`, hidden; moving `calltree` up must not + // strand it at the end of the list. + const ids = ['vitals', 'variables', 'callstack', 'calltree']; + + expect(mergeOrder(ids, new Set(['variables']), ['calltree', 'vitals', 'callstack'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); + + it('leads with a hidden section that has nothing above it', () => { + const ids = ['overview', 'findings', 'calltree']; + + expect(mergeOrder(ids, new Set(['overview']), ['calltree', 'findings'])).toEqual([ + 'overview', + 'calltree', + 'findings', + ]); + }); + + it('keeps several hidden sections behind the same one, in order', () => { + const ids = ['vitals', 'variables', 'issues', 'calltree']; + + expect(mergeOrder(ids, new Set(['variables', 'issues']), ['calltree', 'vitals'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'issues', + ]); + }); +}); + +describe('keepUnbuilt', () => { + it('is the arranged order when the store knew nothing more', () => { + expect(keepUnbuilt(['vitals', 'callstack'], ['callstack', 'vitals'])).toEqual([ + 'callstack', + 'vitals', + ]); + }); + + it('keeps an id this build never produced behind the one it followed', () => { + // Arranged under a SOQL statement, reordered under a DML one, which builds + // no `issues` section at all. + const stored = ['vitals', 'variables', 'callstack', 'issues', 'calltree']; + + expect(keepUnbuilt(stored, ['calltree', 'vitals', 'variables', 'callstack'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + 'issues', + ]); + }); + + it('leads with an unbuilt id the store put above everything', () => { + // They dragged SOQL issues to the top; a reorder under a DML row must not + // cost them that. + const stored = ['issues', 'vitals', 'callstack']; + + expect(keepUnbuilt(stored, ['callstack', 'vitals'])).toEqual(['issues', 'callstack', 'vitals']); + }); + + it('takes a section the store never named', () => { + expect(keepUnbuilt(['vitals', 'callstack'], ['callstack', 'variables', 'vitals'])).toEqual([ + 'callstack', + 'variables', + 'vitals', + ]); + }); + + it('is the arranged order when nothing is stored yet', () => { + expect(keepUnbuilt([], ['callstack', 'vitals'])).toEqual(['callstack', 'vitals']); + }); +}); diff --git a/log-viewer/src/components/__tests__/inspectorTab.test.ts b/log-viewer/src/components/__tests__/inspectorTab.test.ts new file mode 100644 index 000000000..fbc108043 --- /dev/null +++ b/log-viewer/src/components/__tests__/inspectorTab.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { afterEach, describe, expect, it } from '@jest/globals'; + +import { eventBus } from '../../core/events/EventBus.js'; +import { InspectorEmphasis } from '../inspectorEmphasis.js'; +import { revealFirstOf, wireInspectorTab } from '../inspectorTab.js'; + +describe('wireInspectorTab', () => { + let off: (() => void) | null = null; + + afterEach(() => { + off?.(); + off = null; + }); + + /** A Call Tree-like view: it records what it marked, moved to and cleared. */ + function wire( + movesToMergedPick = true, + reveal?: (eventIndex: number, signal: AbortSignal) => Promise, + ) { + const marks: Array = []; + const revealed: number[] = []; + /** The signal each move was given, so a test can read which were abandoned. */ + const signals: AbortSignal[] = []; + /** Marks and moves in the order they arrived, which the two lists cannot show. */ + const order: string[] = []; + let clears = 0; + const moveTo = (eventIndex: number, signal: AbortSignal): void | Promise => { + order.push('move'); + signals.push(signal); + if (reveal) { + return reveal(eventIndex, signal); + } + revealed.push(eventIndex); + }; + off = wireInspectorTab('calltree', new InspectorEmphasis(), { + mark: (eventIndexes) => { + marks.push(eventIndexes); + order.push('mark'); + }, + reveal: moveTo, + clear: () => { + clears++; + }, + // As the tables do it, through the helper they ship with. + revealMerged: movesToMergedPick ? revealFirstOf(moveTo) : undefined, + }); + return { marks, revealed, order, signals, clears: () => clears }; + } + + it('leaves an event for another tab alone', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:reveal', { source: 'analysis', eventIndex: 4 }); + eventBus.emit('selection:clear', { source: 'analysis' }); + + expect(view.marks).toEqual([]); + expect(view.revealed).toEqual([]); + expect(view.clears()).toBe(0); + }); + + it('marks under the pointer without moving the view', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: false }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([]); + }); + + it('marks a picked row and moves to the first of its occurrences', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: true }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([4]); + // The mark goes on first: a row the move renders lights itself from it. + expect(view.order).toEqual(['mark', 'move']); + }); + + it('only marks a picked row where moving to one occurrence would be arbitrary', () => { + const view = wire(false); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: true }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([]); + }); + + it('moves to the one frame a single-frame pick names, wherever the mark is', () => { + const view = wire(false); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 7 }); + + expect(view.revealed).toEqual([7]); + }); + + it('clears the mark when the pick is dropped', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [], sticky: true }); + + expect(view.marks.at(-1)).toEqual([]); + // Nothing to move to, so the view is left where the user put it. + expect(view.revealed).toEqual([4]); + }); + + it('drops the view selection and the mark on an app-wide clear', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('selection:clear', { source: 'calltree' }); + + expect(view.clears()).toBe(1); + expect(view.marks.at(-1)).toEqual([]); + }); + + it('keeps the mark where the view cannot move', async () => { + const view = wire(true, () => Promise.reject(new Error('no row for it'))); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(view.marks).toEqual([[4]]); + }); + + it('abandons a move the next one replaces, and keeps both marks', () => { + const view = wire(); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [9], sticky: true }); + + // The pointer crossing rows asks for a move per row, and the one it stops on + // is the one that scrolls. + expect(view.signals.map((signal) => signal.aborted)).toEqual([true, false]); + expect(view.marks).toEqual([[9]]); + }); + + it('abandons the move in flight when the view goes away', () => { + const view = wire(); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + off?.(); + off = null; + + expect(view.signals[0]?.aborted).toBe(true); + }); + + it('stops answering once unsubscribed', () => { + const view = wire(); + + off?.(); + off = null; + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('selection:clear', { source: 'calltree' }); + + expect(view.marks).toEqual([]); + expect(view.revealed).toEqual([]); + expect(view.clears()).toBe(0); + }); +}); diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 42c861165..d435d310c 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -3,21 +3,22 @@ * * @jest-environment jsdom */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import type { RowComponent } from 'tabulator-tables'; import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; -import { ROOT_PATH_ID, KeyPathIds } from '../../core/log/keyPathIds.js'; +import { logStoreFor } from '../../core/log/LogStore.js'; +import { KeyPathIds, ROOT_PATH_ID } from '../../core/log/keyPathIds.js'; import { LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, - eventPathIds, + rowDetailSelection, + rowFrames, rowIndexStamper, rowPathId, - rowPathStamper, + stampRowPath, } from '../locatedRow.js'; const stamp = rowIndexStamper('eventIndex'); @@ -34,51 +35,83 @@ function rowComponent( } as unknown as RowComponent; } -/** A bucket row and its chain of parents, innermost last, as tabulator hands them - * over: the tree parent of a bottom-up caller row is the frame it called. */ -function bucketRow(...keys: string[]): RowComponent { - let row: RowComponent | false = false; +/** A bucket row as its builder leaves it: the key it merges, and the path that + * tells it from a same-named row under another caller. */ +function bucketRow(ids: KeyPathIds, ...keys: string[]): RowComponent { + let pathId = ROOT_PATH_ID; for (const key of keys) { - row = rowComponent(document.createElement('div'), { key }, row); + pathId = ids.step(pathId, ids.keyId(key)); } - return row as RowComponent; + return rowComponent(document.createElement('div'), { + key: keys[keys.length - 1], + _pathId: pathId, + }); } -function ev(text: string, parent: LogEvent | null): LogEvent { - return { type: 'METHOD_ENTRY', namespace: '', text, parent } as unknown as LogEvent; +function ev(text: string, parent: LogEvent | null, eventIndex?: number): LogEvent { + return { type: 'METHOD_ENTRY', namespace: '', text, parent, eventIndex } as unknown as LogEvent; } -/** A table host holding a rendered row element per index, as the stamp leaves them. */ +/** A table host holding a rendered row element per index, as the stamp leaves + * them, inside the holder and spacer element Tabulator mounts. */ function host(...indexes: number[]): HTMLElement { const element = document.createElement('div'); + const holder = document.createElement('div'); + holder.classList.add('tabulator-tableholder'); + const spacers = document.createElement('div'); + spacers.classList.add('tabulator-table'); + // The renderer always writes both spacers, so start where a rendered table is. + spacers.style.paddingTop = '0px'; + spacers.style.paddingBottom = '0px'; + holder.append(spacers); + element.append(holder); for (const index of indexes) { const row = document.createElement('div'); row.classList.add('tabulator-row'); stamp(rowComponent(row, { eventIndex: index })); - element.append(row); + spacers.append(row); } return element; } +/** + * Re-attaches a row the renderer had detached, which is what a scroll or a sort + * does with a row it has already built. Awaits the observer, which reports after + * the arrival. + */ +async function reattach(container: HTMLElement, row: HTMLElement): Promise { + container.querySelector('.tabulator-table')!.append(row); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function rowFor(container: HTMLElement, index: number): HTMLElement { - return container.children[index] as HTMLElement; + return container.querySelectorAll('.tabulator-row')[index]!; +} + +/** A row entering an already-mounted table, which is what the renderer does the + * first time one is scrolled to: in the DOM first, stamped as it initialises. */ +function renderRow(container: HTMLElement, index: number): HTMLElement { + const row = document.createElement('div'); + row.classList.add('tabulator-row'); + container.querySelector('.tabulator-table')!.append(row); + stamp(rowComponent(row, { eventIndex: index })); + return row; } -describe('rowPathStamper', () => { +describe('stampRowPath', () => { it('marks the row under one parent and not its namesake under another', () => { - const ids = new KeyPathIds(); - const stampPath = rowPathStamper(ids); + const ids = new KeyPathIds(0); const container = document.createElement('div'); - const rows = [bucketRow('Trigger1', 'Util.log'), bucketRow('Trigger2', 'Util.log')]; + const rows = [bucketRow(ids, 'Trigger1', 'Util.log'), bucketRow(ids, 'Trigger2', 'Util.log')]; for (const row of rows) { const element = row.getElement(); element.classList.add('tabulator-row'); - stampPath(row); + stampRowPath(row); container.append(element); } const marker = new LocatedRowMarker(); - marker.mark(container, [rowPathId(rows[0]!, ids)!]); + marker.mark(container, [rowPathId(rows[0]!)!]); expect(rows[0]!.getElement().classList.contains(LOCATED_ROW_CLASS)).toBe(true); expect(rows[1]!.getElement().classList.contains(LOCATED_ROW_CLASS)).toBe(false); @@ -96,65 +129,8 @@ describe('rowIndexStamper', () => { }); describe('rowPathId', () => { - let ids: KeyPathIds; - beforeEach(() => { - ids = new KeyPathIds(); - }); - - it('names a top-level row by its own key alone', () => { - expect(rowPathId(bucketRow('A'), ids)).toBe(ids.pathId(ROOT_PATH_ID, 'A')); - }); - - it('tells two same-named rows apart by the parents that reach them', () => { - // One method holds a row under every caller it has, so the key alone cannot. - expect(rowPathId(bucketRow('Trigger1', 'Util.log'), ids)).not.toBe( - rowPathId(bucketRow('Trigger2', 'Util.log'), ids), - ); - }); - - it('gives one id to the whole path, so two rows on it agree', () => { - const deep = rowPathId(bucketRow('A', 'B', 'C'), ids); - expect(deep).toBe(rowPathId(bucketRow('A', 'B', 'C'), ids)); - expect(deep).not.toBe(rowPathId(bucketRow('A', 'B'), ids)); - }); - it('leaves a row that stands for one frame unnamed, as its index names it', () => { - expect(rowPathId(rowComponent(document.createElement('div'), { id: 7 }), ids)).toBeUndefined(); - }); -}); - -describe('eventPathIds', () => { - const root = ev('exec', null); - const outer = ev('outer', root); - const inner = ev('inner', outer); - let store: LogStore; - let ids: KeyPathIds; - beforeEach(() => { - // A store per test, since each expects an empty table. The frames are not in - // this log's index, so the ids are minted rather than read from the cache. - store = logStoreFor({ eventsById: [] } as unknown as ApexLog); - ids = store.keyPathIds(); - }); - - it('names one row in a top-down view, at the depth the frame ran at', () => { - const found = eventPathIds(inner, 'callees', store); - - expect(found).toHaveLength(1); - expect(found[0]).toBe(rowPathId(bucketRow('METHOD_ENTRY||outer', 'METHOD_ENTRY||inner'), ids)); - }); - - it('names a row per caller depth in a bottom-up view', () => { - // The frame heads a row on its own, and one under each caller above it. - const found = eventPathIds(inner, 'callers', store); - - expect(found).toEqual([ - rowPathId(bucketRow('METHOD_ENTRY||inner'), ids), - rowPathId(bucketRow('METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'), ids), - ]); - }); - - it('leaves the log root out, as it is a row in neither view', () => { - expect(eventPathIds(root, 'callers', store)).toEqual([]); + expect(rowPathId(rowComponent(document.createElement('div'), { id: 7 }))).toBeUndefined(); }); }); @@ -208,6 +184,100 @@ describe('LocatedRowMarker', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); }); + it('lights a row that arrives after the mark, as scrolling to a new one does', () => { + const container = host(); + new LocatedRowMarker().mark(container, [4]); + + // The sweep found no rows, so this is the row lighting itself. + expect(renderRow(container, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(true); + expect(renderRow(container, 5).classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('un-lights a row the renderer hands back with the class still on it', () => { + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + const row = renderRow(container, 4); + + marker.mark(container, [5]); + // Re-used rather than rebuilt, so it arrives carrying the old mark. + row.classList.add(LOCATED_ROW_CLASS); + stamp(rowComponent(row, { eventIndex: 4 })); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('un-lights a row the renderer had detached when the mark moved', () => { + // The formatter does not run again for a row the renderer only re-attaches, + // so a class left on a detached element comes back with it. + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + const row = renderRow(container, 4); + + row.remove(); + marker.mark(container, [5]); + container.querySelector('.tabulator-table')!.append(row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('marks a row the renderer had detached before the mark named it', async () => { + // Rendered once, so it will not be stamped again, then scrolled out of view: + // the renderer keeps the element and detaches it. + const container = host(); + const row = renderRow(container, 4); + row.remove(); + + // Only now does the mark name it, so neither half can reach it. + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + + // Coming back is what re-reads the mark, whatever moved the window: this + // holds for a sort at the top of a table, which writes no spacer at all. + await reattach(container, row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); + }); + + it('keeps watching a table rebuilt into the same container', async () => { + // Several views destroy the table and build another in the same element, so + // a watch held against the old one would go quiet for good. + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + container.querySelector('.tabulator-tableholder')!.remove(); + const rebuilt = host(4); + container.append(rebuilt.querySelector('.tabulator-tableholder')!); + + marker.mark(container, [4]); + const row = renderRow(container, 9); + row.remove(); + marker.mark(container, [9]); + await reattach(container, row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); + }); + + it('leaves a row alone where nothing has marked its table', () => { + const row = renderRow(host(), 4); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('stops lighting rows of a table the mark has left', () => { + const first = host(); + const second = host(); + const marker = new LocatedRowMarker(); + + marker.mark(first, [4]); + marker.mark(second, [4]); + + expect(renderRow(first, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(false); + expect(renderRow(second, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(true); + }); + it('leaves a row the table has not rendered alone', () => { const container = host(1); const marker = new LocatedRowMarker(); @@ -218,17 +288,145 @@ describe('LocatedRowMarker', () => { }); }); +describe('rowFrames', () => { + /** exec -> m1 -> soql, with the indexes the log answers about. */ + function log() { + const exec = ev('exec', null, 1); + const m1 = ev('m1', exec, 3); + const soql = ev('soql', m1, 5); + return { + soql, + apexLog: { eventsById: { 1: exec, 3: m1, 5: soql } } as unknown as ApexLog, + }; + } + + /** The bucket for `soql` and the caller row under it, as a bottom-up grid + * leaves them: the bucket holds the occurrences, the caller row derives its. */ + function rows(apexLog: ApexLog, soql: LogEvent) { + const paths = logStoreFor(apexLog).keyPathIds(); + const bucketPath = paths.step(ROOT_PATH_ID, paths.keyIdOf(soql)); + const bucket = rowComponent(document.createElement('div'), { + key: 'soql', + _pathId: bucketPath, + instances: [soql], + }); + const caller = rowComponent( + document.createElement('div'), + { key: 'm1', _pathId: paths.step(bucketPath, paths.keyIdOf(soql.parent!)) }, + bucket, + ); + return { bucket, caller }; + } + + it('names the caller a bottom-up row is, not the calls it counts', () => { + const { apexLog, soql } = log(); + const { caller } = rows(apexLog, soql); + + expect(rowFrames(caller, apexLog, 'callers')).toEqual([3]); + }); + + it('leaves a top-down row as the calls it counts, since it sits at their depth', () => { + const { apexLog, soql } = log(); + const { caller } = rows(apexLog, soql); + + expect(rowFrames(caller, apexLog, 'callees')).toEqual([5]); + }); + + it('names its own occurrences for the row a bottom-up tree is seeded from', () => { + const { apexLog, soql } = log(); + const { bucket } = rows(apexLog, soql); + + expect(rowFrames(bucket, apexLog, 'callers')).toEqual([5]); + }); +}); + +describe('rowDetailSelection', () => { + /** exec -> m1 -> soql, with the bucket and caller rows a bottom-up grid + * leaves: the bucket holds the occurrences, the caller row derives its own. */ + function rows() { + const exec = ev('exec', null, 1); + const m1 = ev('m1', exec, 3); + const soql = ev('soql', m1, 5); + const apexLog = { eventsById: { 1: exec, 3: m1, 5: soql } } as unknown as ApexLog; + const paths = logStoreFor(apexLog).keyPathIds(); + const bucketPath = paths.step(ROOT_PATH_ID, paths.keyIdOf(soql)); + const bucket = rowComponent(document.createElement('div'), { + key: 'soql', + _pathId: bucketPath, + instances: [soql], + originalData: soql, + text: 'soql', + }); + const caller = rowComponent( + document.createElement('div'), + { + key: 'm1', + _pathId: paths.step(bucketPath, paths.keyIdOf(m1)), + originalData: soql, + text: 'm1', + }, + bucket, + ); + return { apexLog, bucket, caller }; + } + + // Its locals are the caller's, a level above the calls its totals count. + it('names the frames a bottom-up caller row is, beside the calls it counts', () => { + const { apexLog, caller } = rows(); + + expect(rowDetailSelection(caller, apexLog, 'callers')).toEqual({ + kind: 'aggregate', + instances: [5], + frames: [3], + calledBy: 'm1', + }); + }); + + it('gives a root bucket its own calls, as it stands at their depth', () => { + const { apexLog, bucket } = rows(); + + expect(rowDetailSelection(bucket, apexLog, 'callers')).toEqual({ + kind: 'aggregate', + instances: [5], + frames: [5], + calledBy: undefined, + }); + }); + + it('gives a top-down row its own calls, as it sits at their depth', () => { + const { apexLog, caller } = rows(); + + expect(rowDetailSelection(caller, apexLog, 'callees')).toMatchObject({ + instances: [5], + frames: [5], + }); + }); + + it('names the one call a Time Order row is', () => { + const soql = ev('soql', null, 5); + const row = rowComponent(document.createElement('div'), { originalData: soql }); + + expect(rowDetailSelection(row, null, 'callees')).toEqual({ kind: 'event', eventIndex: 5 }); + }); +}); + describe('LocatedRowIds', () => { const root = ev('exec', null); const outerFrame = ev('outer', root); const frame = ev('inner', outerFrame); const log = { eventsById: { 5: frame } } as unknown as ApexLog; - it('builds the paths of the rows the frames belong to', () => { - const found = new LocatedRowIds().idsFor(log, [5], 'callers'); + it('builds the paths of the rows the frames stand for', () => { + const paths = logStoreFor(log).keyPathIds(); + // The row the frame is, and a row for it under another bucket. + const own = paths.step(ROOT_PATH_ID, paths.keyIdOf(frame)); + const under = paths.step(paths.step(ROOT_PATH_ID, paths.keyId('other')), paths.keyIdOf(frame)); + // The row of the caller above it, which stands for the caller. + paths.step(own, paths.keyIdOf(outerFrame)); - // The log's own table, so a row stamped from it reaches the same ids. - expect(found).toEqual(eventPathIds(frame, 'callers', logStoreFor(log))); + expect(new LocatedRowIds().idsFor(log, [5], 'callers').slice().sort()).toEqual( + [own, under].sort(), + ); }); it('reuses what it built for the frames it was last asked about', () => { diff --git a/log-viewer/src/components/__tests__/logOverviewMetrics.test.ts b/log-viewer/src/components/__tests__/logOverviewMetrics.test.ts index c288370ff..049b52e95 100644 --- a/log-viewer/src/components/__tests__/logOverviewMetrics.test.ts +++ b/log-viewer/src/components/__tests__/logOverviewMetrics.test.ts @@ -3,7 +3,12 @@ */ import { describe, expect, it } from '@jest/globals'; -import { GOVERNOR_METRICS, limitTotals, seriesGauges } from '../logOverviewMetrics.js'; +import { + GOVERNOR_METRICS, + limitTotals, + metricSparkline, + seriesGauges, +} from '../logOverviewMetrics.js'; import { emptyLimits, seriesEvent, timeSeries } from './limitsTestUtils.js'; describe('limitTotals', () => { @@ -61,10 +66,10 @@ describe('seriesGauges', () => { ]), ); - expect(gauges).toEqual([{ label: 'SOQL', found: 70, used: 70, limit: 100 }]); + expect(gauges).toMatchObject([{ label: 'SOQL', found: 70, used: 70, limit: 100 }]); }); - it('ranks by percentage, drops zero usage or limit, and caps at six', () => { + it('ranks by percentage, drops zero usage, and caps at six', () => { const gauges = seriesGauges( timeSeries([ seriesEvent(1_000, { @@ -109,4 +114,76 @@ describe('seriesGauges', () => { it('returns nothing for a series without events', () => { expect(seriesGauges(timeSeries([]))).toEqual([]); }); + + describe('a log that reported no limits', () => { + const levels = [3, 5, 5, 9, 12]; + const noLimitSeries = () => + timeSeries( + levels.map((used, i) => + seriesEvent((i + 1) * 1_000, { + soqlQueries: { used, limit: 0 }, + dmlStatements: { used: used * 2, limit: 0 }, + }), + ), + ); + + // Nothing to rank by, so reading order stands in rather than ordering by raw size, which + // would read as a ranking it cannot be. + it('keeps the metrics in reading order', () => { + expect(seriesGauges(noLimitSeries()).map((gauge) => gauge.label)).toEqual(['SOQL', 'DML']); + }); + + it('carries a sparkline instead of a bar', () => { + const [soql] = seriesGauges(noLimitSeries()); + + expect(soql).toMatchObject({ label: 'SOQL', used: 12, limit: 0 }); + expect(soql?.spark).toEqual(levels); + }); + }); + + it('carries no sparkline where the log reported a limit', () => { + const gauges = seriesGauges( + timeSeries([seriesEvent(1_000, { soqlQueries: { used: 70, limit: 100 } })]), + ); + + expect(gauges[0]?.spark).toBeUndefined(); + }); +}); + +describe('metricSparkline', () => { + const of = (levels: number[]) => + timeSeries(levels.map((used, i) => seriesEvent(i + 1, { soqlQueries: { used, limit: 0 } }))); + + // Four readings are dots, not a shape, so the gauge is left with its figure alone. + it('draws nothing below five readings', () => { + expect(metricSparkline(of([1, 2, 3, 4]), 'soqlQueries')).toEqual([]); + }); + + it('keeps every reading up to twenty', () => { + const levels = [1, 2, 3, 4, 5, 6]; + expect(metricSparkline(of(levels), 'soqlQueries')).toEqual(levels); + }); + + // Bucket extremes, so both the spike and the dip survive the downsample. + it('reduces a long series to twenty readings, first and last kept', () => { + const levels = Array.from({ length: 500 }, (_, i) => (i === 250 ? 0 : i)); + const spark = metricSparkline(of(levels), 'soqlQueries'); + + expect(spark).toHaveLength(20); + expect(spark[0]).toBe(0); + expect(spark[spark.length - 1]).toBe(499); + }); + + // An even-interval sample stepped over the one reading that spiked — the whole point of the + // shape, and the figure printed beside it. + it('keeps a spike that falls between the sample intervals', () => { + const levels = Array.from({ length: 500 }, (_, i) => (i === 137 ? 9_999 : 1)); + + expect(metricSparkline(of(levels), 'soqlQueries')).toContain(9_999); + }); + + it('answers the same array for the same series and metric', () => { + const series = of([1, 2, 3, 4, 5]); + expect(metricSparkline(series, 'soqlQueries')).toBe(metricSparkline(series, 'soqlQueries')); + }); }); diff --git a/log-viewer/src/components/__tests__/scopedCallTree.test.ts b/log-viewer/src/components/__tests__/scopedCallTree.test.ts index 40543617a..3e782dc8a 100644 --- a/log-viewer/src/components/__tests__/scopedCallTree.test.ts +++ b/log-viewer/src/components/__tests__/scopedCallTree.test.ts @@ -1,9 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { describe, expect, it } from '@jest/globals'; - -import type { LogEvent } from 'apex-log-parser'; +import { beforeEach, describe, expect, it } from '@jest/globals'; interface FakeEvent { eventIndex: number; @@ -56,33 +54,40 @@ let selectedIndex = 4; const { KeyPathIds } = jest.requireActual( '../../core/log/keyPathIds.js', ); -const { getEventKey, getStackKey } = jest.requireActual< - typeof import('../../core/log/eventKeys.js') ->('../../core/log/eventKeys.js'); -const paths = new KeyPathIds(); +// One table per log in production. The fixtures below reuse event indexes for +// different frames, so each test gets its own rather than one frame's key being +// read back for another. +let paths = new KeyPathIds(1024); +const { LogStore } = jest.requireActual( + '../../core/log/LogStore.js', +); jest.mock('../../core/log/LogStore.js', () => ({ - currentLogStore: () => ({ - log: root, - keyPathIds: () => paths, - keyIdOf: (event: FakeEvent) => paths.keyId(getEventKey(event as unknown as LogEvent)), - stackIdOf: (event: FakeEvent) => paths.keyId(getStackKey(event as unknown as LogEvent)), - eventByIndex: (i: number) => byId.get(i) ?? null, - // Mirrors LogStore.stackByEventIndex over the fixture's own index. - stackByEventIndex: (i: number) => { - const stack: FakeEvent[] = []; - for (let node = byId.get(i) ?? null; node && node !== root; node = node.parent) { - if (node.isParent) { - stack.push(node); + currentLogStore: () => { + const store = { + log: root, + keyPathIds: () => paths, + eventByIndex: (i: number) => byId.get(i) ?? null, + // Mirrors LogStore.stackByEventIndex over the fixture's own index. + stackByEventIndex: (i: number) => { + const stack: FakeEvent[] = []; + for (let node = byId.get(i) ?? null; node && node !== root; node = node.parent) { + if (node.isParent) { + stack.push(node); + } } - } - return stack.reverse(); - }, - }), + return stack.reverse(); + }, + }; + // The real climb, so what it answers about the fixture is under test rather + // than a second copy of it. It reads only `eventByIndex`. + return { ...store, framesAbove: LogStore.prototype.framesAbove.bind(store) }; + }, })); import { buildScopedCallTree, buildWholeLogCallTree, + frameEventIndexes, locatableEventIndexes, rowIdsByPath, type ScopedRow, @@ -93,6 +98,10 @@ import type { FrameBudgetOptions } from '../../core/utility/FrameBudget.js'; * is only there to satisfy the contract. */ const options: FrameBudgetOptions = { yieldSlice: () => Promise.resolve() }; +beforeEach(() => { + paths = new KeyPathIds(1024); +}); + function build(eventIndex: number, instances?: number[]) { return buildScopedCallTree(eventIndex, instances ?? null, options); } @@ -551,6 +560,37 @@ describe('rowIdsByPath', () => { }); }); +describe('frameEventIndexes', () => { + it("names the callers at the row's own depth, not the calls they conducted", async () => { + // exec -> m1 -> m2 -> soql, so the bottom-up seed is the statement and each + // row under it is one frame further up the same stack. + const rows = (await (await build(1))!.bottomUp(options))!; + const seed = rows[0]!; + const m2Row = seed._children![0]!; + const m1Row = m2Row._children![0]!; + + expect(frameEventIndexes(seed)).toEqual([soql.eventIndex]); + expect(frameEventIndexes(m2Row)).toEqual([m2.eventIndex]); + expect(frameEventIndexes(m1Row)).toEqual([m1.eventIndex]); + }); + + it('names one caller frame however many calls it made', async () => { + const instances = loopOccurrences(2); + const rows = (await (await build(300))!.bottomUp(options))!; + const caller = rows[0]!._children![0]!; + + // The row counts both calls, and is the single frame that made them. + expect(locatableEventIndexes(caller)).toEqual(instances); + expect(frameEventIndexes(caller)).toEqual([300]); + }); + + it('names the one frame of a row that merges nothing', () => { + const row = { id: 1, originalData: soql } as unknown as Partial; + + expect(frameEventIndexes(row)).toEqual([soql.eventIndex]); + }); +}); + describe('bottom-up occurrences', () => { it('derives a caller row from the top-level row, holding no calls itself', async () => { // Rebuilds the loop and its two calls; the loop itself is the selection. diff --git a/log-viewer/src/components/__tests__/sectionMenu.test.ts b/log-viewer/src/components/__tests__/sectionMenu.test.ts new file mode 100644 index 000000000..d93f4743c --- /dev/null +++ b/log-viewer/src/components/__tests__/sectionMenu.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { html } from 'lit'; + +import type { PaneSection } from '../PaneView.js'; +import { RESET_SECTIONS_ID, buildSectionMenuItems, sectionIdFor } from '../sectionMenu.js'; + +function sections(...ids: string[]): PaneSection[] { + return ids.map((id) => ({ id, title: id.toUpperCase(), content: html`
${id}
` })); +} + +describe('sectionIdFor', () => { + it('reads the section off a section row', () => { + expect(sectionIdFor('section:callstack')).toBe('callstack'); + }); + + it('names no section for any other row', () => { + expect(sectionIdFor(RESET_SECTIONS_ID)).toBeNull(); + expect(sectionIdFor('section-sep')).toBeNull(); + }); +}); + +describe('buildSectionMenuItems', () => { + it('heads the menu with the reset, then a row per section', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set(['calltree'])); + + expect(items[0]).toEqual({ id: RESET_SECTIONS_ID, label: 'Reset Sections' }); + expect(items[1]?.separator).toBe(true); + expect(items.slice(2).map((item) => [item.id, item.label, item.checked])).toEqual([ + ['section:vitals', 'VITALS', true], + ['section:calltree', 'CALLTREE', false], + ]); + }); + + it('keeps the menu open through a toggle, so several can be picked', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set()); + + expect(items.slice(2).every((item) => item.keepOpen)).toBe(true); + }); + + it('will not untick the last section showing', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set(['calltree'])); + + // Hiding it would leave no header to right-click, and no way back. + expect(items[2]?.disabled).toBe(true); + // A hidden row is always pickable: that is how it comes back. + expect(items[3]?.disabled).toBe(false); + }); + + it('leaves every row pickable while more than one shows', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set()); + + expect(items.slice(2).some((item) => item.disabled)).toBe(false); + }); +}); diff --git a/log-viewer/src/components/__tests__/variableTree.test.ts b/log-viewer/src/components/__tests__/variableTree.test.ts new file mode 100644 index 000000000..307e5acc2 --- /dev/null +++ b/log-viewer/src/components/__tests__/variableTree.test.ts @@ -0,0 +1,622 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + NOT_RECORDED, + type AddressState, + type FrameVariables, + type VariableRow, +} from '../../core/log/frameVariables.js'; +import type { AggregateVariables, VariableSpread } from '../../core/log/aggregateVariables.js'; +import { parentOf, toSpreadRows, toTreeRows, type VariableTreeRow } from '../variableTree.js'; + +function row(name: string, value: string, over: Partial = {}): VariableRow { + return { + name, + value, + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + ...over, + }; +} + +const frame: FrameVariables = { + frameLabel: 'ns.Outer.run()', + cut: 100, + thisType: 'ns.Outer', + thisRow: null, + locals: [row('total', '42'), row('held', '{"a":1,"b":2}')], + fields: [row('name', '"Acme"')], + statics: [{ className: 'ns.Cache', rows: [row('hits', '3')] }], + truncated: false, +}; + +/** Opens nothing, so every group reads at its default. */ +const closed = (_id: string, byDefault: boolean): boolean => byDefault; +const openAll = (): boolean => true; + +describe('toTreeRows', () => { + it('opens Local and leaves the other groups closed', () => { + const rows = toTreeRows(frame, closed); + + expect(rows.filter((r) => r.kind === 'group').map((r) => [r.id, r.open])).toEqual([ + ['local', true], + ['this', false], + ['static', false], + ]); + // Local is open, so its variables are rows; the others contribute none. + expect(rows.filter((r) => r.kind === 'variable').map((r) => r.id)).toEqual([ + 'local/total', + 'local/held', + ]); + }); + + it('costs one row for a closed group', () => { + const rows = toTreeRows({ ...frame, locals: [] }, () => false); + + expect(rows).toHaveLength(3); + }); + + it('nests statics under their class', () => { + const rows = toTreeRows(frame, openAll); + + const ids = rows.map((r) => r.id); + expect(ids).toContain('static/ns.Cache'); + expect(ids).toContain('static/ns.Cache/hits'); + expect(rows.find((r) => r.id === 'static/ns.Cache')?.depth).toBe(1); + expect(rows.find((r) => r.id === 'static/ns.Cache/hits')?.depth).toBe(2); + }); + + it('gives an opened value one row per property', () => { + const rows = toTreeRows(frame, openAll); + + expect(rows.filter((r) => r.kind === 'entry').map((r) => r.id)).toEqual([ + 'local/held/0', + 'local/held/1', + ]); + }); + + // A chevron that opened on nothing would teach a depth the log lacks. + it('marks only a value that has something to open', () => { + const rows = toTreeRows(frame, closed); + + expect(rows.find((r) => r.id === 'local/total')?.expandable).toBe(false); + expect(rows.find((r) => r.id === 'local/held')?.expandable).toBe(true); + }); + + it('reads an address as the object it names', () => { + const rows = toTreeRows( + { ...frame, locals: [row('alias', '0xabc', { address: '0xabc' })] }, + closed, + { + resolve: (address) => + address === '0xabc' ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED, + }, + ); + + const alias = rows.find((r) => r.id === 'local/alias'); + expect(alias?.kind === 'variable' && alias.raw).toBe('{"n":1}'); + // Resolved to a container, so it opens. + expect(alias?.expandable).toBe(true); + }); + + it('says a frame assigned nothing rather than showing an empty group', () => { + const rows = toTreeRows({ ...frame, locals: [] }, closed); + + expect(rows.find((r) => r.id === 'local/none')?.kind).toBe('note'); + }); +}); + +describe('parentOf', () => { + it('names the row that holds a row', () => { + const rows = toTreeRows(frame, openAll); + const at = rows.findIndex((r) => r.id === 'static/ns.Cache/hits'); + + const owner = parentOf(rows, at); + + expect(rows[owner]?.id).toBe('static/ns.Cache'); + expect(rows[parentOf(rows, owner)]?.id).toBe('static'); + }); + + it('reports none above a group', () => { + const rows = toTreeRows(frame, closed); + + expect(parentOf(rows, 0)).toBe(-1); + }); +}); + +// `this` is the object the frame runs on, not one of its locals. +describe('the this group', () => { + const self = row('this', '{}', { objectAddress: '0xf1e2d3', declaredType: 'ns.Writer' }); + + it('heads its own group rather than sitting among the locals', () => { + const rows = toTreeRows({ ...frame, thisRow: self }, openAll); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.self?.raw).toBe('{}'); + expect(rows.some((r) => r.id === 'local/this')).toBe(false); + }); + + // A stateless class has nothing inside, so a chevron would promise a depth + // the object does not have. + it('does not open where the object has no fields', () => { + const rows = toTreeRows({ ...frame, thisRow: self, fields: [] }, openAll); + + expect(rows.find((r) => r.id === 'this')?.expandable).toBe(false); + }); + + it('opens on the fields where the object has them', () => { + const rows = toTreeRows({ ...frame, thisRow: self, fields: [row('count', '3')] }, openAll); + + expect(rows.find((r) => r.id === 'this')?.expandable).toBe(true); + expect(rows.some((r) => r.id === 'this/count')).toBe(true); + }); + + it('shows the group without a value where the log wrote only fields', () => { + const rows = toTreeRows({ ...frame, fields: [row('count', '3')] }, openAll); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.self).toBeNull(); + expect(group?.expandable).toBe(true); + }); +}); + +describe('toTreeRows references', () => { + const held = row('view', '{"m_tliFilter":"0x6c98700c","n":1}'); + const resolve = (address: string): AddressState => + address === '0x6c98700c' ? { text: '{"RowLimit":3000}', laterAt: null } : NOT_RECORDED; + + it('reads a field holding a reference as the object it names', () => { + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, { resolve }); + + const entry = rows.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.raw).toBe('{"RowLimit":3000}'); + expect(entry?.kind === 'entry' && entry.address).toBe('0x6c98700c'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(true); + // Resolved to an object, so the property opens on its own properties. + expect(entry?.expandable).toBe(true); + expect(rows.some((r) => r.id === 'local/view/0/0')).toBe(true); + }); + + it('leaves an address the log never wrote down as the address', () => { + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, { + resolve: () => NOT_RECORDED, + }); + + const entry = rows.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.raw).toBe('"0x6c98700c"'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(false); + // Still names the address, so the row can say the log had no value for it. + expect(entry?.kind === 'entry' && entry.address).toBe('0x6c98700c'); + expect(entry?.expandable).toBe(false); + }); + + // The address is only the identity the runtime printed. The contents are a + // separate event, which may land after the frame the reader picked. + it('tells an address the log never wrote down from one it wrote later', () => { + const later = toTreeRows({ ...frame, locals: [held] }, openAll, { + resolve: () => ({ text: null, laterAt: 91 }), + }); + + const entry = later.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(false); + // The eventIndex, so the row can name the frame that records it. + expect(entry?.kind === 'entry' && entry.laterAt).toBe(91); + }); + + // A resolved object may name the address it was reached through. + it('stops a reference that points back at itself', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('loop', '0xaaa', { address: '0xaaa' })], + }, + openAll, + { + resolve: (address) => + address === '0xaaa' ? { text: '{"self":"0xaaa"}', laterAt: null } : NOT_RECORDED, + }, + ); + + const inner = rows.find((r) => r.id === 'local/loop/0'); + // Read as the object, so the row never claims the log recorded nothing. + expect(inner?.kind === 'entry' && inner.resolved).toBe(true); + // But it does not open again, which is what would loop. + expect(inner?.expandable).toBe(false); + }); + + it('opens a property that is an object in its own right', () => { + const rows = toTreeRows( + { ...frame, locals: [row('outer', '{"inner":{"a":1,"b":2}}')] }, + openAll, + ); + + expect(rows.map((r) => r.id)).toContain('local/outer/0/0'); + expect(rows.find((r) => r.id === 'local/outer/0')?.expandable).toBe(true); + }); +}); + +describe('toTreeRows group labels', () => { + // Local's label says whose scope answered, which a SOQL selection needs. The + // others carried nothing the group name and count did not already say. + it('names the frame on Local and nothing on the others', () => { + const rows = toTreeRows(frame, openAll).filter((r) => r.kind === 'group'); + + expect(rows.map((r) => [r.id, r.kind === 'group' ? r.of : null])).toEqual([ + ['local', 'ns.Outer.run()'], + ['this', null], + ['static', null], + ]); + }); +}); + +// The log serialises an object as `{}` wherever it could not serialise its +// contents, but it does record the writes to that object's fields. Those are +// what the row opens on. +describe('recorded fields', () => { + const recorded: Record = { + '0xaaa': [row('rows', '5'), row('sObj', '"Account"')], + }; + const lookups = { fields: (address: string) => recorded[address] ?? [] }; + + /** What a row is called, whichever kind of row it is. */ + const nameOf = (held: VariableTreeRow): string | null => + held.kind === 'variable' ? held.row.name : held.kind === 'entry' ? held.key : null; + + it('opens a value the log wrote as {} on the fields it recorded', () => { + const rows = toTreeRows( + { ...frame, locals: [row('selector', '{}', { objectAddress: '0xaaa' })] }, + openAll, + lookups, + ); + + const held = rows.find((r) => r.id === 'local/selector'); + expect(held?.expandable).toBe(true); + expect(held?.kind === 'variable' && held.parts).toHaveLength(2); + expect(rows.map((r) => r.id)).toContain('local/selector/sObj'); + }); + + // `{}` with nothing recorded is the honest reading of a stateless object, so + // it must not offer a chevron that opens on nothing. + // `{}` and a count of eight reads as empty, so the row previews what it opens + // on. Assembled from writes of their own: the log never held it in one piece. + it('previews the parts a closed row opens on, in the order they open', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('selector', '{"sObj":"stale","extra":1}', { objectAddress: '0xaaa' })], + }, + closed, + lookups, + ); + + const held = rows.find((r) => r.id === 'local/selector'); + expect(held?.kind === 'variable' && held.assembled).toMatchObject({ + entries: [ + { key: 'rows', text: '5' }, + { key: 'sObj', text: '"Account"' }, + { key: 'extra', text: '1' }, + ], + }); + // The row's own text stays what the log wrote, for the raw value. + expect(held?.kind === 'variable' && held.raw).toBe('{"sObj":"stale","extra":1}'); + expect(held?.kind === 'variable' && held.parts).toHaveLength(3); + }); + + // One rule for every row that opens into parts, so a count means one thing + // wherever it appears. + it('counts the parts of a value the log serialised itself', () => { + const rows = toTreeRows(frame, closed, lookups); + + const held = rows.find((r) => r.id === 'local/held'); + expect(held?.kind === 'variable' && held.parts).toHaveLength(2); + // Written on its own line, so there is nothing to assemble. + expect(held?.kind === 'variable' && held.assembled).toBeNull(); + }); + + it('counts no parts for a value that opens on its text', () => { + const long = 'x'.repeat(200); + const rows = toTreeRows({ ...frame, locals: [row('big', `"${long}"`)] }, closed, lookups); + + const held = rows.find((r) => r.id === 'local/big'); + expect(held?.expandable).toBe(true); + expect(held?.kind === 'variable' && held.parts).toHaveLength(0); + }); + + it('leaves {} closed where the log recorded no fields for it', () => { + const rows = toTreeRows( + { ...frame, locals: [row('empty', '{}', { objectAddress: '0xzzz' })] }, + openAll, + lookups, + ); + + expect(rows.find((r) => r.id === 'local/empty')?.expandable).toBe(false); + }); + + // A recorded field reads as the frame stood; a serialised key reads as the + // object stood when the log wrote it. So the field wins, and the key it covers + // is left out rather than shown twice. + it('puts recorded fields first and drops a serialised key one covers', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('selector', '{"sObj":"stale","extra":1}', { objectAddress: '0xaaa' })], + }, + openAll, + lookups, + ); + + const inside = rows.filter((r) => r.id.startsWith('local/selector/') && nameOf(r) !== null); + expect(inside.map(nameOf)).toEqual(['rows', 'sObj', 'extra']); + expect(inside.find((r) => nameOf(r) === 'sObj')?.kind).toBe('variable'); + }); + + it('opens a field that is an object in its own right', () => { + const nested = { + fields: (address: string) => + address === '0xaaa' + ? [row('inner', '{}', { objectAddress: '0xbbb' })] + : address === '0xbbb' + ? [row('leaf', '"deep"')] + : [], + }; + const rows = toTreeRows( + { ...frame, locals: [row('selector', '{}', { objectAddress: '0xaaa' })] }, + openAll, + nested, + ); + + expect(rows.map((r) => r.id)).toContain('local/selector/inner/leaf'); + }); + + it('stops a field that points back at its own object', () => { + const loop = { + fields: (address: string) => + address === '0xaaa' ? [row('self', '{}', { objectAddress: '0xaaa' })] : [], + }; + const rows = toTreeRows( + { ...frame, locals: [row('holder', '{}', { objectAddress: '0xaaa' })] }, + openAll, + loop, + ); + + const inner = rows.find((r) => r.id === 'local/holder/self'); + expect(inner?.kind === 'variable' && inner.parts).toHaveLength(1); + // Named, but not opened: opening it would be the same object inside itself. + expect(inner?.expandable).toBe(false); + }); + + // The group's preview, count and rows must be one list. Its fields merge the + // index with the frame's own writes, so the preview has to read that merge and + // not the index alone. + it('previews the this group from the fields it opens on', () => { + const self = row('this', '{}', { objectAddress: '0xaaa' }); + const merged = [row('plain', '"no address"'), ...(recorded['0xaaa'] ?? [])]; + const rows = toTreeRows({ ...frame, thisRow: self, fields: merged }, closed, lookups); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.count).toBe(3); + expect(group?.kind === 'group' && group.self?.parts).toHaveLength(3); + expect( + group?.kind === 'group' && + group.self?.assembled?.kind === 'container' && + group.self.assembled.entries.map((entry) => entry.key), + ).toEqual(['plain', 'rows', 'sObj']); + }); + + // The `this` group *is* the frame's object, so a field of it that names that + // object again must not reopen the group's own contents. + it('stops a field of this that points back at this', () => { + const self = row('this', '{}', { objectAddress: '0xaaa' }); + const loop = { + fields: (address: string) => + address === '0xaaa' ? [row('me', '{}', { objectAddress: '0xaaa' })] : [], + }; + const rows = toTreeRows( + { ...frame, thisRow: self, fields: [row('me', '{}', { objectAddress: '0xaaa' })] }, + openAll, + loop, + ); + + expect(rows.find((r) => r.id === 'this/me')?.expandable).toBe(false); + }); +}); + +describe('toSpreadRows', () => { + function spread(name: string, values: [string, number][], over: Partial = {}) { + return { + name, + declaredType: null, + values: values.map(([text, calls], index) => ({ + text, + address: null, + objectAddress: null, + calls, + at: [100 + index], + runs: 1, + cut: 100 + index, + })), + calls: values.reduce((sum, [, calls]) => sum + calls, 0), + unassigned: 0, + capped: false, + ...over, + } satisfies VariableSpread; + } + + const aggregate: AggregateVariables = { + locals: [ + spread('accountId', [ + ['"001A"', 200], + ['"001B"', 140], + ]), + spread('retry', [ + ['false', 328], + ['true', 12], + ]), + spread('batchSize', [['200', 340]]), + ], + thisType: 'ns.Svc', + objects: 1, + fields: [spread('cache', [['{}', 340]])], + truncated: false, + capped: false, + }; + + const closed = (_id: string, byDefault: boolean): boolean => byDefault; + const openAll = (): boolean => true; + + it('opens Local and leaves this closed, one row per name', () => { + const rows = toSpreadRows(aggregate, closed); + + expect(rows.filter((r) => r.kind === 'group').map((r) => [r.id, r.open, r.count])).toEqual([ + ['local', true, 3], + ['this', false, 1], + ]); + // A name the calls disagreed on holds its values; one they agreed on is the value. + expect( + rows + .filter((r) => r.kind === 'spread' || r.kind === 'spread-many') + .map((r) => [r.id, r.kind]), + ).toEqual([ + ['local/accountId', 'spread-many'], + ['local/retry', 'spread-many'], + ['local/batchSize', 'spread'], + ]); + }); + + // The data layer ranks them; the rows must not re-order what it decided. + it('keeps the order the comparison put the names in', () => { + const rows = toSpreadRows(aggregate, closed); + + expect( + rows + .filter((r) => r.kind === 'spread' || r.kind === 'spread-many') + .map((r) => (r.kind === 'spread' || r.kind === 'spread-many' ? r.row.name : '')), + ).toEqual(['accountId', 'retry', 'batchSize']); + }); + + it('opens a name the calls disagreed on into its distinct values', () => { + const rows = toSpreadRows(aggregate, openAll); + + const values = rows.filter((r) => r.kind === 'spread-value' && r.id.startsWith('local/retry/')); + expect(values.map((r) => [r.id, r.kind === 'spread-value' && r.held.calls])).toEqual([ + ['local/retry/0', 328], + ['local/retry/1', 12], + ]); + // Every value is a way into a call, never a further object to open. + expect(values.every((r) => !r.expandable)).toBe(true); + }); + + // A constant reads exactly as it does for a single frame. + it('shows a name every call agreed on as its one value, with nothing to open', () => { + const rows = toSpreadRows(aggregate, openAll); + + const held = rows.find((r) => r.id === 'local/batchSize'); + expect(held?.expandable).toBe(false); + expect(held?.kind === 'spread' && held.raw).toBe('200'); + }); + + it('names the class and the objects the calls ran on', () => { + const rows = toSpreadRows({ ...aggregate, objects: 4 }, closed); + + const group = rows.find((r) => r.kind === 'group' && r.id === 'this'); + expect(group?.kind === 'group' && group.of).toBe('ns.Svc, 4 objects'); + }); + + it('names the class alone where every call ran on one object', () => { + const group = toSpreadRows(aggregate, closed).find( + (r) => r.kind === 'group' && r.id === 'this', + ); + + expect(group?.kind === 'group' && group.of).toBe('ns.Svc'); + }); + + // The plan deferred this while a value drilled to a call; the panel now stays + // on the comparison, so a value has to open where it stands. + it('opens a value the log serialised in place, with no lookups at all', () => { + const rows = toSpreadRows( + { ...aggregate, locals: [spread('held', [['{"a":1,"b":2}', 340]])] }, + openAll, + ); + + expect(rows.find((r) => r.id === 'local/held')?.expandable).toBe(true); + expect(rows.filter((r) => r.kind === 'entry').map((r) => r.id)).toEqual([ + 'local/held/0', + 'local/held/1', + ]); + }); + + it('opens one of many values into its own properties', () => { + const rows = toSpreadRows( + { + ...aggregate, + locals: [ + spread('held', [ + ['{"a":1}', 200], + ['{"b":2}', 140], + ]), + ], + }, + openAll, + ); + + expect(rows.find((r) => r.id === 'local/held/0')?.expandable).toBe(true); + expect(rows.map((r) => r.id)).toContain('local/held/0/0'); + }); + + // Each call read at its own point, so a value resolves against the first call + // that held it. + it("reads an address as the object the value's own call recorded", () => { + const held = spread('ref', [['0xabc', 340]]); + held.values[0]!.address = '0xabc'; + held.values[0]!.cut = 42; + const asked: number[] = []; + + const rows = toSpreadRows({ ...aggregate, locals: [held] }, openAll, (cut) => { + asked.push(cut); + return { + resolve: (address) => + address === '0xabc' && cut === 42 ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED, + }; + }); + + const row = rows.find((r) => r.id === 'local/ref'); + expect(row?.kind === 'spread' && row.raw).toBe('{"n":1}'); + expect(row?.expandable).toBe(true); + expect(asked).toContain(42); + }); + + // The log names the object beside a serialised value as well as in place of + // one, and the fields it recorded elsewhere are what opens it. + it("opens a serialised value into the index's fields for its object", () => { + const held = spread('opts', [['{"name":"A"}', 340]]); + held.values[0]!.objectAddress = '0xf00'; + held.values[0]!.cut = 7; + + const rows = toSpreadRows({ ...aggregate, locals: [held] }, openAll, (cut) => ({ + fields: (address) => (address === '0xf00' && cut === 7 ? [row('tries', '3')] : []), + classOf: (address) => (address === '0xf00' ? 'ns.Options' : null), + })); + + const opts = rows.find((r) => r.id === 'local/opts'); + expect(opts?.expandable).toBe(true); + expect(opts?.kind === 'spread' && opts.className).toBe('ns.Options'); + expect(rows.some((r) => r.id === 'local/opts/tries')).toBe(true); + }); + + it('says the calls hold no locals rather than showing an empty group', () => { + const rows = toSpreadRows({ ...aggregate, locals: [] }, closed); + + expect(rows.find((r) => r.id === 'local/none')?.kind).toBe('note'); + }); + + it('leaves out the this group where no call wrote a field', () => { + const rows = toSpreadRows({ ...aggregate, fields: [] }, closed); + + expect(rows.some((r) => r.id === 'this')).toBe(false); + }); +}); diff --git a/log-viewer/src/components/datagrid-facet-filter.ts b/log-viewer/src/components/datagrid-facet-filter.ts index 272e89059..1d4a37a09 100644 --- a/log-viewer/src/components/datagrid-facet-filter.ts +++ b/log-viewer/src/components/datagrid-facet-filter.ts @@ -82,7 +82,7 @@ export class DatagridFacetFilter extends LitElement { font-weight: 600; color: var(--lana-badge-fg); background-color: var(--lana-badge-bg); - border-radius: 999px; + border-radius: var(--lana-radius-pill); padding: 0 5px; font-size: var(--lana-text-xs); line-height: 1.5; diff --git a/log-viewer/src/components/datagrid-range-filter.ts b/log-viewer/src/components/datagrid-range-filter.ts index 29916d7ec..7a3f27721 100644 --- a/log-viewer/src/components/datagrid-range-filter.ts +++ b/log-viewer/src/components/datagrid-range-filter.ts @@ -123,8 +123,8 @@ export class DatagridRangeFilter extends LitElement { font-size: var(--lana-text-base); color: var(--vscode-settings-numberInputForeground); background-color: var(--vscode-settings-numberInputBackground); - border: 1px solid var(--vscode-settings-numberInputBorder, transparent); - border-radius: 4px; + border: var(--lana-stroke) solid var(--vscode-settings-numberInputBorder, transparent); + border-radius: var(--lana-radius-sm); appearance: textfield; } @@ -135,8 +135,8 @@ export class DatagridRangeFilter extends LitElement { } .range-popover__input:focus { - outline: 1px solid var(--lana-focus-border); - outline-offset: -1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .range-popover__clear { diff --git a/log-viewer/src/components/detailSections.ts b/log-viewer/src/components/detailSections.ts index 12bbddf61..908315ff8 100644 --- a/log-viewer/src/components/detailSections.ts +++ b/log-viewer/src/components/detailSections.ts @@ -21,6 +21,7 @@ import './HotPath.js'; import './HotSpots.js'; import './LogOverview.js'; import './NamespaceTimeBar.js'; +import './VariablesDetail.js'; /** * Build the inspector's sections for a selection from any tab. Every source gets @@ -44,12 +45,17 @@ import './NamespaceTimeBar.js'; * one frame, or the calls a row counts where the view's rows merge occurrences. * Details and the call tree follow it; the call stack stays anchored to * `selection`, so walking down a stack never puts a frame out of reach. + * + * `hidden` names the sections this list is set to hide, so a builder can skip + * work nobody will see. They are still returned: the header menu offers them + * back, and the panel leaves out what it does not show. */ export async function buildDetailSections( source: DetailSource, selection: DetailSelection | null, active: DetailSelection | null = null, sourceView?: SelectionView, + hidden: ReadonlySet = new Set(), ): Promise { // Nothing selected: the whole log is the scope. `DetailDock`'s own empty // state still covers the moment before a tab id resolves. @@ -137,7 +143,7 @@ export async function buildDetailSections( fit: 'content', content: html``, }, - namespaceTimeSection(html``), + namespaceTimeSection(html``, { fit: 'content' }), { id: 'governor-trends', title: 'Governor usage over time', @@ -145,8 +151,9 @@ export async function buildDetailSections( content: html``, }, { - // The same id as the selection's tree, deliberately: collapse state is - // keyed by section id, so the pane treats them as one "Call tree". + // The same id as the selection's tree: it is the one "Call tree" + // section, asked at whole-log scope. Collapse and order are remembered + // per list, so the two scopes still keep their own. id: 'calltree', title: 'Call tree', weight: 4, @@ -164,11 +171,14 @@ export async function buildDetailSections( // The Database grids resolve statement-specific vitals and SOQL lint issues. if (source === 'database' && selection.kind === 'event' && selection.type) { - return buildDatabaseSections({ - eventIndex: selection.eventIndex, - type: selection.type, - activeEventIndex: active?.kind === 'event' ? active.eventIndex : null, - }); + return buildDatabaseSections( + { + eventIndex: selection.eventIndex, + type: selection.type, + activeEventIndex: active?.kind === 'event' ? active.eventIndex : null, + }, + hidden, + ); } const isAggregate = selection.kind === 'aggregate'; @@ -189,19 +199,37 @@ export async function buildDetailSections( ? selection : null; const instances = shown?.instances ?? null; + // The frames the row is, which is the scope Variables compares: a bottom-up + // caller row counts its callee's calls, so its locals live a level up. + const scopeFrames = shown?.frames ?? null; const calledBy = shown?.calledBy ?? ''; const sections: PaneSection[] = [ { id: 'vitals', title: 'Details', - fit: 'content', + // A steady height: what this section says changes with every frame the + // reader steps to, and sizing to it would move the whole stack each time. + height: 'md', content: html``, }, + // What Apex could see from the frame. Always present, so it can say which + // log level would fill it rather than leaving the reader to guess. + { + id: 'variables', + title: 'Variables', + // No natural size — a frame has none or hundreds — so it takes a share of + // the panel and scrolls, rather than a slot that could crowd the grids. + weight: 3, + content: html``, + }, ]; if (source === 'timeline') { // The same split, asked of the selection: whose package burned the time under @@ -212,6 +240,7 @@ export async function buildDetailSections( eventIndex=${activeIndex} .instances=${instances} >`, + { height: 'sm' }, ), ); } @@ -253,8 +282,18 @@ export async function buildDetailSections( return sections; } -/** The Timeline's namespace split. One id and title for both scopes: collapse - * state is keyed by section id, so a drift would split it. */ -function namespaceTimeSection(content: TemplateResult): PaneSection { - return { id: 'namespace-time', title: 'Self time by namespace', fit: 'content', content }; +/** + * The Timeline's namespace split. One id and title for both scopes: it is the + * same section, asked of the whole log or of a selection. + * + * A bar and a legend line per namespace, so it draws little and varies by a + * line. Asked of the whole log it is worked out once, so it sizes to that; + * asked of a selection it empties to one line of prose while each frame's + * figures are added up, and a content-sized pane would flicker on every step. + */ +function namespaceTimeSection( + content: TemplateResult, + sizing: Pick, +): PaneSection { + return { id: 'namespace-time', title: 'Self time by namespace', ...sizing, content }; } diff --git a/log-viewer/src/components/governorCopy.ts b/log-viewer/src/components/governorCopy.ts new file mode 100644 index 000000000..2119d36ba --- /dev/null +++ b/log-viewer/src/components/governorCopy.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Why a governor surface shows no figures, or figures with no bar. Kept in a leaf module — as + * `detailEmptyText` is — so the gauges, the trends, the Row budget and the call tree's utilisation + * columns reach the same wording without importing each other's components. + */ + +/** Before a log reaches the store: the inspector renders while the log parses. */ +export const NO_LOG_TEXT = 'No log is loaded.'; + +/** The log holds no governor usage at all, so there is nothing to read. */ +export const NO_GOVERNOR_USAGE_TEXT = 'This log records no governor usage.'; + +/** Nothing on this surface can be measured: the log named no limit for any metric. */ +export const NO_REPORTED_LIMITS_TEXT = 'The log reports no governor limits.'; + +/** One metric cannot be measured. Per metric, so it never contradicts a metered figure beside it. */ +export const NO_LIMIT_FOR_METRIC_TEXT = 'The log reports no limit for this governor.'; diff --git a/log-viewer/src/components/governorTrendData.ts b/log-viewer/src/components/governorTrendData.ts index 5ac753f45..89c4edbe9 100644 --- a/log-viewer/src/components/governorTrendData.ts +++ b/log-viewer/src/components/governorTrendData.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { formatByteSize, formatInteger } from '../core/utility/Util.js'; +import { formatByteSize, formatInteger, sharePercent } from '../core/utility/Util.js'; import type { HeatStripTimeSeries } from '../features/timeline/types/flamechart.types.js'; import { rankedLimitMetrics } from './logOverviewMetrics.js'; @@ -14,7 +14,7 @@ const MAX_TRENDS = 4; export interface TrendPoint { /** Nanoseconds since the start of the log. */ t: number; - /** Percentage of the limit consumed at this instant. */ + /** Percentage of the series' denominator reached at this instant. */ ratio: number; /** Raw consumption at this instant, for the hover readout. */ used: number; @@ -26,8 +26,12 @@ export interface TrendSeries { points: TrendPoint[]; /** Final consumption (the peak for heap), for the value column and the tier colour. */ used: number; + /** The limit the log reported, or 0 where it reported none. */ limit: number; - /** used/limit as a percentage — the series' rank. */ + /** + * used/limit as a percentage — the series' rank. 0 with no reported limit, where the shape is a + * share of the metric's own peak and a ratio says nothing about proximity to anything. + */ finalRatio: number; format: (value: number) => string; } @@ -50,6 +54,10 @@ const seriesCache = new WeakMap(); * observation is enough to draw. A leading zero point anchors every series at * the start of the log. A metric whose final consumption is zero is left out: * a flat line at zero says nothing the gauges do not. + * + * Where the log reported no limit the metric's own peak is the denominator, so the shape still + * draws; `limit` then stays 0, which is how the chart knows to drop the 80% guide and the tier + * colour. */ export function governorTrendSeries(series: HeatStripTimeSeries): TrendSeries[] { const cached = seriesCache.get(series); @@ -58,23 +66,33 @@ export function governorTrendSeries(series: HeatStripTimeSeries): TrendSeries[] } const ranked = rankedLimitMetrics(series, MAX_TRENDS).map( - ({ key, label, used, limit, ratio }) => ({ - label, - // A zero point anchors the series at the start of the log. - points: [ - { t: 0, ratio: 0, used: 0 }, - ...series.events.flatMap((event) => { - const value = event.values.get(key); - return value - ? [{ t: event.timestamp, ratio: (value.used / value.limit) * 100, used: value.used }] - : []; - }), - ], - used, - limit, - finalRatio: ratio, - format: key === 'heapSize' ? formatByteSize : formatInteger, - }), + ({ key, label, used, limit, ratio }) => { + // `used` is already the metric's peak, so it is the denominator where no limit was reported. + const scale = limit > 0 ? limit : used; + return { + label, + // A zero point anchors the series at the start of the log. + points: [ + { t: 0, ratio: 0, used: 0 }, + ...series.events.flatMap((event) => { + const value = event.values.get(key); + return value + ? [ + { + t: event.timestamp, + ratio: sharePercent(value.used, scale), + used: value.used, + }, + ] + : []; + }), + ], + used, + limit, + finalRatio: ratio, + format: key === 'heapSize' ? formatByteSize : formatInteger, + }; + }, ); seriesCache.set(series, ranked); return ranked; diff --git a/log-viewer/src/components/inspectorLayout.ts b/log-viewer/src/components/inspectorLayout.ts new file mode 100644 index 000000000..4c36aae9d --- /dev/null +++ b/log-viewer/src/components/inspectorLayout.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { DetailSource } from '../core/events/EventBus.js'; +import type { PaneSection } from './PaneView.js'; + +/** What a section list is reading: the selection, or the whole log. */ +export type LayoutScope = 'detail' | 'summary'; + +/** + * The key the panel remembers a list's section choices under. + * + * Every choice is per tab **and** per scope, because the same id means + * different content in two lists: `calltree` is the whole log on the Timeline's + * summary and one frame's subtree in every detail list. A choice made in one + * list must never reach the other. + */ +export function layoutKey(source: DetailSource, scope: LayoutScope): string { + return `${source}:${scope}`; +} + +/** One section's entry in the flat stores, keyed `:
`. */ +export function scopedKey(key: string, id: string): string { + return `${key}:${id}`; +} + +/** + * One list's entries, by plain section id. The stores are flat, so this is what + * a list hands to ``, which knows only section ids. + */ +export function scopedRecord(store: Record, key: string): Record { + const prefix = `${key}:`; + return Object.fromEntries( + Object.entries(store) + .filter(([stored]) => stored.startsWith(prefix)) + .map(([stored, value]) => [stored.slice(prefix.length), value]), + ); +} + +/** The store with one list's entries dropped: what a reset of that list leaves. */ +export function withoutScope(store: Record, key: string): Record { + const prefix = `${key}:`; + return Object.fromEntries(Object.entries(store).filter(([stored]) => !stored.startsWith(prefix))); +} + +/** + * The sections in the order the user arranged them. An id the order does not + * name keeps its place from the builder, behind the named section it follows + * there. + * + * Not the end of the stack: the list under one key varies with the selection — + * `issues` is built for a SOQL statement and not for a DML one — so an unnamed + * id means "this list did not have it when they arranged it" as often as it + * means "added since". Ranking it last put SOQL issues below the call tree for + * anyone who had reordered while a DML row was selected. + */ +export function orderSections(sections: PaneSection[], order?: string[]): PaneSection[] { + if (!order?.length) { + return sections; + } + const named = new Set(order); + return weave( + sections, + (section) => section.id, + order, + (id) => named.has(id), + ); +} + +/** The section ids this list hides. */ +export function hiddenIds(hidden: Record, key: string): Set { + return new Set( + Object.entries(scopedRecord(hidden, key)) + .filter(([, value]) => value) + .map(([id]) => id), + ); +} + +/** + * The order to remember after a reorder: the visible ids as the user left them, + * with each hidden id kept behind the visible one it currently follows. Bringing + * a section back then returns it to its place rather than the end of the stack. + */ +export function mergeOrder( + ids: string[], + hidden: ReadonlySet, + visibleOrder: string[], +): string[] { + return weave( + ids, + (id) => id, + visibleOrder, + (id) => !hidden.has(id), + (id) => id, + ); +} + +/** + * The order to store after a reorder: `arranged` as the user left it, with an + * id the store already knew that this build did not produce kept behind the id + * it followed there. + * + * The list under one key varies with the selection - `issues` is built for a + * SOQL statement and not for a DML one - so a reorder made under one selection + * would otherwise drop what the user arranged under another, for good. + */ +export function keepUnbuilt(stored: string[], arranged: string[]): string[] { + const built = new Set(arranged); + return weave( + stored, + (id) => id, + arranged, + (id) => built.has(id), + (id) => id, + ); +} + +/** + * `items` in the sequence `order` names, each named item followed by the items + * `anchored` left off that sequence which travel behind it — the one placement + * rule {@link orderSections} and {@link mergeOrder} are inverses about. + * + * An unanchored item above every anchored one has no predecessor, so it leads. + * An id `order` names that `items` does not hold is skipped, unless `orphan` + * says what to put there: a list of ids can stand for itself, where a list of + * sections cannot conjure a pane it was never given. + */ +function weave( + items: T[], + idOf: (item: T) => string, + order: string[], + anchored: (id: string) => boolean, + orphan?: (id: string) => T, +): T[] { + const anchors = new Map(); + const trailing = new Map(); + let previous = ''; + for (const item of items) { + const id = idOf(item); + if (anchored(id)) { + anchors.set(id, item); + previous = id; + } else { + const group = trailing.get(previous); + if (group) { + group.push(item); + } else { + trailing.set(previous, [item]); + } + } + } + const woven = [...(trailing.get('') ?? [])]; + for (const id of order) { + const anchor = anchors.get(id) ?? orphan?.(id); + if (anchor !== undefined) { + woven.push(anchor); + } + woven.push(...(trailing.get(id) ?? [])); + } + return woven; +} diff --git a/log-viewer/src/components/inspectorTab.ts b/log-viewer/src/components/inspectorTab.ts new file mode 100644 index 000000000..a469ba0b2 --- /dev/null +++ b/log-viewer/src/components/inspectorTab.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { type DetailSource, eventBus } from '../core/events/EventBus.js'; +import type { InspectorEmphasis } from './inspectorEmphasis.js'; + +/** What a tab's view does when the inspector points into it. */ +export interface InspectorTabSync { + /** Light the frames the inspector names, in whatever the view's rows are. */ + mark: (eventIndexes: readonly number[]) => void; + + /** + * Move to one frame. A rejection is the view's own to report: it says the view + * cannot reach the frame, and the mark still says where the frame is. + * + * @param signal - aborted once a later move has replaced this one. A view that + * waits on anything checks it before it scrolls. + */ + reveal: (eventIndex: number, signal: AbortSignal) => void | Promise; + + /** Drop the view's own selection, for the app-wide Escape. */ + clear: () => void; + + /** + * Move for a picked row that merges occurrences. The view chooses which of them + * it moves to, because only it knows what "nearest" means in its own layout: a + * table takes the first, the flame chart the one nearest the view. Omitted where + * no move helps, which is why the Database grids only mark. + * + * @param signal - as {@link reveal}. + */ + revealMerged?: (eventIndexes: readonly number[], signal: AbortSignal) => void | Promise; +} + +/** + * The merged-pick move for a view whose rows are ordered rather than placed: it + * moves to the first occurrence, as a pick of one frame does. The flame chart + * supplies its own, because on a time axis "first" is not what the reader wants. + */ +export function revealFirstOf( + reveal: InspectorTabSync['reveal'], +): NonNullable { + return (eventIndexes, signal) => { + const first = eventIndexes[0]; + return first === undefined ? undefined : reveal(first, signal); + }; +} + +/** + * Subscribes a tab's view to the inspector, and returns the one unsubscribe. + * + * All four tabs answer the same three events for their own source, so this is + * where that set is named. A view supplies only what it does differently. + * + * The mark goes on before any move, and the move needs no answer: a row the move + * renders lights itself from the mark the table now holds. + */ +export function wireInspectorTab( + source: DetailSource, + emphasis: InspectorEmphasis, + sync: InspectorTabSync, +): () => void { + let moving: AbortController | null = null; + const move = (run: (signal: AbortSignal) => void | Promise): void => { + // A move waits on a render, and a pointer crossing rows asks for several, so + // the last one asked for is the one that scrolls. Only the move is abandoned: + // the mark of the move that is dropped went on before it. + moving?.abort(); + moving = new AbortController(); + // The view reports its own failure; the mark stands either way. + void Promise.resolve(run(moving.signal)).catch(() => {}); + }; + + const offs = [ + eventBus.onSource('inspector:reveal', source, (detail) => { + move((signal) => sync.reveal(detail.eventIndex, signal)); + }), + + eventBus.onSource('inspector:locate', source, (detail) => { + sync.mark(emphasis.report(detail.eventIndexes, detail.sticky)); + const revealMerged = sync.revealMerged; + if (revealMerged && detail.sticky && detail.eventIndexes.length) { + move((signal) => revealMerged(detail.eventIndexes, signal)); + } + }), + + // A picked inspector row is no selection of the view's own, so the mark is + // dropped here rather than by the view reporting its clear. + eventBus.onSource('selection:clear', source, () => { + sync.clear(); + sync.mark(emphasis.pick([])); + }), + ]; + + return () => { + moving?.abort(); + moving = null; + for (const off of offs) { + off(); + } + }; +} diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index 15a1bcd5e..e0e376ba8 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -6,17 +6,145 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; import type { RowComponent } from 'tabulator-tables'; import type { DetailSelection, SelectionView } from '../core/events/EventBus.js'; -import { logStoreFor, type LogStore } from '../core/log/LogStore.js'; -import { ROOT_PATH_ID, type KeyPathIds } from '../core/log/keyPathIds.js'; +import { ROOT_PATH_ID } from '../core/log/keyPathIds.js'; +import { logStoreFor } from '../core/log/LogStore.js'; import { eventByEventIndex } from '../core/utility/EventSearch.js'; -import { occurrencesThrough } from '../features/call-tree/utils/bottomUpOccurrences.js'; -/** Class the marked row carries; each table styles it itself. */ +/** Class the marked row carries. Styled once, in `tabulator/style/DataGrid.scss`, + * which spells the name out: rename it here and the mark stops painting. */ export const LOCATED_ROW_CLASS = 'located-row'; /** Attribute holding a row's index, so the mark can find its element. */ const ROW_INDEX_ATTRIBUTE = 'data-row-index'; +/** Nothing wanted, shared so a cleared table costs no allocation. */ +const NOTHING_WANTED: ReadonlySet = new Set(); + +/** + * The stamped ids each marked table wants lit. + * + * Held per host rather than on the marker so a row can light itself as it enters + * the DOM. Tabulator builds a row's element on its first render, so a sweep of + * what is rendered cannot reach a row that has never been on screen: one below + * the viewport, or a tree child built after the mark was set. + */ +const wantedByHost = new WeakMap>(); + +/** + * What each table's mark has lit. + * + * A sweep can only reach the rows a table has attached, and the renderer keeps + * the element of a row scrolled out of view without running the formatter again + * when it comes back. So an element lit while on screen has to be remembered to + * be un-lit, or the old mark returns with it. + */ +const litByHost = new WeakMap>(); + +/** Lights `element`, and remembers it as `host`'s until the mark moves. */ +function light(host: HTMLElement, element: HTMLElement): void { + element.classList.add(LOCATED_ROW_CLASS); + (litByHost.get(host) ?? litByHost.set(host, new Set()).get(host)!).add(element); +} + +/** Drops `host`'s mark from every element it lit, attached or not. */ +function unlight(host: HTMLElement): void { + const lit = litByHost.get(host); + if (!lit) { + return; + } + for (const element of lit) { + element.classList.remove(LOCATED_ROW_CLASS); + } + lit.clear(); +} + +/** + * The row-holding element each marked table is watched through. + * + * Held per host so a table rebuilt into the same container is watched again: the + * element belongs to the Tabulator instance, not to the container, and several + * views destroy and rebuild a table in place. + */ +const watchedByHost = new WeakMap(); + +/** + * Sweeps `host` again whenever rows enter its table, so a row coming back into + * view carries the mark as it stands. + * + * The renderer re-attaches a row it has already built without running the + * formatter again, so a row detached before the mark named it is out of reach of + * both halves: the sweep could not see it and the stamp will not run for it. That + * happens on a scroll and equally on a structural render, which fires no scroll + * event at all. + * + * Watched is the arrival itself, which is a child of the row-holding element and + * so cannot be missed. A signal read from the renderer's own bookkeeping can be: + * a scroll fires no event on a sort, and the virtual spacers are written with the + * value they already hold whenever the window does not move, which reports + * nothing at all. + */ +function watchRenders(host: HTMLElement): void { + const watching = watchedByHost.get(host); + const rows = host.querySelector('.tabulator-table'); + if (watching?.rows === rows) { + return; + } + watching?.observer.disconnect(); + if (!rows) { + watchedByHost.delete(host); + return; + } + const observer = new MutationObserver(() => { + const wanted = wantedByHost.get(host); + if (wanted?.size) { + // Only ever touches a class, so it cannot report itself back here. + sweep(host, wanted); + } + }); + observer.observe(rows, { childList: true }); + watchedByHost.set(host, { rows, observer }); +} + +/** Lights the rows a table has rendered that `wanted` names. */ +function sweep(host: HTMLElement, wanted: ReadonlySet): void { + for (const element of host.querySelectorAll( + `.tabulator-row[${ROW_INDEX_ATTRIBUTE}]`, + )) { + if (wanted.has(element.getAttribute(ROW_INDEX_ATTRIBUTE)!)) { + light(host, element); + } + } +} + +/** + * Stamps what identifies `row` in its table, and lights it where the mark wants + * that id. + * + * The walk up is short, a row sitting a handful of nodes below its table, and it + * is what lets the mark be a property of the table rather than of the elements + * that happened to be rendered when it was set. A row in a table nothing has + * marked is left as it is. + */ +function stamp(row: RowComponent, id: number | string): void { + const element = row.getElement(); + if (!element) { + return; + } + const stamped = String(id); + element.setAttribute(ROW_INDEX_ATTRIBUTE, stamped); + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + const wanted = wantedByHost.get(node); + if (wanted) { + if (wanted.has(stamped)) { + light(node, element); + } else { + element.classList.remove(LOCATED_ROW_CLASS); + } + return; + } + } +} + /** * Builds a Tabulator `rowFormatter` that stamps what identifies the row in its * own table: an event index where every row is one frame. @@ -28,8 +156,8 @@ const ROW_INDEX_ATTRIBUTE = 'data-row-index'; * The index is read from the data rather than `getIndex()`: Tabulator runs the * formatter for its calc rows too, and those carry no index. * - * A view whose rows merge occurrences stamps {@link rowPathStamper} instead, as - * a bucket has no event of its own. + * A view whose rows merge occurrences stamps {@link stampRowPath} instead, as a + * bucket has no event of its own. * * @param indexField - the table's `index` option */ @@ -37,7 +165,7 @@ export function rowIndexStamper(indexField: string): (row: RowComponent) => void return (row) => { const index = (row.getData() as Record)[indexField]; if (typeof index === 'number' || typeof index === 'string') { - row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(index)); + stamp(row, index); } }; } @@ -46,6 +174,8 @@ export function rowIndexStamper(indexField: string): (row: RowComponent) => void * occurrences it holds, which a bottom-up caller bucket has none of. */ interface CallRow { key?: string; + /** The interned bucket path the row's builder stamped on it. */ + _pathId?: number; text?: string; instances?: LogEvent[]; originalData?: LogEvent; @@ -53,51 +183,28 @@ interface CallRow { const rowCallData = (row: RowComponent): CallRow => row.getData() as CallRow; -/** Derived calls, held per row: a pointer sweep re-enters rows, and the click - * that follows a hover asks again. Both answers are cached, because deriving - * them reads every occurrence the root bucket holds. */ -const derivedCalls = new WeakMap(); +/** Held per row: a pointer sweep re-enters rows and the click that follows a + * hover asks again, but deriving reads every occurrence the root bucket holds. */ const derivedIndexes = new WeakMap(); -const derivedChains = new WeakMap(); const NO_CALLS: LogEvent[] = []; -/** Where a derived bottom-up row sits: the root bucket whose calls it stands - * for, the keys from that root out to the row, and the row under the root — - * the frame that made those calls. */ -interface CallChain { - root: CallRow; - keys: string[]; - caller: CallRow; -} - /** - * A derived row's place in the bottom-up tree, or null where the walk leaves the - * merged rows and the row stands for nothing. Cached: the occurrences and the - * caller are both read off it. + * The root bucket a derived row reads its calls from, or null where the walk + * leaves the merged rows and the row stands for nothing. */ -function rowCallChain(row: RowComponent, data: CallRow): CallChain | null { - const cached = derivedChains.get(data); - if (cached !== undefined) { - return cached; - } - const keys = [data.key!]; +function rootBucketOf(row: RowComponent, data: CallRow): CallRow | null { let node = data; - let caller = data; for (let parent = row.getTreeParent(); parent; parent = parent.getTreeParent()) { const parentData = rowCallData(parent); if (parentData.key === undefined) { - derivedChains.set(data, null); return null; } - caller = node; node = parentData; - keys.push(parentData.key); } - // The tree parent is the callee, so the walk runs inwards; the path runs out. - const chain: CallChain = { root: node, keys: keys.reverse(), caller }; - derivedChains.set(data, chain); - return chain; + // The tree parent is the callee, so the walk runs inwards, to the bucket that + // holds the calls. + return node; } /** @@ -112,96 +219,32 @@ export function rowId(row: RowComponent | undefined): number | undefined { return typeof id === 'number' ? id : undefined; } -const pathIds = new WeakMap(); - /** * What tells a merged row apart from a same-named row under a different parent: - * the bucket keys from its top-level ancestor out to the row itself, interned to - * one integer. A single key does not, because a bucket map is allocated per - * parent, so one method holds a row under every caller it has. + * the bucket path its builder stamped on it. A single key does not, because a + * bucket map is allocated per parent, so one method holds a row under every + * caller it has. * * Undefined on a row that stands for one frame, which its event index identifies. */ -export function rowPathId(row: RowComponent, ids: KeyPathIds): number | undefined { - const data = rowCallData(row); - if (data.key === undefined) { - return undefined; - } - const cached = pathIds.get(data); - if (cached !== undefined) { - return cached; - } - const keys = [data.key]; - for (let parent = row.getTreeParent(); parent; parent = parent.getTreeParent()) { - const parentKey = rowCallData(parent).key; - if (parentKey === undefined) { - break; - } - keys.push(parentKey); - } - const id = ids.pathOf(keys); - pathIds.set(data, id); - return id; +export function rowPathId(row: RowComponent): number | undefined { + return rowCallData(row)._pathId; } /** A `rowFormatter` for a view whose rows merge occurrences, stamping the path id * so the mark finds the row with the same one DOM query. */ -export function rowPathStamper(ids: KeyPathIds): (row: RowComponent) => void { - return (row) => { - const id = rowPathId(row, ids); - if (id !== undefined) { - row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(id)); - } - }; -} - -/** - * The path ids naming the rows a frame belongs to in a merged view. - * - * A top-down row sits at the frame's own depth, so one id names it. A bottom-up - * row is the frame plus however many of its callers the chain shows, so every - * prefix names a row the frame heads — which is why one frame marks several rows - * there, and why each prefix is interned as it is reached. - * - * The log root is not a row in either view, so the walk stops below it. - */ -export function eventPathIds(event: LogEvent, direction: SelectionView, store: LogStore): number[] { - if (!event.parent) { - return []; - } - const paths = store.keyPathIds(); - if (direction === 'callers') { - // The parent walk is already innermost first, which is the order the ids are - // composed in, so the chain needs no array of its own. - const prefixes: number[] = []; - let id = ROOT_PATH_ID; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - id = paths.step(id, store.keyIdOf(node)); - prefixes.push(id); - } - return prefixes; - } - const chain: number[] = []; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - chain.push(store.keyIdOf(node)); - } - let id = ROOT_PATH_ID; - for (let depth = chain.length - 1; depth >= 0; depth--) { - id = paths.step(id, chain[depth]!); +export function stampRowPath(row: RowComponent): void { + const id = rowPathId(row); + if (id !== undefined) { + stamp(row, id); } - return [id]; -} - -/** True where the row holds no calls of its own, so its chain answers for it. */ -function isDerived(data: CallRow): boolean { - return !data.instances?.length && data.key !== undefined; } /** * The calls a row stands for. A bottom-up caller row holds none of its own, so it * is derived from its root bucket and the chain that reaches it. */ -function rowCallOccurrences(row: RowComponent): LogEvent[] { +function rowCallOccurrences(row: RowComponent, root: ApexLog | null): LogEvent[] { const data = rowCallData(row); if (data.instances?.length) { return data.instances; @@ -209,13 +252,60 @@ function rowCallOccurrences(row: RowComponent): LogEvent[] { if (data.key === undefined) { return data.originalData ? [data.originalData] : NO_CALLS; } - const cached = derivedCalls.get(data); + return derivedRowOf(row, data, root).calls; +} + +/** What a derived row's bucket answers: the calls whose chain runs through the + * row, and the frames the row is at its own depth. */ +interface DerivedRow { + calls: LogEvent[]; + frames: number[]; +} + +const NOTHING_DERIVED: DerivedRow = { calls: NO_CALLS, frames: [] }; + +/** Held per row: a pointer sweep re-enters rows and the click that follows a + * hover asks again, but deriving reads every occurrence the root bucket holds. */ +const derivedRows = new WeakMap(); + +/** + * The root bucket's calls whose own chain runs through the row, and the frames + * the row stands for. + * + * One walk per occurrence answers both: the walk that decides whether a chain + * reaches the row stands on the row's own frame when it does. + * + * The table is the log's that built the rows: a path id is minted per log, so + * another log's table would answer about a path of its own. + */ +function derivedRowOf(row: RowComponent, data: CallRow, root: ApexLog | null): DerivedRow { + const cached = derivedRows.get(data); if (cached) { return cached; } - const chain = rowCallChain(row, data); - const derived = chain ? occurrencesThrough(chain.root.instances ?? [], chain.keys) : NO_CALLS; - derivedCalls.set(data, derived); + const pathId = data._pathId; + if (pathId === undefined || !root) { + return NOTHING_DERIVED; + } + const paths = logStoreFor(root).keyPathIds(); + const instances = rootBucketOf(row, data)?.instances; + if (!instances?.length) { + return NOTHING_DERIVED; + } + const calls: LogEvent[] = []; + const own = new Set(); + for (const event of instances) { + const node = paths.chainNodeAt(event, pathId); + if (node) { + calls.push(event); + own.add(node.eventIndex); + } + } + const derived = { calls, frames: [...own] }; + if (calls.length) { + // Not kept where nothing derived, for the reason `rowOccurrences` gives. + derivedRows.set(data, derived); + } return derived; } @@ -223,46 +313,96 @@ function rowCallOccurrences(row: RowComponent): LogEvent[] { * The path ids that the frames `eventIndexes` name stand for, so a grid whose * rows merge occurrences can mark them. * - * Every occurrence is walked: occurrences of one frame sit under distinct parent - * frames, so there is no cheaper set to walk, and only the paths they produce - * repeat. + * A bottom-up row is the frame at its own depth, so a frame stands for the rows + * its own key heads, wherever they sit; the chain above it holds its callers' + * rows. A top-down row sits on the frame's own chain, so one id names it. */ function pathIdsForEvents( root: ApexLog, eventIndexes: readonly number[], direction: SelectionView, ): number[] { - const store = logStoreFor(root); - const found = new Set(); + const paths = logStoreFor(root).keyPathIds(); + const events: LogEvent[] = []; for (const eventIndex of eventIndexes) { const event = eventByEventIndex(root, eventIndex); - if (!event) { - continue; + if (event) { + events.push(event); } - for (const id of eventPathIds(event, direction, store)) { - found.add(id); + } + if (direction === 'callers') { + return paths.pathsEndingIn(new Set(events.map((event) => paths.keyIdOf(event)))); + } + const found = new Set(); + for (const event of events) { + const pathId = paths.pathIdOf(event); + if (pathId !== undefined) { + found.add(pathId); } } return [...found]; } -/** The calls a row stands for, as the event indexes the mark works in. */ -export function rowOccurrences(row: RowComponent): number[] { +/** The calls a row stands for, as the event indexes the mark works in. + * + * @param root - the log the row was built from, which its path id belongs to */ +export function rowOccurrences(row: RowComponent, root: ApexLog | null): number[] { const data = rowCallData(row); const cached = derivedIndexes.get(data); if (cached) { return cached; } - const indexes = rowCallOccurrences(row).map((event) => event.eventIndex); - derivedIndexes.set(data, indexes); + const indexes = rowCallOccurrences(row, root).map((event) => event.eventIndex); + if (indexes.length) { + // Not kept where nothing derived: the calls are read through the log on + // screen, so an answer of none can be that log not being set yet. + derivedIndexes.set(data, indexes); + } return indexes; } +/** + * The frames a row is, which is what the inspector marks it by. + * + * A bottom-up caller row is one of the frames above a call, so it stands for the + * callers at its own depth rather than the calls they conducted. A top-down row + * sits at its own frames' depth, so there the two are the same, and so is a row + * that is one call. + * + * {@link rowOccurrences} stays the calls the row counts, which is what its + * totals describe. + * + * @param direction - the way the row's own table reads the tree + */ +export function rowFrames( + row: RowComponent, + root: ApexLog | null, + direction: SelectionView, +): number[] { + const data = rowCallData(row); + const pathId = data._pathId; + if (direction !== 'callers' || !root || pathId === undefined) { + return rowOccurrences(row, root); + } + if (logStoreFor(root).keyPathIds().parentOf(pathId) === ROOT_PATH_ID) { + // A row at the depth of its own calls stands for them. + return rowOccurrences(row, root); + } + return derivedRowOf(row, data, root).frames; +} + /** * What a selected row tells the inspector: a merged row names every call it * counts, a Time Order row the one call it is, and no row nothing. + * + * @param root - the log the row was built from, which its path id belongs to + * @param direction - the way the row's own table reads the tree */ -export function rowDetailSelection(row: RowComponent | undefined): DetailSelection | null { +export function rowDetailSelection( + row: RowComponent | undefined, + root: ApexLog | null, + direction: SelectionView, +): DetailSelection | null { if (!row) { return null; } @@ -274,13 +414,15 @@ export function rowDetailSelection(row: RowComponent | undefined): DetailSelecti if (data.key === undefined) { return { kind: 'event', eventIndex: event.eventIndex }; } - // A bucket stands for its calls even where none derive: `originalData` is the - // caller frame, which is the mis-scoping this scoping exists to avoid. - const chain = isDerived(data) ? rowCallChain(row, data) : null; + // The row itself is what reached the calls, at whatever depth it sits: a + // deeper row narrows the same calls to the ones its own chain conducted, so + // naming a fixed frame would read the same at every depth. A root bucket holds + // its own calls, so nothing reached them but it. return { kind: 'aggregate', - instances: rowOccurrences(row), - calledBy: chain?.caller.text, + instances: rowOccurrences(row, root), + frames: rowFrames(row, root, direction), + calledBy: data.instances?.length ? undefined : data.text, }; } @@ -346,44 +488,44 @@ export class LocatedRowIds { * a mark can land on several rows at once. * * The mark is not a selection: it only styles the row elements, so nothing - * scrolls, expands or re-sorts. Rows the table has not rendered have no element - * to mark, so they are left alone. + * scrolls, expands or re-sorts. It belongs to the table rather than to the rows + * rendered when it was set, so a row scrolled back into view lights itself. * * The table must stamp its rows: {@link rowIndexStamper} where a row is one - * frame, {@link rowPathStamper} where rows merge occurrences. + * frame, {@link stampRowPath} where rows merge occurrences. */ export class LocatedRowMarker { - private elements: HTMLElement[] = []; + private host: HTMLElement | null = null; /** * Move the mark to the rows `ids` name, or drop it with an empty list. Only the - * rendered rows are read, so the cost follows the viewport rather than the + * rendered rows are swept, so the cost follows the viewport rather than the * table; callers still only call this when the target changes. * * @param host - Element the table is mounted in * @param ids - What the table stamps for the rows to mark, empty to clear */ public mark(host: HTMLElement | null, ids: readonly (number | string)[]): void { - this.clear(); - if (!host || !ids.length) { + if (this.host && this.host !== host) { + // A view that switches tables would leave the one it left marked. + wantedByHost.delete(this.host); + unlight(this.host); + } + this.host = host; + if (!host) { return; } - const wanted = new Set(ids.map(String)); - for (const element of host.querySelectorAll( - `.tabulator-row[${ROW_INDEX_ATTRIBUTE}]`, - )) { - if (wanted.has(element.getAttribute(ROW_INDEX_ATTRIBUTE)!)) { - element.classList.add(LOCATED_ROW_CLASS); - this.elements.push(element); - } + unlight(host); + const wanted = ids.length ? new Set(ids.map(String)) : NOTHING_WANTED; + wantedByHost.set(host, wanted); + sweep(host, wanted); + if (wanted.size) { + watchRenders(host); } } /** Drop the mark, if one is set. */ public clear(): void { - for (const element of this.elements) { - element.classList.remove(LOCATED_ROW_CLASS); - } - this.elements = []; + this.mark(this.host, []); } } diff --git a/log-viewer/src/components/logOverviewMetrics.ts b/log-viewer/src/components/logOverviewMetrics.ts index e5c5c1126..5c1c4e7ea 100644 --- a/log-viewer/src/components/logOverviewMetrics.ts +++ b/log-viewer/src/components/logOverviewMetrics.ts @@ -3,28 +3,13 @@ */ import type { Limits } from 'apex-log-parser'; -import { formatByteSize } from '../core/utility/Util.js'; +import { formatByteSize, formatInteger, sharePercent } from '../core/utility/Util.js'; import type { GaugeMetric } from '../features/database/components/GovernorSummary.js'; import type { HeatStripTimeSeries } from '../features/timeline/types/flamechart.types.js'; /** How many gauges the strip shows before it stops being at-a-glance. */ const MAX_GAUGES = 6; -/** - * Why governor figures are missing, and the likely fix. Shared by every - * surface that needs the cumulative snapshots (`LogOverview`, - * `GovernorTrends`) so they all give the same reason. The parser samples the - * snapshots from CUMULATIVE_LIMIT_USAGE events, which the Apex Profiling - * debug category emits at INFO and above — though some INFO logs still lack - * them, so the copy hedges. - */ -export const NO_CUMULATIVE_LIMITS_TEXT = - 'This log has no CUMULATIVE_LIMIT_USAGE events, so governor totals are unknown. This can happen when the Apex Profiling debug level is below INFO.'; - -/** Short caveat under figures that were estimated without cumulative snapshots. */ -export const ESTIMATED_LIMITS_TEXT = - 'No CUMULATIVE_LIMIT_USAGE events in the log; figures are estimated from logged events. An Apex Profiling debug level of INFO or higher usually includes them.'; - /** * Every governor-tracked metric, with the label the inspector shows for it. A * local list rather than the timeline adapter's `APEX_METRICS`, which is @@ -110,24 +95,97 @@ export function limitTotals(series: HeatStripTimeSeries): Limits { } /** - * The governor metrics closest to a limit, tightest first, capped at `max`, - * from {@link limitTotals}. A metric with no consumption, or no limit, is left - * out. + * The governor metrics worth showing, capped at `max`, from {@link limitTotals}. A metric with no + * consumption is left out. + * + * Tightest first where the log reported limits. Where it reported none there is nothing to rank by + * — an absolute count cannot say which metric is nearest breaking, and ordering by size would read + * as if it could — so every ratio is 0 and the stable sort leaves {@link GOVERNOR_METRICS} reading + * order standing, which keeps every row in a predictable slot. */ export function rankedLimitMetrics(series: HeatStripTimeSeries, max: number): RankedLimitMetric[] { const totals = limitTotals(series); return GOVERNOR_METRICS.flatMap(({ key, label }) => { const { used, limit } = totals[key]; - return limit > 0 && used > 0 ? [{ key, label, used, limit, ratio: (used / limit) * 100 }] : []; + return used > 0 ? [{ key, label, used, limit, ratio: sharePercent(used, limit) }] : []; }) .sort((a, b) => b.ratio - a.ratio) .slice(0, max); } +/** Fewest points that read as a shape rather than a couple of dots. */ +const MIN_SPARK_POINTS = 5; + +/** Most points worth plotting across a gauge-width sparkline. */ +const MAX_SPARK_POINTS = 20; + +/** Memo of {@link metricSparkline}: the gauges re-render on every selection. */ +const sparkCache = new WeakMap>(); + +/** Each bucket's lowest and highest reading, in the order they occurred. */ +function bucketExtremes(levels: readonly number[]): readonly number[] { + if (levels.length <= MAX_SPARK_POINTS) { + return [...levels]; + } + const buckets = Math.floor(MAX_SPARK_POINTS / 2); + const spark: number[] = []; + for (let bucket = 0; bucket < buckets; bucket++) { + const start = Math.floor((bucket * levels.length) / buckets); + const end = Math.floor(((bucket + 1) * levels.length) / buckets); + let lowAt = start; + let highAt = start; + for (let i = start + 1; i < end; i++) { + if (levels[i]! < levels[lowAt]!) { + lowAt = i; + } else if (levels[i]! > levels[highAt]!) { + highAt = i; + } + } + // Chronological, so a rise reads as a rise: whichever extreme came first goes first. + const [first, second] = lowAt <= highAt ? [lowAt, highAt] : [highAt, lowAt]; + spark.push(levels[first]!); + if (second !== first) { + spark.push(levels[second]!); + } + } + return spark; +} + +/** + * A metric's level over the log, oldest first, for a gauge that has no limit to fill a bar + * against. Empty below {@link MIN_SPARK_POINTS} readings — too few to read as a shape. + * + * Reduced by taking each bucket's lowest and highest reading, in the order they occurred: an + * even-interval sample would step over the one allocation that spiked, and the peak is both the + * point of the shape and the figure printed beside it. + */ +export function metricSparkline(series: HeatStripTimeSeries, key: keyof Limits): readonly number[] { + let byKey = sparkCache.get(series); + if (!byKey) { + byKey = new Map(); + sparkCache.set(series, byKey); + } + const cached = byKey.get(key); + if (cached) { + return cached; + } + + const levels: number[] = []; + for (const event of series.events) { + const value = event.values.get(key); + if (value) { + levels.push(value.used); + } + } + + const spark = levels.length < MIN_SPARK_POINTS ? [] : bucketExtremes(levels); + byKey.set(key, spark); + return spark; +} + /** - * The whole-log gauges closest to a limit, capped at {@link MAX_GAUGES}. - * Without cumulative snapshots the totals are estimates, and the caller shows - * {@link ESTIMATED_LIMITS_TEXT} alongside them. + * The whole-log gauges, capped at {@link MAX_GAUGES}. A gauge with no reported limit has no bar to + * fill, so it carries a sparkline of its own level instead. */ export function seriesGauges(series: HeatStripTimeSeries): GaugeMetric[] { return rankedLimitMetrics(series, MAX_GAUGES).map(({ key, label, used, limit }) => ({ @@ -135,6 +193,7 @@ export function seriesGauges(series: HeatStripTimeSeries): GaugeMetric[] { found: used, used, limit, - ...(key === 'heapSize' ? { format: formatByteSize } : {}), + spark: limit > 0 ? undefined : metricSparkline(series, key), + format: key === 'heapSize' ? formatByteSize : formatInteger, })); } diff --git a/log-viewer/src/components/scopedCallTree.ts b/log-viewer/src/components/scopedCallTree.ts index dafa85a7a..ad4341ca0 100644 --- a/log-viewer/src/components/scopedCallTree.ts +++ b/log-viewer/src/components/scopedCallTree.ts @@ -45,6 +45,8 @@ export interface ScopedRow { * calls its chain conducted, so it derives them from the top-level row rather * than holding a copy of the list. */ _seed?: OccurrenceSeed; + /** The frames the row itself stands for, once derived. */ + _frameIndexes?: number[]; _children: ScopedRow[] | null; } @@ -106,6 +108,35 @@ export function locatableEventIndexes(row: Partial | undefined): numb return single === null ? [] : [single]; } +/** + * The frames a scoped row stands for, which is what a highlight elsewhere points + * at: the flame chart dims to them, and the call tree selects one. + * + * A bottom-up caller row is one of the frames above a call, so it stands for the + * callers at its own depth rather than the calls they conducted, and stepping + * down the callers walks the highlight up the stack. + * + * {@link locatableEventIndexes} stays the calls the row counts, which is what its + * totals describe. + */ +export function frameEventIndexes(row: Partial | undefined): number[] { + if (row?._frameIndexes) { + return row._frameIndexes; + } + const conducted = locatableEventIndexes(row); + const seed = row?._seed; + const store = currentLogStore(); + if (!seed || !store) { + return conducted; + } + // `_seed` is only ever set alongside `_pathId`. + const levels = seed.paths.depthOf(row._pathId!) - 1; + if (levels <= 0) { + return conducted; + } + return (row._frameIndexes = store.framesAbove(conducted, levels)); +} + /** * The rows of one view keyed by the bucket path each stands for, so a frame * named elsewhere can be found behind the synthetic id of a row that merges @@ -522,20 +553,19 @@ async function aggregate( // The recursion follows the call depth, which is shallow; each level's input // is the wide dimension, so that is where the slicing goes. async function merge(input: ScopedRow[], parentPathId: number): Promise { - const groups = new Map(); - const order: number[] = []; - // The occurrences behind each group — a set, so one frame cannot be listed - // twice. - const indexes = new Map>(); + // The occurrences behind each group are a set, so one frame cannot be listed + // twice; `groups` keeps its own insertion order, so it is also the output + // order. + const groups = new Map }>(); for (let i = 0; i < input.length; i++) { if (i % CHECK_EVERY === 0 && !(await tick())) { return null; } const row = input[i]!; - const key = store.keyIdOf(row.originalData); - let group = groups.get(key); - if (!group) { - group = { + const key = paths.keyIdOf(row.originalData); + let held = groups.get(key); + if (!held) { + const group: ScopedRow = { id: nextId(), originalData: row.originalData, text: row.text, @@ -547,10 +577,10 @@ async function aggregate( _pathId: paths.step(parentPathId, key), _children: [], }; - groups.set(key, group); - order.push(key); - indexes.set(key, new Set()); + held = { row: group, seen: new Set() }; + groups.set(key, held); } + const group = held.row; if (!row.onPath) { // A group holding one real call is not a route, so it stays closed. group.onPath = undefined; @@ -559,9 +589,8 @@ async function aggregate( group.duration.self += row.duration.self; group.callCount += row.callCount; // Every merged occurrence, so pointing at the group points at all of them. - const seen = indexes.get(key)!; for (const index of locatableEventIndexes(row)) { - seen.add(index); + held.seen.add(index); } if (row._children) { const kids = group._children as ScopedRow[]; @@ -572,9 +601,8 @@ async function aggregate( } const merged: ScopedRow[] = []; - for (const key of order) { - const group = groups.get(key)!; - group.eventIndexes = [...indexes.get(key)!]; + for (const { row: group, seen } of groups.values()) { + group.eventIndexes = [...seen]; const kids = group._children as ScopedRow[]; if (kids.length) { const mergedKids = await merge(kids, group._pathId!); @@ -687,8 +715,8 @@ async function buildBottomUp( const entryFor = (row: ScopedRow, callers: WalkEntry | null): WalkEntry => ({ row, callers, - keyId: store.keyIdOf(row.originalData), - stackId: store.stackIdOf(row.originalData), + keyId: paths.keyIdOf(row.originalData), + stackId: paths.stackIdOf(row.originalData), leaving: false, attributed: 0, outer: null, @@ -749,16 +777,16 @@ async function buildBottomUp( } // Each occurrence is tagged with the chain that reached it, which is how a // caller row picks out the ones it stands for. - const store = seed._seed; + const occurrences = seed._seed; const held = row.eventIndexes; if (held) { for (let i = 0; i < held.length; i++) { - store.eventIndexes.push(held[i]!); - store.chains.push(pathId); + occurrences.eventIndexes.push(held[i]!); + occurrences.chains.push(pathId); } } else { - store.eventIndexes.push(row.originalData.eventIndex); - store.chains.push(pathId); + occurrences.eventIndexes.push(row.originalData.eventIndex); + occurrences.chains.push(pathId); } } } diff --git a/log-viewer/src/components/sectionMenu.ts b/log-viewer/src/components/sectionMenu.ts new file mode 100644 index 000000000..861d53d38 --- /dev/null +++ b/log-viewer/src/components/sectionMenu.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ContextMenuItem } from './ContextMenu.js'; +import type { PaneSection } from './PaneView.js'; + +/** The reset row. Every other row names a section, behind {@link SECTION_PREFIX}. */ +export const RESET_SECTIONS_ID = 'reset-sections'; +const SECTION_PREFIX = 'section:'; + +/** The section a menu row names, or null for any other row. */ +export function sectionIdFor(itemId: string): string | null { + return itemId.startsWith(SECTION_PREFIX) ? itemId.slice(SECTION_PREFIX.length) : null; +} + +/** + * The section header's menu: reset this list, then a row per section, ticked + * while it shows. + * + * The last section still showing cannot be unticked. Hiding it would leave no + * header to right-click, and so no way back to this menu. + */ +export function buildSectionMenuItems( + sections: PaneSection[], + hidden: ReadonlySet, +): ContextMenuItem[] { + const showing = sections.filter((section) => !hidden.has(section.id)).length; + return [ + { id: RESET_SECTIONS_ID, label: 'Reset Sections' }, + { id: 'section-sep', label: '', separator: true }, + ...sections.map((section) => { + const shows = !hidden.has(section.id); + return { + id: `${SECTION_PREFIX}${section.id}`, + label: section.title, + checked: shows, + keepOpen: true, + disabled: showing === 1 && shows, + }; + }), + ]; +} diff --git a/log-viewer/src/components/variableTree.ts b/log-viewer/src/components/variableTree.ts new file mode 100644 index 000000000..ced5d1990 --- /dev/null +++ b/log-viewer/src/components/variableTree.ts @@ -0,0 +1,570 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { + AggregateVariables, + SpreadValue, + VariableSpread, +} from '../core/log/aggregateVariables.js'; +import { + NOT_RECORDED, + type FrameVariables, + type IndexView, + type VariableRow, +} from '../core/log/frameVariables.js'; +import { + assembledContainer, + clampRaw, + isExpandable, + parseVariableValue, + type ValueEntry, + type VariableValue, +} from '../core/log/variableValue.js'; + +/** + * The Variables section as one flat list of rows. + * + * Flat because it is a keyboard tree: every row is reachable with the arrow + * keys, so the row after the one in hand has to be a lookup rather than a walk + * of nested markup. Only an open row contributes its children, so a closed + * group costs one row. + * + * Structure is how a value is formatted: an object opens into a row per + * property, and a property that is itself an object opens again. The log's own + * text is never reformatted, only laid out. + */ + +/** + * How deep a value may open. + * + * The log serialises one level itself, so depth beyond that comes from resolving + * an address, and each resolved object may name more. The cycle guard stops a + * loop; this stops a long chain. + */ +const MAX_DEPTH = 8; + +export interface Common { + /** Stable across selections, so disclosure and focus survive a re-render. */ + id: string; + depth: number; + expandable: boolean; + open: boolean; +} + +/** A value as shown, which is not always the text the log wrote on that line. */ +export interface Shown { + value: VariableValue; + /** The text behind {@link value}, for the raw text row. */ + raw: string; + /** The address this row's own text named, where its text was only an address. */ + address: string | null; + /** True where {@link raw} came from that address rather than from this row. */ + resolved: boolean; + /** Where the log first describes the object, where that is after this frame. */ + laterAt: number | null; + /** The class of the object shown, where the log names it. More telling than + * the declared type, which is often only an interface. */ + className: string | null; + /** The object this row shows, however its line named it: its own address, or + * the address the line reported for it. What its fields are indexed by. */ + objectAddress: string | null; + /** What the row opens into, in that order. One list, so the preview, the + * count and the rows below cannot disagree. */ + parts: readonly Part[]; + /** What a closed row previews, where the object's parts reached the log as + * writes of their own rather than on its own line. Null where its own text is + * all there is. */ + assembled: VariableValue | null; +} + +/** One part of a value: a field the log recorded for the object, or a key the + * value's own text held. `at` is its place in that text, for a stable id. */ +export type Part = { field: VariableRow } | { entry: ValueEntry; at: number }; + +/** What the tree asks the log about an address. Every lookup is optional: a + * caller with no index still gets its rows, with nothing resolved. */ +export type Lookups = Partial; + +/** A row that shows a value of its own, as well as holding others. */ +export type GroupSelf = Shown & { declaredType: string | null }; + +/** What a group is, before the disclosure state it is pushed with. */ +interface GroupHead { + id: string; + name: string; + count: number; + /** Whose frame, or whose class: metadata beside the name. */ + of?: string | null; + openByDefault?: boolean; + /** False where opening it would promise a depth the log has not got. */ + expandable?: boolean; + /** The group's own value, where the group *is* an object. */ + self?: GroupSelf | null; +} + +/** What a row is, before the place and disclosure state it is pushed with. */ +export type RowBody = + | { kind: 'group'; name: string; of: string | null; count: number; self: GroupSelf | null } + | { kind: 'class'; className: string; count: number } + | ({ kind: 'variable'; row: VariableRow } & Shown) + | ({ kind: 'entry'; key: string | null } & Shown) + | ({ kind: 'spread'; row: VariableSpread } & Shown) + | { kind: 'spread-many'; row: VariableSpread } + | ({ kind: 'spread-value'; held: SpreadValue; of: number } & Shown) + | { kind: 'text'; raw: string } + | { kind: 'note'; text: string }; + +export type VariableTreeRow = Common & RowBody; + +/** The value a row shows: an address resolves to the object it names. + * + * `fields` overrides what the log holds for the object, for a caller that has + * already gathered them: the `this` group's fields merge the index with the + * frame's own writes, and its preview must be the list it opens on. */ +function shownValue(row: VariableRow, lookups: Lookups, fields?: readonly VariableRow[]): Shown { + // The value's own address where it has one, else the address the line + // reported for it. A field write reports its owner and so carries neither. + return shown(row.value, row.address, row.address ?? row.objectAddress, lookups, fields); +} + +/** + * One value, as shown: the object an address names where the log holds it, else + * the row's own text. + * + * `objectAddress` differs from `address` only for a value the log serialised in + * place: it has no address to resolve, but the line still named the object, and + * that is what its class and its fields are indexed by. + */ +function shown( + text: string, + address: string | null, + objectAddress: string | null, + lookups: Lookups, + given?: readonly VariableRow[], +): Shown { + const state = (address ? lookups.resolve?.(address) : null) ?? NOT_RECORDED; + const raw = state.text ?? text; + const value = parseVariableValue(raw); + const fields = given ?? (objectAddress ? (lookups.fields?.(objectAddress) ?? []) : []); + const parts = partsOf(fields, value); + return { + value, + raw, + address, + resolved: state.text !== null, + laterAt: state.laterAt, + className: (objectAddress && lookups.classOf?.(objectAddress)) || null, + objectAddress, + parts, + // Only where a part came from a write of its own: a value the log + // serialised in place previews as the log wrote it. + assembled: fields.length ? assembledOf(parts, value) : null, + }; +} + +/** + * What a value opens into, in that order: the fields the log recorded for the + * object, then the keys the value's own text held that no field covers. + * + * A recorded field wins because it is its own write, at or before this frame, + * where a serialised key is only as the object stood when that line was written. + * One list, read by the preview, the count and the rows alike. + */ +function partsOf(fields: readonly VariableRow[], value: VariableValue): Part[] { + const named = new Set(fields.map((field) => field.name)); + const parts: Part[] = fields.map((field) => ({ field })); + const held = value.kind === 'container' ? value.entries : []; + held.forEach((entry, at) => { + if (entry.key === null || !named.has(entry.key)) { + parts.push({ entry, at }); + } + }); + return parts; +} + +/** {@link partsOf} as one value, for the row that holds them closed: the log + * wrote `{}` for an object it could not serialise, and a row showing only that + * reads as empty while holding eight fields. */ +function assembledOf(parts: readonly Part[], value: VariableValue): VariableValue { + return assembledContainer( + parts.map((part) => + 'field' in part + ? { key: part.field.name, text: part.field.value } + : { key: part.entry.key, text: part.entry.text }, + ), + // The object's own line serialised nothing, so only a surviving serialised + // part can be short of what the log held. + value.kind === 'container' && value.truncated && parts.some((part) => 'entry' in part), + ); +} + +/** A row that shows a value, before the disclosure state it is pushed with. */ +type ValueRow = + | { kind: 'variable'; row: VariableRow } + | { kind: 'entry'; key: string | null } + | { kind: 'spread'; row: VariableSpread } + | { kind: 'spread-value'; held: SpreadValue; of: number }; + +/** + * The half both builders share: what a value contributes once it is open. + * + * One emitter, so a value opens the same way at either scope. A spread's value + * is an object as much as a frame's local is, and the reader who can open one + * expects to open the other. + * + * `lookups` rides on each call rather than being captured: a frame reads every + * value at one point in the log, where a comparison reads each value at the + * point the first call to hold it stood. + */ +function rowEmitter(isOpen: (id: string, openByDefault: boolean) => boolean) { + const rows: VariableTreeRow[] = []; + + const note = (id: string, depth: number, text: string): void => { + rows.push({ kind: 'note', id, depth, expandable: false, open: false, text }); + }; + + /** + * A row that holds others, opened where it may be and the reader wants it. + * The one place disclosure is decided, so every kind of row obeys one rule. + */ + function node( + of: RowBody, + id: string, + depth: number, + expandable: boolean, + kids?: (depth: number) => void, + openByDefault = false, + ): void { + const open = expandable && isOpen(id, openByDefault); + rows.push({ ...of, id, depth, expandable, open }); + if (open) { + kids?.(depth + 1); + } + } + + const group = (head: GroupHead, kids: (depth: number) => void): void => { + const { + id, + name, + count, + expandable = true, + openByDefault = false, + of = null, + self = null, + } = head; + node({ kind: 'group', name, count, of, self }, id, 0, expandable, kids, openByDefault); + }; + + /** + * One rule for what may open: the value holds parts, or its text is too long + * to read in a row. An object already open above this row would be a cycle, + * and `MAX_DEPTH` stops a long chain. + */ + function opens(depth: number, held: Shown, seen: ReadonlySet): boolean { + const cycle = held.objectAddress !== null && seen.has(held.objectAddress); + return !cycle && depth < MAX_DEPTH && (held.parts.length > 0 || isExpandable(held.value)); + } + + /** A row that shows a value, and everything inside it while it is open. */ + function value( + of: ValueRow, + id: string, + depth: number, + held: Shown, + seen: ReadonlySet, + lookups: Lookups, + ): void { + node({ ...of, ...held }, id, depth, opens(depth, held, seen), (inside) => + children(id, inside, held, withAddress(seen, held.objectAddress), lookups), + ); + } + + /** The rows an open value contributes: the parts it holds, or its raw text. */ + function children( + parentId: string, + depth: number, + holder: Shown, + seen: ReadonlySet, + lookups: Lookups, + ): void { + const { value: held, raw, parts } = holder; + if (parts.length) { + let repeats = 0; + const keys = new Set(); + for (const part of parts) { + if ('field' in part) { + variable(parentId, depth, part.field, seen, lookups); + continue; + } + const { entry, at } = part; + if (entry.key !== null) { + if (keys.has(entry.key)) { + repeats++; + } else { + keys.add(entry.key); + } + } + const id = `${parentId}/${at}`; + value( + { kind: 'entry', key: entry.key }, + id, + depth, + shown(entry.text, entry.address, entry.address, lookups), + seen, + lookups, + ); + } + if (repeats) { + note( + `${parentId}/repeats`, + depth, + `${repeats} keys repeat, kept in the order the log wrote them.`, + ); + } + if (held.kind === 'container' && held.truncated) { + note(`${parentId}/cut`, depth, 'The log cut this collection short.'); + } + return; + } + const { text, clamped } = clampRaw(raw); + rows.push({ + kind: 'text', + id: `${parentId}/raw`, + depth, + expandable: false, + open: false, + raw: text, + }); + if (clamped) { + note(`${parentId}/clamped`, depth, `Shown to the first ${text.length} characters.`); + } + } + + function variable( + parentId: string, + depth: number, + row: VariableRow, + seen: ReadonlySet, + lookups: Lookups, + ): void { + value( + { kind: 'variable', row }, + `${parentId}/${row.name}`, + depth, + shownValue(row, lookups), + seen, + lookups, + ); + } + + function variables( + parentId: string, + depth: number, + of: readonly VariableRow[], + seen: ReadonlySet, + lookups: Lookups, + ): void { + for (const row of of) { + variable(parentId, depth, row, seen, lookups); + } + } + + return { rows, note, group, node, value, variables }; +} + +/** + * Every row the section shows, in order, given which ids are open. + * + * `isOpen` decides a group's default too, so the caller owns the policy: Local + * opens, the rest do not. + */ +export function toTreeRows( + frame: FrameVariables, + isOpen: (id: string, openByDefault: boolean) => boolean, + lookups: Lookups = {}, +): VariableTreeRow[] { + const { rows, note, group, node, variables } = rowEmitter(isOpen); + + group( + { + id: 'local', + name: 'Local', + of: frame.frameLabel, + count: frame.locals.length, + openByDefault: true, + }, + (depth) => { + if (frame.locals.length) { + variables('local', depth, frame.locals, NOTHING_OPEN, lookups); + } else { + note('local/none', depth, 'The log records no locals for this frame.'); + } + }, + ); + + // `this` is the object the frame runs on, so the group *is* that object: its + // own value when closed, its fields when open. A class with no fields has + // nothing to open, which is the honest reading of a stateless class. + if (frame.thisRow || frame.fields.length) { + const self = frame.thisRow ? shownValue(frame.thisRow, lookups, frame.fields) : null; + group( + { + id: 'this', + name: 'this', + count: frame.fields.length, + expandable: frame.fields.length > 0, + self: self && { ...self, declaredType: frame.thisRow?.declaredType ?? frame.thisType }, + }, + // The frame's own object, so a field pointing back at it cannot reopen it. + (depth) => + variables( + 'this', + depth, + frame.fields, + withAddress(NOTHING_OPEN, self?.objectAddress ?? null), + lookups, + ), + ); + } + + if (frame.statics.length) { + const total = frame.statics.reduce((sum, entry) => sum + entry.rows.length, 0); + // Statics nest one level by class: every static the log names is + // class-qualified, and a log holds thousands of them. + group({ id: 'static', name: 'Static', count: total }, (depth) => { + for (const entry of frame.statics) { + node( + { kind: 'class', className: entry.className, count: entry.rows.length }, + `static/${entry.className}`, + depth, + true, + (inside) => + variables(`static/${entry.className}`, inside, entry.rows, NOTHING_OPEN, lookups), + ); + } + }); + } + + return rows; +} + +/** + * Every row a merged row's comparison shows, in order, given which ids are open. + * + * The same row model and the same emitter as {@link toTreeRows}, so one + * renderer and one keyboard tree serve both scopes, and a value that is an + * object opens into its fields here as it does for one frame. + * + * @param lookupsAt - the log bound to the point a value's first call read at. + * Each call read at its own point, so an object is shown as that one call + * recorded it; the section says so. + */ +export function toSpreadRows( + aggregate: AggregateVariables, + isOpen: (id: string, openByDefault: boolean) => boolean, + lookupsAt: (cut: number) => Lookups = () => ({}), +): VariableTreeRow[] { + const { rows, note, group, node, value } = rowEmitter(isOpen); + + function spread(parentId: string, depth: number, row: VariableSpread): void { + const id = `${parentId}/${row.name}`; + // One value every call held, so the row *is* that value: it reads and opens + // exactly as a single frame's row does, through the same emitter. + const only = row.values.length === 1 ? row.values[0] : null; + if (only) { + value( + { kind: 'spread', row }, + id, + depth, + shownOf(only, lookupsAt), + NOTHING_OPEN, + lookupsAt(only.cut), + ); + return; + } + // The calls disagreed, so the row holds their values rather than being one. + node({ kind: 'spread-many', row }, id, depth, row.values.length > 0, (inside) => { + row.values.forEach((entry, at) => { + value( + { kind: 'spread-value', held: entry, of: row.calls }, + `${id}/${at}`, + inside, + shownOf(entry, lookupsAt), + NOTHING_OPEN, + lookupsAt(entry.cut), + ); + }); + }); + } + + group( + { id: 'local', name: 'Local', count: aggregate.locals.length, openByDefault: true }, + (depth) => { + if (!aggregate.locals.length) { + note('local/none', depth, 'The log records no locals for these calls.'); + return; + } + for (const row of aggregate.locals) { + spread('local', depth, row); + } + }, + ); + + if (aggregate.fields.length) { + group( + { + id: 'this', + name: 'this', + // The calls need not have run on one object, and which they ran on is + // part of the reading, so the head says it rather than implying one. + of: objectsLabel(aggregate), + count: aggregate.fields.length, + }, + (depth) => { + for (const row of aggregate.fields) { + spread('this', depth, row); + } + }, + ); + } + + return rows; +} + +/** One compared value as shown, read at the point its first call stood. + * + * Only a value the log wrote as an address asks the log anything, so a scalar + * never builds a view it would not read. */ +function shownOf(value: SpreadValue, lookupsAt: (cut: number) => Lookups): Shown { + // The object the value is, which is what opens it - the log names it beside a + // serialised value as well as in place of one. + const object = value.address ?? value.objectAddress; + const lookups = object ? lookupsAt(value.cut) : {}; + return shown(value.text, value.address, object, lookups); +} + +/** Whose class the fields belong to, and how many objects held them. */ +function objectsLabel(aggregate: AggregateVariables): string | null { + const objects = aggregate.objects > 1 ? `${aggregate.objects} objects` : null; + return [aggregate.thisType, objects].filter(Boolean).join(', ') || null; +} + +/** Nothing open above a row, shared so a rebuild allocates none. `withAddress` + * never mutates what it is given. */ +const NOTHING_OPEN: ReadonlySet = new Set(); + +/** The addresses open above a row, so the same object cannot open inside itself. */ +function withAddress(seen: ReadonlySet, address: string | null): ReadonlySet { + return address ? new Set([...seen, address]) : seen; +} + +/** The row that holds `id`'s children, for the key that moves out of one. */ +export function parentOf(rows: readonly VariableTreeRow[], at: number): number { + const depth = rows[at]?.depth ?? 0; + for (let above = at - 1; above >= 0; above--) { + if ((rows[above]?.depth ?? 0) < depth) { + return above; + } + } + return -1; +} diff --git a/log-viewer/src/core/events/DomListenerController.ts b/log-viewer/src/core/events/DomListenerController.ts new file mode 100644 index 000000000..3e1b45d87 --- /dev/null +++ b/log-viewer/src/core/events/DomListenerController.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** A handler map: the event name to call for, and what to call. */ +export type DomListeners = { + [K in keyof M]?: (event: M[K]) => void; +}; + +/** + * Listens on a target outside the host's own tree — `document` or `window` — + * for exactly as long as the host is connected. + * + * Wiring a global listener in a constructor and removing it in + * `disconnectedCallback` leaves the host deaf after a re-attach, because the + * constructor never runs again. The host lifecycle owns both ends here, so a + * detach and re-attach re-subscribes. + * + * Pass each handler as an inline arrow, never a reference to a class field: + * this package compiles with `useDefineForClassFields: false`, so field + * initialisers run in declaration order and a field declared below this one is + * still `undefined` when the controller is built. `addEventListener(name, + * undefined)` then fails silently. + */ +export class DomListenerController< + M extends { [K in keyof M]: Event }, +> implements ReactiveController { + private readonly _target: EventTarget; + private readonly _entries: [string, EventListener][]; + + constructor(host: ReactiveControllerHost, target: EventTarget, listeners: DomListeners) { + this._target = target; + this._entries = Object.entries(listeners) as [string, EventListener][]; + for (const [name, handler] of this._entries) { + if (typeof handler !== 'function') { + // addEventListener(name, undefined) is a legal no-op, so without this the + // host just goes quiet. See the field-order note above. + throw new TypeError(`DomListenerController: the handler for "${name}" is not a function.`); + } + } + host.addController(this); + } + + hostConnected(): void { + for (const [name, handler] of this._entries) { + this._target.addEventListener(name, handler); + } + } + + hostDisconnected(): void { + for (const [name, handler] of this._entries) { + this._target.removeEventListener(name, handler); + } + } +} diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 8c7eb8665..381b40e69 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -32,6 +32,10 @@ export type DetailSelection = | { kind: 'aggregate'; instances: number[]; + /** The frames the row *is*, which is the scope it holds: a bottom-up + * caller row counts its callee's calls, so its own scope lives a level + * up. Equal to {@link instances} where the row sits at its calls' depth. */ + frames: number[]; /** The frame that made the calls, where the row naming them is not it: a * bottom-up caller row counts its callee's calls. Absent where the row * names the calls it counts. */ @@ -86,12 +90,15 @@ interface EventMap { // `detail:select` is strictly inbound to it; separate events stop an echo loop. 'inspector:reveal': { source: DetailSource; eventIndex: number }; - // A row in the inspector points at events — mark them in the tab the inspector - // is showing, so the user can see where they sit without the view moving: - // no scroll, no pan, and no selection beyond `inspector:reveal`'s. A grouped - // row names every occurrence it merges, and an empty list drops the mark. + // A row in the inspector points at events: mark them in the tab the inspector + // is showing. The list is the frames the row stands for, so a bottom-up caller + // row names the callers at its own depth rather than the calls they conducted, + // and an empty list drops the mark. // `sticky` is true when the row was picked, so the mark holds while the pointer - // is elsewhere, and false for the pointer itself. + // is elsewhere, and false for the pointer itself. A hover moves nothing at all. + // A pick also reveals its first frame in the views that have a row for one, so + // the Call Tree and Analysis grids scroll and select, while the Database grids + // and the flame chart only mark. 'inspector:locate': { source: DetailSource; eventIndexes: readonly number[]; @@ -99,13 +106,29 @@ interface EventMap { }; // The other direction: a frame in the tab's own view is under the pointer, so - // the inspector marks the rows that stand for it — only where a row is already - // on screen. Nothing moves: no selection change, no scroll, no expand. A row - // that merges occurrences names them all, and the list is empty when the - // pointer leaves the frame. + // the inspector marks the rows that stand for it, only where a row is already + // on screen. Nothing moves: no selection change, no scroll, no expand. The + // list is the frames the row stands for, as `inspector:locate` is, so a + // bottom-up caller row names the callers at its own depth rather than the + // calls they conducted. It is empty when the pointer leaves the frame. 'detail:locate': { source: DetailSource; eventIndexes: readonly number[] }; } +/** One event's payload, for code that answers an event it is handed rather than + * one it names itself. The map stays where each payload is described. */ +export type EventDetail = EventMap[K]; + +/** + * The events that name the tab they are for. + * + * Naming a tab is not the same as being for one tab only: the inspector records + * every tab's `detail:select` and `detail:view`, so filtering those by source + * would lose the tab it is not showing. `onSource` is for a tab's own view. + */ +type SourcedEvent = { + [K in keyof EventMap]: EventMap[K] extends { source: DetailSource } ? K : never; +}[keyof EventMap]; + type EventCallback = (detail: EventMap[K]) => void; class EventBusImpl { @@ -123,6 +146,23 @@ class EventBusImpl { }; } + /** + * Subscribes to an event only where it names `source`: the whole contract of a + * view that answers for one tab. Every such event reaches every view, so the + * filter belongs to the bus rather than to each view. + */ + onSource( + event: K, + source: DetailSource, + callback: EventCallback, + ): () => void { + return this.on(event, (detail) => { + if (detail.source === source) { + callback(detail); + } + }); + } + emit(event: K, detail: EventMap[K]): void { this.listeners.get(event)?.forEach((callback) => callback(detail)); } diff --git a/log-viewer/src/core/events/__tests__/DomListenerController.test.ts b/log-viewer/src/core/events/__tests__/DomListenerController.test.ts new file mode 100644 index 000000000..e8f685fb8 --- /dev/null +++ b/log-viewer/src/core/events/__tests__/DomListenerController.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment jsdom + */ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { LitElement, html, type ReactiveController } from 'lit'; + +import { DomListenerController, type DomListeners } from '../DomListenerController.js'; +import type { FindEventDetail, FindEventMap } from '../../../features/find/findEvents.js'; + +/** A host that drives the controller hooks without needing a real element. */ +function fakeHost() { + const controllers = new Set(); + return { + addController: (c: ReactiveController) => void controllers.add(c), + removeController: (c: ReactiveController) => void controllers.delete(c), + requestUpdate: () => {}, + updateComplete: Promise.resolve(true), + connect: () => controllers.forEach((c) => c.hostConnected?.()), + disconnect: () => controllers.forEach((c) => c.hostDisconnected?.()), + }; +} + +const DETAIL: FindEventDetail = { text: 'abc', count: 2, options: { matchCase: true } }; + +const EVERY_NAME: (keyof FindEventMap)[] = [ + 'lv-find', + 'lv-find-match', + 'lv-find-close', + 'lv-find-results', + 'db-find-results', +]; + +function find(name: keyof FindEventMap, detail: unknown = DETAIL): void { + document.dispatchEvent(new CustomEvent(name, { detail })); +} + +/** Records every event the controller hands back, for one set of names. */ +function listen(names: readonly (keyof FindEventMap)[]) { + const host = fakeHost(); + const seen: CustomEvent[] = []; + const listeners: DomListeners = {}; + for (const name of names) { + listeners[name] = (e) => void seen.push(e); + } + new DomListenerController(host, document, listeners); + return { host, seen }; +} + +/** A real Lit host, so the re-attach case goes through Lit's own lifecycle. */ +const litCalls: CustomEvent[] = []; + +class FindBusTestHost extends LitElement { + readonly bus = new DomListenerController(this, document, { + 'lv-find': (e) => void litCalls.push(e), + }); + + override render() { + return html``; + } +} +customElements.define('find-bus-test-host', FindBusTestHost); + +describe('DomListenerController', () => { + beforeEach(() => { + litCalls.length = 0; + }); + + it('stays deaf until the host connects', () => { + const { seen } = listen(['lv-find']); + + find('lv-find'); + + expect(seen).toEqual([]); + }); + + it('routes each name to its own handler, and ignores the rest', () => { + const host = fakeHost(); + const finds: CustomEvent[] = []; + const results: CustomEvent[] = []; + new DomListenerController(host, document, { + 'lv-find': (e) => void finds.push(e), + 'db-find-results': (e) => void results.push(e), + }); + host.connect(); + + for (const name of EVERY_NAME) { + find(name, name === 'db-find-results' ? { totalMatches: 3, type: 'soql' } : DETAIL); + } + + expect(finds.map((e) => e.type)).toEqual(['lv-find']); + expect(results.map((e) => e.detail)).toEqual([{ totalMatches: 3, type: 'soql' }]); + }); + + it('hands the event through untouched', () => { + const { host, seen } = listen(['lv-find-close']); + host.connect(); + + find('lv-find-close'); + + // The views branch on `type`, so it has to survive alongside the payload. + expect(seen[0]?.type).toBe('lv-find-close'); + expect(seen[0]?.detail).toEqual(DETAIL); + }); + + it('stops listening when the host disconnects', () => { + const { host, seen } = listen(['lv-find']); + host.connect(); + host.disconnect(); + + find('lv-find'); + + expect(seen).toEqual([]); + }); + + it('hears again after the element is detached and re-attached', async () => { + const el = new FindBusTestHost(); + document.body.append(el); + await el.updateComplete; + el.remove(); + document.body.append(el); + await el.updateComplete; + + find('lv-find'); + + expect(litCalls).toHaveLength(1); + el.remove(); + }); +}); diff --git a/log-viewer/src/core/events/__tests__/EventBus.test.ts b/log-viewer/src/core/events/__tests__/EventBus.test.ts new file mode 100644 index 000000000..88d1c4800 --- /dev/null +++ b/log-viewer/src/core/events/__tests__/EventBus.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { eventBus } from '../EventBus.js'; + +describe('eventBus.onSource', () => { + it('hands over an event that names the source, and nothing else', () => { + const seen: number[] = []; + const off = eventBus.onSource('inspector:reveal', 'calltree', (detail) => { + seen.push(detail.eventIndex); + }); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('inspector:reveal', { source: 'analysis', eventIndex: 5 }); + + off(); + expect(seen).toEqual([4]); + }); + + it('gives one event to the tab that names it and to no other', () => { + const seen: string[] = []; + const offTimeline = eventBus.onSource('inspector:locate', 'timeline', () => { + seen.push('timeline'); + }); + const offCalltree = eventBus.onSource('inspector:locate', 'calltree', () => { + seen.push('calltree'); + }); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: false }); + + offTimeline(); + offCalltree(); + expect(seen).toEqual(['calltree']); + }); + + it('stops on unsubscribe', () => { + let count = 0; + const off = eventBus.onSource('selection:clear', 'database', () => { + count++; + }); + + eventBus.emit('selection:clear', { source: 'database' }); + off(); + eventBus.emit('selection:clear', { source: 'database' }); + + expect(count).toBe(1); + }); +}); diff --git a/log-viewer/src/core/log/LogStore.ts b/log-viewer/src/core/log/LogStore.ts index d1dd3d8bf..ad9c31041 100644 --- a/log-viewer/src/core/log/LogStore.ts +++ b/log-viewer/src/core/log/LogStore.ts @@ -9,7 +9,6 @@ import { SOSLExecuteBeginLine, } from 'apex-log-parser'; -import { getEventKey, getStackKey } from './eventKeys.js'; import { KeyPathIds } from './keyPathIds.js'; export type Stack = LogEvent[]; @@ -26,8 +25,6 @@ export class LogStore { private _statements: Statements | null = null; private _keyPathIds: KeyPathIds | null = null; - private _keyIds: Int32Array | null = null; - private _stackIds: Int32Array | null = null; constructor(log: ApexLog) { this.log = log; @@ -38,6 +35,32 @@ export class LogStore { return this.log.eventsById[eventIndex] ?? null; } + /** + * The distinct frames `levels` parents above each of `eventIndexes`: what a + * merged row standing for its callers is, since a row at path depth D sits + * D - 1 hops above the calls it counts. + * + * How far the dedupe folds is the log's business. A call in a loop has one + * caller; a call made once per record has one caller each, so the answer can + * be as long as what was asked about. + * + * @param levels - hops to climb, at least one: the caller guards the rest so + * it can hand back the calls it already holds rather than a copy + */ + framesAbove(eventIndexes: readonly number[], levels: number): number[] { + const own = new Set(); + for (const index of eventIndexes) { + let frame = this.eventByIndex(index); + for (let up = levels; up > 0 && frame; up--) { + frame = frame.parent; + } + if (frame) { + own.add(frame.eventIndex); + } + } + return [...own]; + } + /** * The parent frames from the log's root down to `eventIndex`, the event itself * last. Empty if the log has no such event. @@ -70,52 +93,12 @@ export class LogStore { return this.statements().sosl; } - /** The interned bucket paths of this log, shared by every view that marks a row - * whose occurrences are merged. */ + /** The interned keys and bucket paths of this log, shared by every view that + * marks a row whose occurrences are merged. */ keyPathIds(): KeyPathIds { - return (this._keyPathIds ??= new KeyPathIds()); - } - - /** - * The event's interned bucket key, kept per event. - * - * A mark walks the caller chain of every occurrence a pick names, and those - * occurrences share their ancestors, so building the key string per frame was - * most of what a mark cost. - */ - keyIdOf(event: LogEvent): number { - const cache = (this._keyIds ??= idCache(this.log)); - const at = event.eventIndex; - if (at >= 0 && at < cache.length) { - let id = cache[at]!; - if (id < 0) { - id = this.keyPathIds().keyId(getEventKey(event)); - cache[at] = id; - } - return id; - } - // An event the log's own index does not cover, so there is no slot to keep. - return this.keyPathIds().keyId(getEventKey(event)); - } - - /** - * The event's interned stack key, which tells a recursive call from a fresh - * one. Interned in the same table as {@link keyIdOf}, since the two vocabularies - * are never compared with each other. - */ - stackIdOf(event: LogEvent): number { - const cache = (this._stackIds ??= idCache(this.log)); - const at = event.eventIndex; - if (at >= 0 && at < cache.length) { - let id = cache[at]!; - if (id < 0) { - id = this.keyPathIds().keyId(getStackKey(event)); - cache[at] = id; - } - return id; - } - // An event the log's own index does not cover, so there is no slot to keep. - return this.keyPathIds().keyId(getStackKey(event)); + // No index means no slot to keep a key in, which costs only the key being + // built again. + return (this._keyPathIds ??= new KeyPathIds(this.log.eventsById?.length ?? 0)); } private statements(): Statements { @@ -123,11 +106,6 @@ export class LogStore { } } -/** One slot per event, -1 until the event is asked about. */ -function idCache(log: ApexLog): Int32Array { - return new Int32Array(log.eventsById.length).fill(-1); -} - interface Statements { soql: SOQLExecuteBeginLine[]; dml: DMLBeginLine[]; diff --git a/log-viewer/src/core/log/__tests__/LogStore.test.ts b/log-viewer/src/core/log/__tests__/LogStore.test.ts index aca966280..d02335a5c 100644 --- a/log-viewer/src/core/log/__tests__/LogStore.test.ts +++ b/log-viewer/src/core/log/__tests__/LogStore.test.ts @@ -63,33 +63,6 @@ describe('LogStore', () => { expect(stack[stack.length - 1]?.text).toBe('ns.ClassTwo.second()'); }); - it('gives a frame one interned key, and tells the two vocabularies apart', () => { - const log = - '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + - '09:18:22.6 (6586704)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|ns.Thing.run()\n' + - '09:19:13.82 (51592737891)|CODE_UNIT_FINISHED|ns.Thing.run()\n' + - '09:19:13.82 (51595120059)|EXECUTION_FINISHED\n'; - - const apexLog = parse(log); - const store = logStoreFor(apexLog); - const unit = apexLog.eventsById.find((event) => event.text === 'ns.Thing.run()')!; - - // Asked twice, kept once — the mark reads the same frame per occurrence. - expect(store.keyIdOf(unit)).toBe(store.keyIdOf(unit)); - // The bucket key carries the event type and the stack key does not, so a - // frame's two ids are not the same id. - expect(store.stackIdOf(unit)).not.toBe(store.keyIdOf(unit)); - }); - - it('keys a frame the log index does not cover', () => { - const apexLog = parse('09:18:22.6 (6574780)|EXECUTION_STARTED\n'); - const store = logStoreFor(apexLog); - // Built rather than parsed, so it has no slot in the log's own index. - const loose = { type: 'METHOD_ENTRY', namespace: '', text: 'made up', eventIndex: 9999 }; - - expect(store.keyIdOf(loose as never)).toBe(store.keyIdOf(loose as never)); - }); - it('gives one log one store, whichever view asks', () => { const log = '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + '09:18:22.6 (7400000)|EXECUTION_FINISHED\n'; diff --git a/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts new file mode 100644 index 000000000..b1abb9c32 --- /dev/null +++ b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import { type ApexLog, parse } from 'apex-log-parser'; + +import { + aggregateVariablesFor, + cachedAggregateVariables, + MAX_VALUES_PER_NAME, +} from '../aggregateVariables.js'; +import { variableIndexFor } from '../frameVariables.js'; +import { logStoreFor, type LogStore } from '../LogStore.js'; + +const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; + +/** Resolves at once, so a test measures the walk rather than the frames it + * would leave to the next paint. */ +const yieldSlice = (): Promise => Promise.resolve(); + +function storeOf(body: string): { log: ApexLog; store: LogStore } { + const log = parse( + SETTINGS + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n', + ); + return { log, store: logStoreFor(log) }; +} + +/** Every frame whose log text is `text`, in log order. Frames only: a + * METHOD_EXIT carries the same text as the entry it closes. */ +function indexesOf(log: ApexLog, text: string): number[] { + return log.eventsById + .filter((event) => event.isParent && event.text === text) + .map((event) => event.eventIndex); +} + +/** One call of `ns.Svc.run()` writing `retry` and `accountId`. */ +function call(at: number, retry: string, accountId: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|retry|Boolean|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|retry|${retry}\n` + + `${t(30)}|VARIABLE_SCOPE_BEGIN|[3]|accountId|Id|true|false\n` + + `${t(40)}|VARIABLE_ASSIGNMENT|[3]|accountId|"${accountId}"\n` + + `${t(50)}|VARIABLE_ASSIGNMENT|[4]|batchSize|200\n` + + `${t(60)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); +} + +const CALLS = + call(1000, 'true', '001A') + call(2000, 'false', '001B') + call(3000, 'false', '001C'); + +/** The comparison of every `ns.Svc.run()` call, with the statics index built. */ +async function compare(body: string, text = 'ns.Svc.run()') { + const { log, store } = storeOf(body); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = indexesOf(log, text); + const spread = await aggregateVariablesFor(store, frames, index, { yieldSlice }); + return { log, store, index, frames, spread }; +} + +describe('aggregateVariablesFor', () => { + it('reads every call and groups its locals by name', async () => { + const { spread } = await compare(CALLS); + + expect(spread?.locals.map((row) => [row.name, row.calls])).toEqual([ + ['accountId', 3], + ['retry', 3], + ['batchSize', 3], + ]); + }); + + it('counts the calls that held each distinct value, most calls first', async () => { + const { spread } = await compare(CALLS); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + expect(retry?.values.map((value) => [value.text, value.calls])).toEqual([ + ['false', 2], + ['true', 1], + ]); + expect(retry?.calls).toBe(3); + }); + + // Which input varied is the reading a merged row carries, so the names that + // varied lead; a constant reads as it does for a single frame. + it('leads with the names that varied and trails with the constants', async () => { + const { spread } = await compare(CALLS); + + expect(spread?.locals.map((row) => [row.name, row.values.length])).toEqual([ + ['accountId', 3], + ['retry', 2], + ['batchSize', 1], + ]); + }); + + // The mark is what a value row points at, so it names every call, not one. + it('names the calls that held a value', async () => { + const { log, spread } = await compare(CALLS); + const calls = indexesOf(log, 'ns.Svc.run()'); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + expect(retry?.values.find((value) => value.text === 'false')?.at).toEqual([calls[1], calls[2]]); + expect(retry?.values.find((value) => value.text === 'true')?.at).toEqual([calls[0]]); + }); + + // Counts alone mislead: an unbroken run is a state the calls were in, where + // the same count scattered is a value that came and went. + it('counts the runs of consecutive calls that held each value', async () => { + const { spread } = await compare(CALLS); + const back = await compare( + call(1000, 'true', '001A') + call(2000, 'false', '001B') + call(3000, 'true', '001C'), + ); + + expect( + spread?.locals.find((row) => row.name === 'retry')?.values.map((value) => value.runs), + ).toEqual([1, 1]); + // `true` was held by the first and last call, with `false` between them. + expect( + back.spread?.locals.find((row) => row.name === 'retry')?.values.map((value) => value.runs), + ).toEqual([2, 1]); + }); + + it('stops naming calls for the mark past its own cap', async () => { + let body = ''; + for (let at = 0; at < 210; at++) { + body += call(1000 + at * 100, 'true', '001A'); + } + + const { spread } = await compare(body); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + // Every call held it, and the mark holds the first 200 of them. + expect(retry?.values[0]?.calls).toBe(210); + expect(retry?.values[0]?.at).toHaveLength(200); + expect(retry?.values[0]?.runs).toBe(1); + }); + + // In scope at its default, with no value the log recorded. + it('counts the calls that declared a name and never wrote it', async () => { + const declared = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[2]|held|Integer|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[2]|held|7\n' + + '09:18:22.6 (1030)|METHOD_EXIT|[1]|ns.Svc.run()\n' + + '09:18:22.6 (2000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (2010)|VARIABLE_SCOPE_BEGIN|[2]|held|Integer|true|false\n' + + '09:18:22.6 (2030)|METHOD_EXIT|[1]|ns.Svc.run()\n'; + + const { spread } = await compare(declared); + + const held = spread?.locals.find((row) => row.name === 'held'); + expect(held).toMatchObject({ calls: 2, unassigned: 1, declaredType: 'Integer' }); + expect(held?.values.map((value) => value.text)).toEqual(['7']); + }); + + // Every value is listed, since a value per call is the reading that says a + // name is an input; the cap only bounds a pathological selection. + it('stops holding values past the cap and says it did', async () => { + let body = ''; + for (let at = 0; at < MAX_VALUES_PER_NAME + 20; at++) { + body += call(1000 + at * 100, 'true', `001${at}`); + } + + const { spread } = await compare(body); + + const accountId = spread?.locals.find((row) => row.name === 'accountId'); + expect(accountId?.values).toHaveLength(MAX_VALUES_PER_NAME); + expect(accountId?.capped).toBe(true); + expect(accountId?.calls).toBe(MAX_VALUES_PER_NAME + 20); + // Under the cap, so its count is the whole truth. + expect(spread?.locals.find((row) => row.name === 'retry')?.capped).toBe(false); + }); + + // A recursive frame's nested call is its own call with its own values, so + // dropping it the way an outermost-events dedupe would undercounts the spread. + it('reads a nested call of the same frame as a call of its own', async () => { + const recursive = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|depth|1\n' + + '09:18:22.6 (1020)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|depth|2\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Svc.run()\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[1]|ns.Svc.run()\n'; + + const { spread } = await compare(recursive); + + const depth = spread?.locals.find((row) => row.name === 'depth'); + expect(depth?.calls).toBe(2); + expect(depth?.values.map((value) => value.text)).toEqual(['1', '2']); + }); + + it('returns null for an abandoned walk, and never memoises it', async () => { + const { log, store } = storeOf(CALLS); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = indexesOf(log, 'ns.Svc.run()'); + // Only a spent slice yields, and only a yield reads the signal. + const clock = jest.spyOn(performance, 'now'); + let time = 0; + clock.mockImplementation(() => (time += 100)); + try { + const spread = await aggregateVariablesFor(store, frames, index, { + yieldSlice, + signal: AbortSignal.abort(), + }); + + expect(spread).toBeNull(); + expect(cachedAggregateVariables(frames)).toBeUndefined(); + } finally { + clock.mockRestore(); + } + }); + + it('answers a walked selection from the memo, so nothing walks twice', async () => { + const { store, index, frames, spread } = await compare(CALLS); + + expect(cachedAggregateVariables(frames)).toBe(spread); + expect(await aggregateVariablesFor(store, frames, index, { yieldSlice })).toBe(spread); + }); +}); + +describe('aggregateVariablesFor on this', () => { + /** One call of `ns.Svc.run()` on the object at `address`. */ + function onObject(at: number, address: string, name: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|this|ns.Svc|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|this|{}|${address}\n` + + `${t(30)}|VARIABLE_ASSIGNMENT|[3]|this.name|"${name}"|${address}\n` + + `${t(40)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + } + + it('spreads the fields and counts the objects the calls ran on', async () => { + const { spread } = await compare(onObject(1000, '0xaaa', 'A') + onObject(2000, '0xbbb', 'B')); + + expect(spread?.thisType).toBe('ns.Svc'); + expect(spread?.objects).toBe(2); + expect(spread?.fields.map((row) => [row.name, row.values.length])).toEqual([['name', 2]]); + }); + + it('names no class where the calls did not agree on one', async () => { + const { log, store } = storeOf( + onObject(1000, '0xaaa', 'A') + + '09:18:22.6 (2000)|METHOD_ENTRY|[1]|01p|ns.Other.run()\n' + + '09:18:22.6 (2010)|VARIABLE_ASSIGNMENT|[2]|count|1\n' + + '09:18:22.6 (2020)|METHOD_EXIT|[1]|ns.Other.run()\n', + ); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = [...indexesOf(log, 'ns.Svc.run()'), ...indexesOf(log, 'ns.Other.run()')]; + + const spread = await aggregateVariablesFor(store, frames, index, { yieldSlice }); + + // Both frames were read: the second one's local is the only one that holds it. + expect(spread?.locals.find((row) => row.name === 'count')?.calls).toBe(1); + expect(spread?.thisType).toBeNull(); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/frameVariables.test.ts b/log-viewer/src/core/log/__tests__/frameVariables.test.ts new file mode 100644 index 000000000..5046f7d59 --- /dev/null +++ b/log-viewer/src/core/log/__tests__/frameVariables.test.ts @@ -0,0 +1,974 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { type ApexLog, parse } from 'apex-log-parser'; + +import { + apexCodeLevel, + frameVariablesFor, + recordsVariables, + variableIndexFor, +} from '../frameVariables.js'; +import { logStoreFor, type LogStore } from '../LogStore.js'; + +const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; + +/** Wraps `body` in the header and footer the parser needs to build a tree. */ +function logOf(body: string, settings = SETTINGS): string { + return ( + settings + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n' + ); +} + +function storeOf(body: string, settings = SETTINGS): { log: ApexLog; store: LogStore } { + const log = parse(logOf(body, settings)); + return { log, store: logStoreFor(log) }; +} + +/** The eventIndex of the frame or event whose log text is `text`. */ +function indexOf(log: ApexLog, text: string): number { + const found = log.eventsById.find((event) => event.text === text); + if (!found) { + throw new Error(`no event with text ${text}`); + } + return found.eventIndex; +} + +const OUTER = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|total|Integer|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[2]|total|1\n' + + '09:18:22.6 (1300)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1400)|VARIABLE_ASSIGNMENT|[6]|inner|"deep"\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1600)|VARIABLE_ASSIGNMENT|[8]|total|2\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + +describe('apexCodeLevel', () => { + it('reads the level the log was captured at', () => { + expect(apexCodeLevel(storeOf('').log)).toBe('FINEST'); + expect(recordsVariables(storeOf('').log)).toBe(true); + }); + + it('tells a level that records no variables from one that does', () => { + const { log } = storeOf('', '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'); + + expect(apexCodeLevel(log)).toBe('FINE'); + expect(recordsVariables(log)).toBe(false); + }); +}); + +describe('frameVariablesFor', () => { + it('reads the locals a frame wrote', async () => { + const { log, store } = storeOf(OUTER); + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.frameLabel).toBe('ns.Outer.run()'); + expect(frame?.locals).toEqual([ + { + name: 'total', + value: '2', + declaredType: 'Integer', + address: null, + assigned: true, + objectAddress: null, + }, + ]); + }); + + // The value is what the frame left, so the later write wins. + it('shows the last write a frame made, not the first', () => { + const { log, store } = storeOf(OUTER); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals[0]?.value).toBe('2'); + }); + + // "Only the ones on stack and would be visible": a caller's local is on the + // stack but out of scope from the method it called. + it('keeps a caller local out of the frame it called', () => { + const { log, store } = storeOf(OUTER); + + const inner = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + + expect(inner?.locals.map((row) => row.name)).toEqual(['inner']); + }); + + it('reads an event inside a frame as the log reached it', () => { + const { log, store } = storeOf(OUTER); + + // The inner call sits between the two writes to `total`. + const atInner = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + const outerIndex = indexOf(log, 'ns.Outer.run()'); + const inner = log.eventsById.find((event) => event.text === 'ns.Inner.step()')!; + const fromParent = frameVariablesFor(store, outerIndex, null); + + // Asked of the inner frame, the answer is the inner frame's own scope. + expect(atInner?.frameLabel).toBe('ns.Inner.step()'); + // Asked of the outer frame, both of its writes are in. + expect(fromParent?.locals[0]?.value).toBe('2'); + expect(inner.eventIndex).toBeGreaterThan(outerIndex); + }); + + it('splits instance fields out of the locals', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|count|1\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|this.count|7\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + // Shadowing stays visible: one name, one row in each group. + expect(frame?.locals.map((row) => row.name)).toEqual(['count']); + expect(frame?.fields).toEqual([ + { + name: 'count', + value: '7', + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + }, + ]); + expect(frame?.thisType).toBe('ns.Outer'); + }); + + it('leaves a static out of the locals, since the index answers for it', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|4\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals).toEqual([]); + expect(frame?.fields).toEqual([]); + }); + + it('sorts a frame’s locals by name', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|zeta|1\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alpha|2\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals.map((row) => row.name)).toEqual(['alpha', 'zeta']); + }); + + it('reports no frame for an event the log does not hold', () => { + const { store } = storeOf(OUTER); + + expect(frameVariablesFor(store, 99_999, null)).toBeNull(); + }); + + // A frame asked about once is scanned straight from its children, bounded at + // the cut; a frame asked about twice is read into a memo. Both must answer the + // same, or looking at a row a second time would change it. + it('answers the same however many times a frame has been read', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[2]|tries|Integer|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[2]|tries|1\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|this.name|"A"|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|tries|2\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[1]|ns.Svc.run()\n', + ); + const at = (text: string): number => { + const found = log.eventsById.find((event) => event.logLine?.includes(text)); + if (!found) { + throw new Error(`no line holding ${text}`); + } + return found.eventIndex; + }; + const read = (eventIndex: number): string => + JSON.stringify(frameVariablesFor(store, eventIndex, null)); + + const early = at('this.name'); + const first = read(early); + // The whole frame, which is what earns the memo. + read(at('ns.Svc.run()')); + const again = read(early); + + // As it stood at that line: the first write, and the field beside it. + expect(JSON.parse(first).locals).toEqual([ + expect.objectContaining({ name: 'tries', value: '1' }), + ]); + expect(JSON.parse(first).fields).toEqual([expect.objectContaining({ name: 'name' })]); + expect(again).toBe(first); + }); +}); + +describe('VariableIndex', () => { + const STATICS = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_SCOPE_BEGIN|[2]|ns.Cache.hits|Integer|true|true\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|1\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|ns.Cache.hits|2\n' + + '09:18:22.6 (1350)|VARIABLE_ASSIGNMENT|[7]|ns.Other.flag|true\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + // Reading them walks every static class the log holds, which is the bulk of a + // read; a caller that does not compare statics must not pay for it. + it('leaves the statics unread where the caller asked it to', async () => { + const { log, store } = storeOf(STATICS); + const statics = await variableIndexFor(log); + const at = indexOf(log, 'ns.Outer.run()'); + + const frame = frameVariablesFor(store, at, statics, { statics: false }); + + expect(frame?.statics).toEqual([]); + // Everything else the frame holds is untouched. + expect(frame?.locals).toEqual(frameVariablesFor(store, at, statics)?.locals); + }); + + it('groups statics by their class, both sorted', async () => { + const { log, store } = storeOf(STATICS); + const statics = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), statics); + + expect(frame?.statics).toEqual([ + { + className: 'ns.Cache', + rows: [ + { + name: 'hits', + value: '2', + declaredType: 'Integer', + address: null, + assigned: true, + objectAddress: null, + }, + ], + }, + { + className: 'ns.Other', + rows: [ + { + name: 'flag', + value: 'true', + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + }, + ], + }, + ]); + }); + + // A static assigned after the frame ran was not visible from it. + it('holds back a static assigned after the frame', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|1\n' + + '09:18:22.6 (1200)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1300)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1400)|VARIABLE_ASSIGNMENT|[10]|ns.Later.set|9\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const statics = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), statics); + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), statics); + + expect(first?.statics.map((group) => group.className)).toEqual(['ns.Cache']); + // Every static assigned by this point is visible, whichever frame wrote it. + expect(second?.statics.map((group) => group.className)).toEqual(['ns.Cache', 'ns.Later']); + }); + + it('says whether the log recorded any write at all', async () => { + const withWrites = await variableIndexFor(storeOf(OUTER).log); + const withNone = await variableIndexFor( + storeOf('09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n').log, + ); + + expect(withWrites.sawAnyWrite).toBe(true); + expect(withNone.sawAnyWrite).toBe(false); + }); + + it('builds one index per log, however many readers ask', async () => { + const { log } = storeOf(STATICS); + + const [first, second] = await Promise.all([variableIndexFor(log), variableIndexFor(log)]); + + expect(second).toBe(first); + expect(await variableIndexFor(log)).toBe(first); + }); +}); + +// Where a value would not serialise the log writes a bare address, and reports +// that same address beside a real value elsewhere, which is how nearly every +// bare address resolves. +// A frame can run with another instance of its own class on the stack. The class +// alone cannot tell them apart; the object's address can. +// Past the per-name cap the walk drops the oldest writes to a static, so a late +// frame reads the true last value rather than a stale early one. +describe('classAt does not leak the last declared class', () => { + // `this` is not redeclared on every call: the second frame here writes `this` + // with no scope declaration of its own, and must not borrow the first + // frame's class just because it was the last one the walk saw. + it('names no class for a this write its own frame never declared', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[2]|01p|()|ns.First\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.First|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xaaa111\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_EXIT|[2]|01p|()|ns.First\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[3]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[3]|this|{}|0xbbb222\n' + + '09:18:22.6 (1260)|METHOD_EXIT|[3]|ns.Second.run()\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.classAt('0xaaa111', 99_999)).toBe('ns.First'); + expect(index.classAt('0xbbb222', 99_999)).toBeNull(); + }); +}); + +describe('static write cap keeps recency, not insertion order', () => { + it('answers with the most recent write, not the earliest', async () => { + const lines: string[] = ['09:18:22.6 (300)|METHOD_ENTRY|[1]|01p|ns.Outer.run()']; + const total = 20_005; + for (let i = 0; i < total; i++) { + lines.push(`09:18:22.6 (${400 + i})|VARIABLE_ASSIGNMENT|[2]|ns.Counter.total|${i}`); + } + lines.push('09:18:22.6 (999999)|METHOD_EXIT|[1]|ns.Outer.run()'); + const { log, store } = storeOf(lines.join('\n') + '\n'); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + const counter = frame?.statics.find((entry) => entry.className === 'ns.Counter'); + + expect(counter?.rows.find((row) => row.name === 'total')?.value).toBe(String(total - 1)); + }, 15_000); +}); + +// A field row's own line reports its owner, never the field's own value: taking +// it as the field's address would resolve the field to the OWNER's class. +describe('a field row never borrows its owner as its own address', () => { + it('carries no object address of its own', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Outer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xbbb222\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|this.total|42|0xbbb222\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + const total = frame?.fields.find((row) => row.name === 'total'); + + expect(total?.value).toBe('42'); + expect(total?.objectAddress).toBeNull(); + }); +}); + +describe('two instances of one class on the stack', () => { + const TWO = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Same.outer()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.outerOnly|"A"|0xaaa111\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Same.inner()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[10]|this.innerOnly|"B"|0xbbb222\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[9]|ns.Same.inner()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Same.outer()\n'; + + it('keeps one instance out of the other instance fields', () => { + const { log, store } = storeOf(TWO); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.inner()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['innerOnly']); + }); + + // The caller's own frame reads as its own object, not the callee's. + it('reads the caller as its own instance', () => { + const { log, store } = storeOf(TWO); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.outer()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['outerOnly']); + }); + + // A frame the log never named still merges by class, which is all it has. + it('still merges by class where the log named no object', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Same.outer()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.outerOnly|"A"\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Same.inner()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[10]|this.innerOnly|"B"|0xbbb222\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[9]|ns.Same.inner()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Same.outer()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.inner()'), null); + + expect(frame?.fields.map((row) => row.name).sort()).toEqual(['innerOnly', 'outerOnly']); + }); +}); + +// The class of the object at an address, which the declared type often gives +// only as an interface. +describe('VariableIndex classAt', () => { + // A superclass constructor runs on the same object, inside the subclass + // constructor. Taking the later declaration would report the ancestor. + const SUBCLASS = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[17]|01p|()|ns.Handler\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[11]|this|ns.Handler|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[11]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_ENTRY|[12]|01p|()|ns.BaseHandler\n' + + '09:18:22.6 (1090)|VARIABLE_SCOPE_BEGIN|[15]|this|ns.BaseHandler|true|false\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[15]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1110)|CONSTRUCTOR_EXIT|[12]|01p|()|ns.BaseHandler\n' + + '09:18:22.6 (1120)|CONSTRUCTOR_EXIT|[17]|01p|()|ns.Handler\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('names the class the object was constructed as', async () => { + const { log } = storeOf(SUBCLASS); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 99_999)).toBe('ns.Handler'); + }); + + it('names none before the log declared it', async () => { + const { log } = storeOf(SUBCLASS); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 0)).toBeNull(); + expect(index.classAt('0xnothere', 99_999)).toBeNull(); + }); + + // An object built outside the log is only ever named by a method, which + // declares `this` as the type it was compiled against. + it('falls back to the type a method declared', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.BaseHandler.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[15]|this|ns.BaseHandler|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[15]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.BaseHandler.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 99_999)).toBe('ns.BaseHandler'); + }); +}); + +describe('VariableIndex address resolution', () => { + const ADDRESSED = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|alias|0xd854c6b\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + // The row names the address; the index says what the log holds for it. The + // row's own value stays exactly what the log wrote on that line. + it('names the address a value is, and holds what the log wrote for it', async () => { + const { log, store } = storeOf(ADDRESSED); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index)!; + const alias = frame.locals.find((row) => row.name === 'alias'); + + expect(alias?.address).toBe('0xd854c6b'); + expect(alias?.value).toBe('0xd854c6b'); + expect(index.addressState('0xd854c6b', frame.cut).text).toBe('{"Id":"001"}'); + }); + + it('leaves a value that is not an address alone', async () => { + const { log, store } = storeOf(ADDRESSED); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + + expect(frame?.locals.find((row) => row.name === 'held')?.address).toBeNull(); + }); + + // The address names an object whose contents change, so answering with a + // later write would show a state the frame never saw. + it('answers as the value stood at the cut, not a later one', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|held|{"n":1}|0xaaa\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|alias|0xaaa\n' + + '09:18:22.6 (1150)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[10]|held|{"n":2}|0xaaa\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[11]|alias|0xaaa\n' + + '09:18:22.6 (1350)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index)!; + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), index)!; + + expect(index.addressState('0xaaa', first.cut).text).toBe('{"n":1}'); + expect(index.addressState('0xaaa', second.cut).text).toBe('{"n":2}'); + }); + + it('holds nothing for an address the log never serialised', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xbbb\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index)!; + + expect(frame.locals[0]?.address).toBe('0xbbb'); + expect(index.addressState('0xbbb', frame.cut)).toEqual({ text: null, laterAt: null }); + }); + + // On a `this.field` line the reported address is the object the field belongs + // to, not the value the line wrote: one such address usually carries two or + // more different values. Taking it as a witness answers about an object with + // one of its fields. + it('takes no value from a field write, whose address is the owner', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.sortDir|"asc"|0xf1e2d3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alias|0xf1e2d3\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0xf1e2d3', 99_999).text).toBeNull(); + }); + + // The same address on a `this` line does name the value: the variable is the + // object. + it('takes the value from a write of this, whose address is the object', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this|{"sortDir":"asc"}|0xf1e2d3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alias|0xf1e2d3\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0xf1e2d3', 99_999).text).toBe('{"sortDir":"asc"}'); + }); + + // The frame answers before the index exists, so the section can show the + // scope while the walk runs. Only the log-wide statics wait for it. + it('answers the frame alone before the index is built', () => { + const { log, store } = storeOf(ADDRESSED); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals.find((row) => row.name === 'alias')?.address).toBe('0xd854c6b'); + expect(frame?.statics).toEqual([]); + }); +}); + +// "Everything that frame could access": a name in scope with no value recorded +// is still in scope, and a field the frame never touched is still its field. +describe('frameVariablesFor whole scope', () => { + it('lists a local the log declared and never assigned', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + const never = frame?.locals.find((row) => row.name === 'never'); + + expect(never?.assigned).toBe(false); + expect(never?.value).toBe(''); + expect(never?.declaredType).toBe('Boolean'); + }); + + // A method of class X shares its `this` with another method of X that called + // it, so a field the caller set is in scope in the callee. + it('gathers fields from a caller frame of the same class', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.fromCaller|"set early"\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Outer.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|this.fromCallee|7\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Outer.step()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.step()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['fromCallee', 'fromCaller']); + }); + + // A different class on the stack has a different `this`. + it('leaves the fields of another class out', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Other.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.notMine|1\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Outer.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|this.mine|2\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Outer.step()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Other.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.step()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['mine']); + }); + + it('lists a static the log declared and never assigned', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|ns.Cache.never|Integer|true|true\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + + expect(frame?.statics).toEqual([ + { + className: 'ns.Cache', + rows: [ + { + name: 'never', + value: '', + declaredType: 'Integer', + address: null, + assigned: false, + objectAddress: null, + }, + ], + }, + ]); + }); + + it('holds back a static declared after the frame ran', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|seen|1\n' + + '09:18:22.6 (1100)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_SCOPE_BEGIN|[10]|ns.Late.field|Integer|true|true\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index); + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), index); + + expect(first?.statics).toEqual([]); + expect(second?.statics.map((group) => group.className)).toEqual(['ns.Late']); + }); +}); + +/** + * A SOQL statement is not Apex code with locals of its own: `Database.query` + * issued it, and the query was built two lines above in the calling method. So + * the scope answered is the nearest frame recording a variable, and the label + * says which frame that was. + */ +describe('frameVariablesFor scope attribution', () => { + const QUERY = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|compId|"a450R000004NtIoQAK"\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|qry|"SELECT Id FROM Account"\n' + + '09:18:22.6 (1150)|STATEMENT_EXECUTE|[4]\n' + + '09:18:22.6 (1200)|SYSTEM_METHOD_ENTRY|[4]|Database.query(String)\n' + + '09:18:22.6 (1250)|SOQL_EXECUTE_BEGIN|[4]|Aggregations:0|SELECT Id FROM Account\n' + + '09:18:22.6 (1300)|SOQL_EXECUTE_END|[4]|Rows:204\n' + + '09:18:22.6 (1350)|SYSTEM_METHOD_EXIT|[4]|Database.query(String)\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('answers a SOQL selection with the scope that built the query', () => { + const { log, store } = storeOf(QUERY); + + const frame = frameVariablesFor(store, indexOf(log, 'SELECT Id FROM Account'), null); + + expect(frame?.locals.map((row) => row.name)).toEqual(['compId', 'qry']); + // The label names the frame the locals belong to, so it is never a guess. + expect(frame?.frameLabel).toBe('ns.Outer.run()'); + }); + + it('answers a frame that has its own variables with its own', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|outer|1\n' + + '09:18:22.6 (1100)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[6]|inner|2\n' + + '09:18:22.6 (1200)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1250)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + + // It records a variable of its own, so the climb stops there. + expect(frame?.locals.map((row) => row.name)).toEqual(['inner']); + expect(frame?.frameLabel).toBe('ns.Inner.step()'); + }); + + it('reads the query scope as it stood when the query ran', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|compId|"first"\n' + + '09:18:22.6 (1100)|SYSTEM_METHOD_ENTRY|[4]|Database.query(String)\n' + + '09:18:22.6 (1150)|SOQL_EXECUTE_BEGIN|[4]|Aggregations:0|SELECT Id FROM Account\n' + + '09:18:22.6 (1200)|SOQL_EXECUTE_END|[4]|Rows:1\n' + + '09:18:22.6 (1250)|SYSTEM_METHOD_EXIT|[4]|Database.query(String)\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[7]|compId|"after the query"\n' + + '09:18:22.6 (1350)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const atQuery = frameVariablesFor(store, indexOf(log, 'SELECT Id FROM Account'), null); + const atFrame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + // The write after the query was not in scope when the query ran. + expect(atQuery?.locals[0]?.value).toBe('"first"'); + expect(atFrame?.locals[0]?.value).toBe('"after the query"'); + }); +}); + +// An address can appear inside a value rather than as one, such as a field +// holding a reference to another object. +describe('VariableIndex nested addresses', () => { + it('resolves an address that only ever appears inside a value', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|filter|{"RowLimit":3000}|0x6c98700c\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|view|{"m_tliFilter":"0x6c98700c"}|0x7d1781a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0x6c98700c', Number.MAX_SAFE_INTEGER).text).toBe( + '{"RowLimit":3000}', + ); + }); + + it('reads it as it stood at the cut', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|filter|{"n":1}|0xaaa\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|view|{"ref":"0xaaa"}\n' + + '09:18:22.6 (1150)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[10]|filter|{"n":2}|0xaaa\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index); + + expect(index.addressState('0xaaa', first!.cut).text).toBe('{"n":1}'); + expect(index.addressState('0xaaa', Number.MAX_SAFE_INTEGER).text).toBe('{"n":2}'); + }); +}); + +// A `this.field` line names the object the field belongs to, so an object's +// fields are indexed by that address wherever they were written. This is what +// lets a value the log wrote as `{}` show its parts. +describe('VariableIndex object fields', () => { + const BUILT = + '09:18:22.6 (1000)|METHOD_ENTRY|[70]|01p|ns.Caller.run()\n' + + '09:18:22.6 (1010)|CONSTRUCTOR_ENTRY|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[3]|this.rows|5|0xaaa\n' + + '09:18:22.6 (1060)|CONSTRUCTOR_EXIT|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1070)|VARIABLE_SCOPE_BEGIN|[71]|selector|ns.Selector|true|false\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[71]|selector|{}|0xaaa\n' + + '09:18:22.6 (1090)|METHOD_EXIT|[70]|ns.Caller.run()\n'; + + it('answers with the fields the log recorded for an object', async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xaaa', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'rows', value: '5' }, + { name: 'sObj', value: '"Account"' }, + ]); + expect(index.fieldsAt('0xaaa', Number.MAX_SAFE_INTEGER)).toHaveLength(2); + }); + + it('holds nothing for an object whose fields the log never wrote', async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xbbb', Number.MAX_SAFE_INTEGER)).toEqual([]); + }); + + // A field is its own write with its own place in the log, so it reads as the + // frame stood rather than as the object ended up. + it('answers as the frame stood, not as the object ended up', async () => { + const { log, store } = storeOf( + BUILT + + '09:18:22.6 (2000)|METHOD_ENTRY|[80]|01p|ns.Caller.later()\n' + + '09:18:22.6 (2010)|VARIABLE_ASSIGNMENT|[81]|this.rows|9|0xaaa\n' + + '09:18:22.6 (2020)|METHOD_EXIT|[80]|ns.Caller.later()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.Caller.run()'), index)!; + const later = frameVariablesFor(store, indexOf(log, 'ns.Caller.later()'), index)!; + + expect(index.fieldsAt('0xaaa', first.cut)).toMatchObject([{ name: 'rows', value: '5' }, {}]); + expect(index.fieldsAt('0xaaa', later.cut)).toMatchObject([{ name: 'rows', value: '9' }, {}]); + }); + + // The frame's own stack cannot see a constructor that has already returned, + // and most of an object's fields are set there. + it('gives a frame the fields its returned constructor set', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|CONSTRUCTOR_ENTRY|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1040)|CONSTRUCTOR_EXIT|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1050)|METHOD_ENTRY|[70]|01p|ns.Selector.query()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[71]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[71]|this|{}|0xaaa\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[72]|found|7\n' + + '09:18:22.6 (1090)|METHOD_EXIT|[70]|ns.Selector.query()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Selector.query()'), index); + + expect(frame?.fields.map((row) => row.name)).toEqual(['sObj']); + }); + + // An address is reused once its object is collected, so the fields of the + // object that lived there before are not this one's. + it('leaves out the fields of an earlier object at the same address', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|CONSTRUCTOR_ENTRY|[1]|01p|()|ns.First\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.First|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xbbb\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.one|1|0xbbb\n' + + '09:18:22.6 (1040)|CONSTRUCTOR_EXIT|[1]|01p|()|ns.First\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[9]|01p|()|ns.Second\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Second|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xbbb\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[10]|this.two|2|0xbbb\n' + + '09:18:22.6 (1090)|CONSTRUCTOR_EXIT|[9]|01p|()|ns.Second\n' + + '09:18:22.6 (1100)|METHOD_ENTRY|[20]|01p|ns.Second.go()\n' + + '09:18:22.6 (1110)|VARIABLE_SCOPE_BEGIN|[20]|this|ns.Second|true|false\n' + + '09:18:22.6 (1120)|VARIABLE_ASSIGNMENT|[20]|this|{}|0xbbb\n' + + '09:18:22.6 (1130)|METHOD_EXIT|[20]|ns.Second.go()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Second.go()'), index)!; + + expect(index.fieldsAt('0xbbb', frame.cut).map((row) => row.name)).toEqual(['two']); + }); + + // The index only sees a field write whose line reported an address, so the + // frame's own writes are merged in rather than replaced. + it('keeps a field write whose line reported no address', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[70]|01p|ns.Selector.query()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[71]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[71]|this|{}|0xaaa\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[72]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[73]|this.plain|"no address"\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[70]|ns.Selector.query()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Selector.query()'), index); + + expect(frame?.fields.map((row) => row.name)).toEqual(['plain', 'sObj']); + }); + + // Every row that shows an object reads its fields, and every row is built + // again whenever anything opens, so the read is held. + it("holds an object's fields for the point it was asked about", async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + const view = index.viewAt(Number.MAX_SAFE_INTEGER); + + expect(view.fields('0xaaa')).toBe(view.fields('0xaaa')); + expect(view.fields('0xaaa')).toHaveLength(2); + }); + + // Two objects of ONE class at a reused address are still two objects. The + // runs are what tells them apart, so they must not merge on the class name. + it('leaves out the fields of an earlier object of the same class', async () => { + const twice = (line: number, field: string) => + `09:18:22.6 (${1000 + line * 10})|CONSTRUCTOR_ENTRY|[${line}]|01p|()|ns.Item\n` + + `09:18:22.6 (${1001 + line * 10})|VARIABLE_SCOPE_BEGIN|[${line}]|this|ns.Item|true|false\n` + + `09:18:22.6 (${1002 + line * 10})|VARIABLE_ASSIGNMENT|[${line}]|this|{}|0xbbb\n` + + `09:18:22.6 (${1003 + line * 10})|VARIABLE_ASSIGNMENT|[${line}]|this.${field}|1|0xbbb\n` + + `09:18:22.6 (${1004 + line * 10})|CONSTRUCTOR_EXIT|[${line}]|01p|()|ns.Item\n`; + const { log, store } = storeOf( + twice(1, 'first') + + twice(5, 'second') + + '09:18:22.6 (1100)|METHOD_ENTRY|[20]|01p|ns.Item.go()\n' + + '09:18:22.6 (1110)|VARIABLE_SCOPE_BEGIN|[20]|this|ns.Item|true|false\n' + + '09:18:22.6 (1120)|VARIABLE_ASSIGNMENT|[20]|this|{}|0xbbb\n' + + '09:18:22.6 (1130)|METHOD_EXIT|[20]|ns.Item.go()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Item.go()'), index)!; + + expect(index.fieldsAt('0xbbb', frame.cut).map((row) => row.name)).toEqual(['second']); + }); + + // Each field holds its own writes, so a field assigned once early survives a + // field assigned four thousand times. + it('keeps a field written once beside a field written past the cap', async () => { + let body = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Loop|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xccc\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.keep|"important"|0xccc\n'; + for (let at = 1; at <= 4_100; at++) { + body += `09:18:22.6 (${2000 + at})|VARIABLE_ASSIGNMENT|[3]|this.n|${at}|0xccc\n`; + } + body += '09:18:22.6 (7000)|METHOD_EXIT|[1]|ns.Loop.run()\n'; + const { log } = storeOf(body); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xccc', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'keep', value: '"important"' }, + { name: 'n', value: '4100' }, + ]); + }); + + // Past the per-object cap the oldest writes are dropped, so a late frame still + // reads the true last value rather than a stale early one. + it('keeps the newest field writes when an object is written past the cap', async () => { + let body = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Loop|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xccc\n'; + for (let at = 1; at <= 4_100; at++) { + body += `09:18:22.6 (${2000 + at})|VARIABLE_ASSIGNMENT|[2]|this.n|${at}|0xccc\n`; + } + body += '09:18:22.6 (7000)|METHOD_EXIT|[1]|ns.Loop.run()\n'; + const { log } = storeOf(body); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xccc', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'n', value: '4100' }, + ]); + expect(index.capped).toBe(false); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts index 7cbcd437b..63466efe6 100644 --- a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts +++ b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts @@ -3,17 +3,29 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { LogEvent } from 'apex-log-parser'; + import { KeyPathIds, ROOT_PATH_ID } from '../keyPathIds.js'; +/** A frame at `eventIndex`, of the type and text a bucket key is built from. */ +function ev(eventIndex: number, text: string, parent: LogEvent | null, type = 'METHOD_ENTRY') { + return { eventIndex, type, namespace: '', text, parent } as unknown as LogEvent; +} + describe('KeyPathIds', () => { let ids: KeyPathIds; beforeEach(() => { - ids = new KeyPathIds(); + ids = new KeyPathIds(32); }); - /** Interns a whole path, named outermost key first as a row reads. */ + /** Interns a whole path, named outermost key first as a row reads, the way a + * tree build composes one. */ function pathFor(table: KeyPathIds, ...keys: string[]): number { - return table.pathOf([...keys].reverse()); + let id = ROOT_PATH_ID; + for (const key of keys) { + id = table.step(id, table.keyId(key)); + } + return id; } it('gives one id to the same path, however often it is asked for', () => { @@ -30,15 +42,6 @@ describe('KeyPathIds', () => { expect(pathFor(ids, 'A', 'B')).not.toBe(pathFor(ids, 'A')); }); - it('names the two directions apart, since a row means each in one view only', () => { - const chain = ['leaf', 'caller']; - - // Top-down names the whole chain; bottom-up names the leaf, then the leaf - // under its caller. - expect(ids.prefixesOf(chain)).toEqual([pathFor(ids, 'leaf'), expect.any(Number)]); - expect(ids.prefixesOf(chain)[1]).not.toBe(ids.pathOf(chain)); - }); - it('reads a path back, outermost first', () => { expect(ids.keysOf(pathFor(ids, 'A', 'B', 'C'))).toEqual(['A', 'B', 'C']); }); @@ -58,11 +61,123 @@ describe('KeyPathIds', () => { expect(ids.reaches(inner, pathFor(ids, 'Z'))).toBe(false); }); + describe('chainNodeAt', () => { + const root = ev(1, 'exec', null); + const outer = ev(2, 'outer', root); + const inner = ev(3, 'inner', outer); + + it('names the frame the row sits at, which is what a caller row stands for', () => { + const leafRow = pathFor(ids, 'METHOD_ENTRY||inner'); + const callerRow = pathFor(ids, 'METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'); + + expect(ids.chainNodeAt(inner, leafRow)).toBe(inner); + expect(ids.chainNodeAt(inner, callerRow)).toBe(outer); + }); + + it('answers for a path the chain misses with nothing', () => { + const elsewhere = pathFor(ids, 'METHOD_ENTRY||Z'); + + expect(ids.chainNodeAt(inner, elsewhere)).toBeNull(); + }); + }); + + it('reads back how many keys a path stands for, and none for the empty one', () => { + expect(ids.depthOf(pathFor(ids, 'A', 'B', 'C'))).toBe(3); + expect(ids.depthOf(ROOT_PATH_ID)).toBe(0); + }); + it('mints on its own, so an id from one log means nothing to another', () => { - const other = new KeyPathIds(); + const other = new KeyPathIds(32); expect(pathFor(other, 'Z')).toBe(pathFor(ids, 'A')); expect(other.keysOf(pathFor(other, 'Z'))).toEqual(['Z']); expect(ids.keysOf(pathFor(ids, 'A'))).toEqual(['A']); }); + + describe('keyIdOf', () => { + it('keeps one id per signature, not one per frame', () => { + const first = ev(1, 'Util.log', null); + const second = ev(2, 'Util.log', null); + + expect(ids.keyIdOf(first)).toBe(ids.keyIdOf(first)); + // Same type, namespace and text is the same bucket. + expect(ids.keyIdOf(second)).toBe(ids.keyIdOf(first)); + }); + + it('keys a frame no slot of its own covers, without one standing in for another', () => { + // Built rather than parsed, so neither carries an index at all. Keeping one + // under an index-less write lands it on the memo as an ordinary property, + // which the next such frame then reads back as its own. + const loose = { type: 'METHOD_ENTRY', namespace: '', text: 'made up' } as unknown as LogEvent; + const other = { + type: 'METHOD_ENTRY', + namespace: '', + text: 'and another', + } as unknown as LogEvent; + + expect(ids.keyIdOf(loose)).toBe(ids.keyIdOf(loose)); + expect(ids.keyIdOf(other)).not.toBe(ids.keyIdOf(loose)); + // And one whose index is past the end of the log's own array. + expect(ids.keyIdOf(ev(9999, 'past the end', null))).not.toBe(ids.keyIdOf(loose)); + }); + }); + + describe('stackIdOf', () => { + it('reads through the entry type, which a bucket key does not', () => { + const unit = ev(1, 'Thing.run()', null, 'CODE_UNIT_STARTED'); + const method = ev(2, 'Thing.run()', null, 'METHOD_ENTRY'); + + // A method that recurses as a code unit is one frame to the stack. + expect(ids.stackIdOf(unit)).toBe(ids.stackIdOf(method)); + expect(ids.keyIdOf(unit)).not.toBe(ids.keyIdOf(method)); + }); + + it('tells two frames apart', () => { + expect(ids.stackIdOf(ev(1, 'one', null))).not.toBe(ids.stackIdOf(ev(2, 'two', null))); + }); + }); + + describe('pathIdOf', () => { + const root = ev(1, 'exec', null); + const outer = ev(2, 'outer', root); + const inner = ev(3, 'inner', outer); + + it('names one row in a top-down view, at the depth the frame ran at', () => { + expect(ids.pathIdOf(inner)).toBe(pathFor(ids, 'METHOD_ENTRY||outer', 'METHOD_ENTRY||inner')); + }); + + it('leaves the log root out, as it heads no row', () => { + expect(ids.pathIdOf(root)).toBeUndefined(); + }); + }); + + describe('pathsEndingIn', () => { + /** The ids a key heads, over the paths the table has been asked for. */ + function found(...keys: string[]): number[] { + return ids.pathsEndingIn(new Set(keys.map((key) => ids.keyId(key)))); + } + + it('names the rows a frame is, and not the rows of the callers above it', () => { + const own = pathFor(ids, 'A'); + const elsewhere = pathFor(ids, 'B', 'A'); + // A row for the caller of A, which stands for that caller and not for A. + pathFor(ids, 'A', 'C'); + + expect(found('A').sort()).toEqual([own, elsewhere].sort()); + }); + + it('answers for several frames at once, which one pointed-at row can name', () => { + const a = pathFor(ids, 'A'); + const b = pathFor(ids, 'B'); + + expect(found('A', 'B').sort()).toEqual([a, b].sort()); + }); + + it('leaves the empty path out, since no row stands for it, and asks nothing of no keys', () => { + pathFor(ids, 'A'); + + expect(found()).toEqual([]); + expect(found('Z')).toEqual([]); + }); + }); }); diff --git a/log-viewer/src/core/log/__tests__/variableLine.test.ts b/log-viewer/src/core/log/__tests__/variableLine.test.ts new file mode 100644 index 000000000..cd577319f --- /dev/null +++ b/log-viewer/src/core/log/__tests__/variableLine.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + bareAddress, + bareAddressOf, + nestedAddressesOf, + classOf, + isStaticName, + parseVariableScope, + parseVariableWrite, + reportedAddressOf, +} from '../variableLine.js'; + +describe('parseVariableWrite', () => { + it('reads the name and the value', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|total|42'); + + expect(write).toEqual({ name: 'total', value: '42', address: null }); + }); + + it('takes the address off the end', () => { + const write = parseVariableWrite( + '11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|records|{"size":2}|0x3f2a1b', + ); + + expect(write).toEqual({ name: 'records', value: '{"size":2}', address: '0x3f2a1b' }); + }); + + // The log can leave the address field present and empty. Taken as part of the + // value it reads as `null|`. + it('drops the empty address field rather than reading it as the value', () => { + const write = parseVariableWrite('08:02:57.611 (1)|VARIABLE_ASSIGNMENT|[57]|a|null|'); + + expect(write).toEqual({ name: 'a', value: 'null', address: null }); + }); + + it('drops it from an object value too', () => { + const write = parseVariableWrite('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|'); + + expect(write?.value).toBe('{"a":1}'); + }); + + it('still reads a bare address that has the empty field after it', () => { + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|alias|0xd854c6b|')).toBe('0xd854c6b'); + }); + + // No real value holds a pipe, but position parsing keeps one if it ever does. + it('keeps a value that holds a pipe', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id'); + + expect(write?.value).toBe('Name|Id'); + expect(write?.address).toBeNull(); + }); + + it('keeps a piped value and still takes its address', () => { + const write = parseVariableWrite( + '11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id|0xabc123', + ); + + expect(write?.value).toBe('Name|Id'); + expect(write?.address).toBe('0xabc123'); + }); + + it('reads a value that is itself hex but not an address', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|code|0xdeadbeef'); + + // No pipe after the name's, so the hex is the value rather than an address. + expect(write).toEqual({ name: 'code', value: '0xdeadbeef', address: null }); + }); + + it('holds a this-qualified field name whole', () => { + expect(parseVariableWrite('t|VARIABLE_ASSIGNMENT|[7]|this.count|3')?.name).toBe('this.count'); + }); + + it('reports no write where the line carries no value', () => { + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|total')).toBeNull(); + }); + + it('reports no write where the line carries no name', () => { + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]||42')).toBeNull(); + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT')).toBeNull(); + }); +}); + +describe('parseVariableScope', () => { + it('reads the declared type and the static flag', () => { + const scope = parseVariableScope( + '11:22:33.1 (1)|VARIABLE_SCOPE_BEGIN|[7]|Account.cache|Map|true|true', + ); + + expect(scope).toEqual({ + name: 'Account.cache', + declaredType: 'Map', + isStatic: true, + }); + }); + + it('reads a local as not static', () => { + const scope = parseVariableScope( + '11:22:33.1 (1)|VARIABLE_SCOPE_BEGIN|[7]|total|Integer|false|false', + ); + + expect(scope).toEqual({ name: 'total', declaredType: 'Integer', isStatic: false }); + }); + + // The type holds commas, so the flags are read from the right, not by count. + it('keeps a generic type whole', () => { + const scope = parseVariableScope( + 't|VARIABLE_SCOPE_BEGIN|[7]|byKey|Map>|false|true', + ); + + expect(scope?.declaredType).toBe('Map>'); + expect(scope?.isStatic).toBe(true); + }); + + it('reports no scope for a line too short to carry one', () => { + expect(parseVariableScope('t|VARIABLE_SCOPE_BEGIN|[7]|total|Integer|false')).toBeNull(); + }); +}); + +describe('static names', () => { + it('names the class a static belongs to', () => { + expect(classOf('Account.cache')).toBe('Account'); + expect(classOf('ns.Account.cache')).toBe('ns.Account'); + expect(classOf('total')).toBeNull(); + }); + + it('tells a static from a field and a local', () => { + expect(isStaticName('Account.cache')).toBe(true); + expect(isStaticName('this.count')).toBe(false); + expect(isStaticName('total')).toBe(false); + }); +}); + +describe('addresses', () => { + it('reads a value that is only an address', () => { + expect(bareAddress('0xd854c6b')).toBe('0xd854c6b'); + expect(bareAddress(' 0xd854c6b ')).toBe('0xd854c6b'); + expect(bareAddress('{"a":1}')).toBeNull(); + expect(bareAddress('42')).toBeNull(); + }); + + it('reads one straight off a line', () => { + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|alias|0xd854c6b')).toBe('0xd854c6b'); + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|0xd854c6b')).toBeNull(); + }); + + // The walk reads every line of the log, and a value can be very long. + it('gives up on a long value before it slices it', () => { + const long = `t|VARIABLE_ASSIGNMENT|[7]|big|${'x'.repeat(70_000)}`; + + expect(bareAddressOf(long)).toBeNull(); + }); + + it('reads the address a line reported for its value', () => { + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|0xd854c6b')).toBe('0xd854c6b'); + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}')).toBeNull(); + // A piped value whose tail is not an address. + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id')).toBeNull(); + }); +}); + +describe('nestedAddressesOf', () => { + it('finds an address a value names inside itself', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"m_tliFilter":"0x6c98700c","n":1}'; + + expect(nestedAddressesOf(line)).toEqual(['0x6c98700c']); + }); + + // Every assignment reports one, so taking them all would hold the whole log. + it('leaves out the address the line reports for its own value', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"n":1}|0x7d1781a3'; + + expect(nestedAddressesOf(line)).toEqual([]); + }); + + it('finds both when a value names one and the line reports another', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"ref":"0x6c98700c"}|0x7d1781a3'; + + expect(nestedAddressesOf(line)).toEqual(['0x6c98700c']); + }); + + it('finds nothing in a value that names none', () => { + expect(nestedAddressesOf('t|VARIABLE_ASSIGNMENT|[7]|n|42')).toEqual([]); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/variableValue.test.ts b/log-viewer/src/core/log/__tests__/variableValue.test.ts new file mode 100644 index 000000000..e101340a6 --- /dev/null +++ b/log-viewer/src/core/log/__tests__/variableValue.test.ts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + clampRaw, + isExpandable, + parseVariableValue, + previewOf, + RAW_CLAMP_CHARS, +} from '../variableValue.js'; + +describe('parseVariableValue', () => { + it('reads a number, the commonest value', () => { + expect(parseVariableValue('42')).toEqual({ kind: 'literal', text: '42' }); + expect(parseVariableValue('-1.5')).toEqual({ kind: 'literal', text: '-1.5' }); + }); + + it('reads a boolean and a null', () => { + expect(parseVariableValue('true')).toEqual({ kind: 'literal', text: 'true' }); + expect(parseVariableValue('null')).toEqual({ kind: 'literal', text: 'null' }); + }); + + it('reads a value the log left blank', () => { + expect(parseVariableValue('')).toEqual({ kind: 'empty' }); + expect(parseVariableValue(' ')).toEqual({ kind: 'empty' }); + }); + + // A bare address means the value would not serialise. Calling it an empty + // object would claim the log knew it held nothing. + it('reads a bare address as an address', () => { + expect(parseVariableValue('0x3f2a1b')).toEqual({ kind: 'address', text: '0x3f2a1b' }); + }); + + it('reads a one-level object', () => { + const value = parseVariableValue('{"name":"Acme","count":2}'); + + expect(value).toEqual({ + kind: 'container', + brackets: '{}', + truncated: false, + fromString: false, + entries: [ + { key: 'name', text: '"Acme"', address: null }, + { key: 'count', text: '2', address: null }, + ], + }); + }); + + it('reads a list', () => { + const value = parseVariableValue('[1, 2, 3]'); + + expect(value).toMatchObject({ brackets: '[]' }); + expect(value).toMatchObject({ + entries: [ + { key: null, text: '1' }, + { key: null, text: '2' }, + { key: null, text: '3' }, + ], + }); + }); + + it('reads an empty object as an empty object', () => { + const value = parseVariableValue('{}'); + + expect(value).toEqual({ + kind: 'container', + brackets: '{}', + entries: [], + truncated: false, + fromString: false, + }); + expect(previewOf(value)).toBe('{}'); + expect(isExpandable(value)).toBe(false); + }); + + it('reads an empty list', () => { + expect(previewOf(parseVariableValue('[]'))).toBe('[]'); + }); + + // A Map serialises with repeats, so dropping them would hide entries the + // transaction really held. JSON.parse loses them in silence. + it('keeps duplicate keys, in the order the log wrote them', () => { + const value = parseVariableValue('{"key":"a","key":"b","key":"c"}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'key', text: '"a"' }, + { key: 'key', text: '"b"' }, + { key: 'key', text: '"c"' }, + ], + }); + }); + + it('reads a string', () => { + const value = parseVariableValue('"Acme Corp"'); + + expect(value).toMatchObject({ kind: 'string', inner: 'Acme Corp', toStringLike: false }); + }); + + it('marks a string the log cut short', () => { + const value = parseVariableValue('"first20charsofthisva (10 more) ..."'); + + expect(value).toMatchObject({ kind: 'string', truncated: true }); + // The marker is the log's own text and stays in it. + expect(previewOf(value, 200)).toContain('(10 more) ...'); + }); + + it('marks an Apex toString() that landed inside a string', () => { + const value = parseVariableValue('"{Id=001, Name=Acme}"'); + + expect(value).toMatchObject({ kind: 'string', toStringLike: true }); + }); + + it('marks a collection the log cut short', () => { + const value = parseVariableValue('{"a":1, "b":2, ...}'); + + expect(value).toMatchObject({ truncated: true, entries: [{ key: 'a' }, { key: 'b' }] }); + expect(previewOf(value, 200)).toBe('{a: 1, b: 2, …}'); + }); + + it('keeps a nested value verbatim rather than reading into it', () => { + const value = parseVariableValue('{"child":{"aot":"0x1f"},"n":1}'); + + // One level: the log holds no more, so a scan would find nothing deeper. + expect(value).toMatchObject({ + entries: [ + { key: 'child', text: '{"aot":"0x1f"}' }, + { key: 'n', text: '1' }, + ], + }); + }); + + it('does not split on a comma inside a string', () => { + const value = parseVariableValue('{"address":"1 High St, London","n":2}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'address', text: '"1 High St, London"' }, + { key: 'n', text: '2' }, + ], + }); + }); + + it('does not split on a comma inside a nested value', () => { + expect(parseVariableValue('{"a":{"x":1,"y":2},"n":3}')).toMatchObject({ + entries: [ + { key: 'a', text: '{"x":1,"y":2}' }, + { key: 'n', text: '3' }, + ], + }); + }); + + // One escaped quote before the comma: a scanner that does not skip the escape + // reads the string as closed and splits the entry in half. + it('reads an escaped quote without losing the key', () => { + expect(parseVariableValue('{"quote":"say \\"hi, there","n":1}')).toMatchObject({ + entries: [ + { key: 'quote', text: '"say \\"hi, there"' }, + { key: 'n', text: '1' }, + ], + }); + }); + + it('holds an unquoted key as text rather than guessing one', () => { + expect(parseVariableValue('{a=1}')).toMatchObject({ entries: [{ key: null, text: 'a=1' }] }); + }); + + // A logged value can be very long. Scanning that shape earns nothing, and the + // raw text is the honest answer. + it('hands back a huge value as text rather than scanning it', () => { + const huge = `{${'"k":"' + 'x'.repeat(70_000) + '"'}}`; + + expect(parseVariableValue(huge).kind).toBe('literal'); + }); +}); + +describe('isExpandable', () => { + it('offers an expander only where a row cannot hold the value', () => { + expect(isExpandable(parseVariableValue('42'))).toBe(false); + expect(isExpandable(parseVariableValue('0x3f2a'))).toBe(false); + expect(isExpandable(parseVariableValue('{}'))).toBe(false); + expect(isExpandable(parseVariableValue('{"a":1}'))).toBe(true); + expect(isExpandable(parseVariableValue(`"${'x'.repeat(200)}"`))).toBe(true); + expect(isExpandable(parseVariableValue('"short"'))).toBe(false); + }); +}); + +describe('an entry that names an address', () => { + // The log quotes a nested address, and the tree needs it to resolve the object. + it('reads it off a quoted entry', () => { + const value = parseVariableValue('{"m_tliFilter":"0x6c98700c","n":1}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'm_tliFilter', address: '0x6c98700c' }, + { key: 'n', address: null }, + ], + }); + }); + + it('reads it off a bare list entry', () => { + expect(parseVariableValue('[0x6c98700c]')).toMatchObject({ + entries: [{ key: null, address: '0x6c98700c' }], + }); + }); +}); + +describe('a string holding JSON', () => { + it('reads it as the object it holds', () => { + const value = parseVariableValue('"{\\"a\\":1}"'); + + expect(value).toMatchObject({ + kind: 'container', + brackets: '{}', + fromString: true, + entries: [{ key: 'a', text: '1' }], + }); + }); + + it('reads an unescaped one too', () => { + expect(parseVariableValue('"{"a":1}"')).toMatchObject({ kind: 'container', fromString: true }); + }); + + it('reads a list it holds', () => { + expect(parseVariableValue('"[1,2]"')).toMatchObject({ brackets: '[]', fromString: true }); + }); + + // An Apex toString() is text. Reading structure out of one would claim the log + // recorded something it did not. + it('leaves an Apex toString() as text', () => { + expect(parseVariableValue('"{accountid=AccountId, name=Name}"')).toMatchObject({ + kind: 'string', + toStringLike: true, + }); + }); + + it('leaves a braced string with no key as text', () => { + expect(parseVariableValue('"{not json}"')).toMatchObject({ kind: 'string' }); + }); + + it('leaves an ordinary string alone', () => { + expect(parseVariableValue('"Acme"')).toMatchObject({ kind: 'string', inner: 'Acme' }); + }); +}); + +describe('previewOf', () => { + it('clamps a long value to one row', () => { + const preview = previewOf(parseVariableValue(`"${'x'.repeat(500)}"`)); + + expect(preview.length).toBeLessThanOrEqual(82); + expect(preview.endsWith('…')).toBe(true); + }); + + it('shows nothing for a value the log left blank', () => { + expect(previewOf(parseVariableValue(''))).toBe(''); + }); +}); + +describe('clampRaw', () => { + it('cuts a value too big to lay out, and says so', () => { + const clamped = clampRaw('x'.repeat(RAW_CLAMP_CHARS + 1)); + + expect(clamped.clamped).toBe(true); + expect(clamped.text).toHaveLength(RAW_CLAMP_CHARS); + }); + + it('leaves a value that fits alone', () => { + expect(clampRaw('{"a":1}')).toEqual({ text: '{"a":1}', clamped: false }); + }); +}); diff --git a/log-viewer/src/core/log/aggregateVariables.ts b/log-viewer/src/core/log/aggregateVariables.ts new file mode 100644 index 000000000..6ef35dc98 --- /dev/null +++ b/log-viewer/src/core/log/aggregateVariables.ts @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { frameBudget, type FrameBudgetOptions } from '../utility/FrameBudget.js'; +import { frameVariablesFor, type VariableIndex, type VariableRow } from './frameVariables.js'; +import type { LogStore } from './LogStore.js'; + +/** + * What a merged row's calls held, compared across them. + * + * A merged row has no single frame, so it is answered by the spread rather than + * by one reading: 340 calls with 340 different `accountId` values and one + * `batchSize` of 200 says which input varied, which is the verdict the grids + * beside it do not carry. + * + * Statics are left out. A static lives for the whole transaction, so it moves + * for reasons the row does not own, and comparing it would report the log's + * history as this row's spread. + */ + +/** + * Distinct values held per name before the rest go uncounted. + * + * This is an interrogation tool, so every value a name held is listed: a name + * with one value per call is exactly the reading that says it is an input. The + * cap is only a bound on a pathological selection, since the section renders + * every row it lists. Past it the screen says some are not listed, never a + * distinct count the walk did not finish. + */ +export const MAX_VALUES_PER_NAME = 1_000; + +/** + * Calls held per value, for the mark. + * + * A mark on more frames than this shows the reader nothing further, and the + * lists partition the calls, so this only stops one value of a two-valued name + * from holding the whole selection. + */ +export const MAX_MARKED_PER_VALUE = 200; + +/** What a compared frame is read with, allocated once: a wide selection reads + * tens of thousands of frames. Statics are not compared, and the rows are + * grouped by name here, so the per-frame sort is thrown away. */ +const AS_READ = { statics: false, sorted: false } as const; + +/** One value a name held, and which calls held it. */ +export interface SpreadValue { + /** The log's own text, as it wrote it. */ + text: string; + address: string | null; + /** The address of the object the value *is*, where the log named one beside + * the text. What opens the value into the fields the index recorded for it. */ + objectAddress: string | null; + /** How many of the read calls held it. */ + calls: number; + /** The calls that held it, in the order the selection lists them, for the + * mark. Short of {@link calls} once {@link MAX_MARKED_PER_VALUE} bites. */ + at: number[]; + /** Runs of consecutive calls that held it. One run is a phase the calls + * passed through; more is a value that came and went. */ + runs: number; + /** The point the first call that held it read at, so an address can be + * resolved and an object opened. Each call read at its own point, so this is + * the one call the object is shown as. */ + cut: number; +} + +/** One name, across the calls the row counts. */ +export interface VariableSpread { + name: string; + declaredType: string | null; + /** Distinct values, most calls first. */ + values: SpreadValue[]; + /** Calls that had the name in scope. */ + calls: number; + /** Calls that declared it and never wrote it. */ + unassigned: number; + /** More distinct values than the cap holds, so `values` is not all of them. */ + capped: boolean; +} + +export interface AggregateVariables { + locals: VariableSpread[]; + /** The class owning the fields, where every call agreed on one. */ + thisType: string | null; + /** Distinct objects the calls ran on. */ + objects: number; + fields: VariableSpread[]; + /** A frame read ran past the end of a truncated log, so a missing write may be + * unrecorded rather than absent. */ + truncated: boolean; + /** Some name held more distinct values than the cap, so a spread is not all of + * what the calls held. Read here rather than rescanned per render. */ + capped: boolean; +} + +/** One value while it is still being gathered, beside where its last call sat + * so a break in the run can be seen. */ +interface GatheredValue { + value: SpreadValue; + /** Which call held it last, as an ordinal into the calls read. */ + previous: number; +} + +/** A spread while it is still being gathered: the row it will ship, with the + * values keyed by identity until they can be ordered. */ +interface Gathering { + row: VariableSpread; + byValue: Map; +} + +/** + * What the calls `frames` names held, compared across them. + * + * Returns null when the walk is abandoned (see {@link FrameBudgetOptions}). + */ +async function compareFrames( + store: LogStore, + frames: readonly number[], + index: VariableIndex | null, + options: FrameBudgetOptions, +): Promise { + const tick = frameBudget(options); + const locals = new Map(); + const fields = new Map(); + const objects = new Set(); + const classes = new Set(); + let read = 0; + let truncated = false; + let capped = false; + + // Every frame, with no `outermostEvents` dedupe — deliberately, and unlike + // every other multi-frame read here. A recursive frame's nested call is its + // own call with its own values, so dropping it would undercount the spread. + for (const eventIndex of frames) { + // Per frame rather than every CHECK_EVERY: one read scans a whole frame's + // lines, so 256 of them would overrun the slice many times over, and asking + // the clock once per read costs nothing beside it. + if (!(await tick())) { + return null; + } + const frame = frameVariablesFor(store, eventIndex, index, AS_READ); + if (!frame) { + continue; + } + truncated ||= frame.truncated; + for (const row of frame.locals) { + hold(locals, row, eventIndex, read, frame.cut); + } + for (const row of frame.fields) { + hold(fields, row, eventIndex, read, frame.cut); + } + read++; + if (frame.thisType) { + classes.add(frame.thisType); + } + if (frame.thisRow?.objectAddress) { + objects.add(frame.thisRow.objectAddress); + } + } + + for (const held of [...locals.values(), ...fields.values()]) { + capped ||= held.row.capped; + } + return { + locals: spreadsOf(locals), + // Only where every call agreed: two classes under one row means the reading + // is not one class's. + thisType: classes.size === 1 ? [...classes][0]! : null, + objects: objects.size, + fields: spreadsOf(fields), + truncated, + capped, + }; +} + +/** + * Adds one call's reading of a name to what the walk holds for it. + * + * @param at - the call's eventIndex, for the mark + * @param ordinal - which call it is among those read, so a value's runs can be + * counted without holding the whole sequence + * @param cut - the point that call read at, kept for the first call to hold a + * value so its object can be opened + */ +function hold( + into: Map, + row: VariableRow, + at: number, + ordinal: number, + cut: number, +): void { + let held = into.get(row.name); + if (!held) { + held = { + row: { + name: row.name, + declaredType: null, + values: [], + calls: 0, + unassigned: 0, + capped: false, + }, + byValue: new Map(), + }; + into.set(row.name, held); + } + const spread = held.row; + spread.calls++; + // The first call to declare it names the type; a later one repeats it. + spread.declaredType ??= row.declaredType; + if (!row.assigned) { + // In scope at its default, with no value the log recorded. + spread.unassigned++; + return; + } + // The log's own text is the identity: two calls that named the same address + // held the same object, and two that wrote the same text held the same value. + const identity = row.value || row.address || ''; + const seen = held.byValue.get(identity); + if (seen) { + const value = seen.value; + value.calls++; + // A call that does not follow the last one starts a run of its own. + if (ordinal !== seen.previous + 1) { + value.runs++; + } + seen.previous = ordinal; + if (value.at.length < MAX_MARKED_PER_VALUE) { + value.at.push(at); + } + } else if (held.byValue.size < MAX_VALUES_PER_NAME) { + held.byValue.set(identity, { + value: { + text: row.value, + address: row.address, + objectAddress: row.objectAddress, + calls: 1, + at: [at], + runs: 1, + cut, + }, + previous: ordinal, + }); + } else { + spread.capped = true; + } +} + +/** + * The gathered names as rows, in the order they read. + * + * The names that varied lead, most distinct values first, since they are what + * explains the spread; then the constants, then the names every call declared + * and never wrote. + */ +function spreadsOf(held: ReadonlyMap): VariableSpread[] { + const spreads: VariableSpread[] = []; + for (const { row, byValue } of held.values()) { + for (const { value } of byValue.values()) { + row.values.push(value); + } + row.values.sort((left, right) => right.calls - left.calls); + spreads.push(row); + } + return spreads.sort( + (left, right) => + right.values.length - left.values.length || left.name.localeCompare(right.name), + ); +} + +/** Memo of the walk: the log never changes after parse, so each row's frames are + * compared once. Keyed by the frames array, which stays the same object while + * the selection does. */ +const compared = new WeakMap(); + +/** The memoised comparison for `frames`, or undefined if it has never been + * walked. Lets a caller render an already-walked selection without showing a + * placeholder first. */ +export function cachedAggregateVariables(frames: object): AggregateVariables | undefined { + return compared.get(frames); +} + +/** + * {@link compareFrames} memoised on the `frames` array's identity. An abandoned + * walk is not memoised. + */ +export async function aggregateVariablesFor( + store: LogStore, + frames: readonly number[], + index: VariableIndex | null, + options: FrameBudgetOptions, +): Promise { + const held = compared.get(frames); + if (held) { + return held; + } + const spread = await compareFrames(store, frames, index, options); + if (spread) { + compared.set(frames, spread); + } + return spread; +} diff --git a/log-viewer/src/core/log/eventKeys.ts b/log-viewer/src/core/log/eventKeys.ts index b91177f8c..662e89630 100644 --- a/log-viewer/src/core/log/eventKeys.ts +++ b/log-viewer/src/core/log/eventKeys.ts @@ -22,3 +22,15 @@ export function getEventKey(event: LogEvent): string { export function getStackKey(event: LogEvent): string { return `${event.namespace}|${event.text}`; } + +/** + * The bucket keys from `event` out to its outermost frame, innermost first. The + * log root heads no row in any view, so the walk stops below it. + */ +export function eventKeyChain(event: LogEvent): string[] { + const keys: string[] = []; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + keys.push(getEventKey(node)); + } + return keys; +} diff --git a/log-viewer/src/core/log/frameVariables.ts b/log-viewer/src/core/log/frameVariables.ts new file mode 100644 index 000000000..ff29bba36 --- /dev/null +++ b/log-viewer/src/core/log/frameVariables.ts @@ -0,0 +1,1122 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LOG_LEVEL, type ApexLog, type LogEvent } from 'apex-log-parser'; + +import { + CHECK_EVERY, + frameBudget, + type FrameBudgetOptions, + type Tick, +} from '../utility/FrameBudget.js'; +import type { LogStore, Stack } from './LogStore.js'; +import { + bareAddress, + bareAddressOf, + classOf, + isFieldName, + isStaticName, + parseVariableScope, + nestedAddressesOf, + parseVariableWrite, + reportedAddressOf, + shortName, + variableNameOf, +} from './variableLine.js'; + +/** + * What Apex could reach from a frame, read from what the log recorded. + * + * Everything in scope, not only what the frame itself wrote: + * + * - **Local** — the frame's own writes, plus the locals it declared and never + * assigned, which are in scope at their default. + * - **this** — the fields of the instance, gathered from every frame on the + * stack that shares the frame's class, since those share its `this`. + * - **Static** — every static assigned anywhere by this point, plus those + * declared and never assigned. A static lives for the transaction. + * + * A calling frame's *locals* stay out: they are on the stack but out of scope + * from a called method. + * + * Every value is the last write at or before the frame's cut point, so it reads + * as the frame saw it. + */ + +const ASSIGNMENT = 'VARIABLE_ASSIGNMENT'; +const SCOPE_BEGIN = 'VARIABLE_SCOPE_BEGIN'; +const CONSTRUCTOR_ENTRY = 'CONSTRUCTOR_ENTRY'; + +/** + * Writes held per static name before the oldest is dropped, so a static + * reassigned in a long loop cannot grow the index without bound. + * + * Dropping the *oldest* keeps a late frame's reading correct, which is the + * common case; a frame whose cut falls before every retained write for that + * name reads it as unassigned, which is the honest answer once its history is + * gone. A real log holds at most a few thousand static lines in total, so this + * is headroom, not a working limit. + */ +const MAX_WRITES_PER_STATIC = 10_000; + +/** Distinct static names held. A codebase compiles a bounded number of static + * fields, so this only bites a pathological log. */ +const MAX_STATIC_NAMES = 50_000; + +/** Writes held per field of an object, dropping the oldest, as + * {@link MAX_WRITES_PER_STATIC} does for a static. Per field, not per object: + * one field written in a long loop must not evict the rest of the object. */ +const MAX_WRITES_PER_FIELD = 2_000; + +/** Distinct objects whose fields are held. */ +const MAX_FIELD_OWNERS = 100_000; + +/** One variable, as it stood at the frame. */ +export interface VariableRow { + /** What the row is called: bare for a local, the field name under `this`, the + * field name under its class for a static. */ + name: string; + /** The value exactly as the log wrote it, empty where it wrote none. */ + value: string; + declaredType: string | null; + /** Where the log said the value lives, where it wrote an address in place of a + * value. Resolved by {@link VariableIndex.addressState}, never here. */ + address: string | null; + /** False where the log declared the name and never wrote it: it is in scope at + * its default, and the log records no value for it. */ + assigned: boolean; + /** The address of the object this row's value *is*, where the log named one. + * A `this.field` line reports the object the field belongs to instead, so it + * contributes none. */ + objectAddress: string | null; +} + +/** What a whole-log walk takes. + * + * Not the full {@link FrameBudgetOptions}: the walk cannot be abandoned, so it + * must not advertise a signal it would ignore. */ +type WalkOptions = Pick; + +/** What the log holds for one address, as a frame stood. */ +export interface AddressState { + /** The value the log wrote for it at or before the frame, or null. */ + text: string | null; + /** Where the log first describes the object after the frame, so the reader can + * be sent to it. Null where the log describes it nowhere. */ + laterAt: number | null; +} + +/** The log holds nothing for the address, at any point. */ +export const NOT_RECORDED: AddressState = { text: null, laterAt: null }; + +/** One class's statics, since every static the log names is class-qualified. */ +export interface StaticClass { + className: string; + rows: VariableRow[]; +} + +export interface FrameVariables { + /** The frame these belong to, as the call tree names it. */ + frameLabel: string; + /** The point on the eventIndex axis these were read at, so a reader resolving + * an address asks for the value as it stood here. */ + cut: number; + /** The class owning the fields, where the frame's own name gives it. */ + thisType: string | null; + locals: VariableRow[]; + /** The object the frame runs on, where the log wrote it. Its own row, not a + * local: `this` is the instance, and the fields below it are its parts. */ + thisRow: VariableRow | null; + fields: VariableRow[]; + statics: StaticClass[]; + /** The frame or one of its callers ran past the end of a truncated log, so a + * missing write may be unrecorded rather than absent. */ + truncated: boolean; +} + +/** A declaration the log made, and where it made it. */ +interface Declared { + declaredType: string; + eventIndex: number; +} + +/** The Apex Code log level this log was captured at, or null if it says none. */ +export function apexCodeLevel(log: ApexLog): string | null { + return log.debugLevels.find((level) => level.logCategory === 'APEX_CODE')?.logLevel ?? null; +} + +/** True where the log was captured at the only level that records variables. */ +export function recordsVariables(log: ApexLog): boolean { + return apexCodeLevel(log) === LOG_LEVEL.Finest; +} + +/** + * What the log says about variables, indexed so any frame is answered by a + * search rather than a walk. + * + * Holds the two things the log only gives up at log scope: + * + * - **Statics.** A static lives for the whole transaction and is visible + * wherever its class is. A local cannot be indexed the same way: the log never + * emits `VARIABLE_SCOPE_END`, so two locals of one name in different blocks + * cannot be told apart outside their own frame. + * - **Addresses.** Where a value would not serialise the log writes a bare + * address, and reports that same address beside a real value elsewhere. Nearly + * every bare address resolves that way, which turns unreadable hex into the + * object. + * A `this.field` line is no such witness: its reported address is the object + * the field belongs to, not the value the line wrote. + * - **An object's fields.** That same owner address groups the writes to one + * object's fields, wherever they were made. A frame's own stack cannot see a + * constructor that has already returned; the index can. + */ +export class VariableIndex { + /** The log recorded at least one write, so an empty answer means an empty + * scope rather than a log that records nothing. */ + readonly sawAnyWrite: boolean; + /** A name or an object went unrecorded past its cap, so an answer may be + * missing something the log did record. */ + readonly capped: boolean; + + private readonly _writes: Map; + private readonly _declared: Map; + private readonly _byAddress: Map; + private readonly _classes: Map; + private readonly _fieldsByOwner: Map>; + + private constructor( + writes: Map, + declared: Map, + byAddress: Map, + classes: Map, + fieldsByOwner: Map>, + sawAnyWrite: boolean, + capped: boolean, + ) { + this._writes = writes; + this._declared = declared; + this._byAddress = byAddress; + this._classes = classes; + this._fieldsByOwner = fieldsByOwner; + this.sawAnyWrite = sawAnyWrite; + this.capped = capped; + } + + /** Reads the log, yielding between slices so the UI keeps its frames. */ + static async build(log: ApexLog, options: WalkOptions): Promise { + const tick = frameBudget(options); + const writes = new Map(); + const declared = new Map(); + // Only the addresses a value is ever written *as*. Every assignment reports + // an address, so indexing all of them would hold a quarter of a million + // events to answer about two thousand. + const wanted = new Set(); + // Address to the class of the object living there. A frame declares its own + // object as `this`, so a `this` write names the class the address holds. + const classes = new Map(); + // The class a frame's own `VARIABLE_SCOPE_BEGIN|this|…` declared, keyed by + // that frame. Walk-wide and keyed by name alone would leak: `this` is not + // redeclared on every call, so a frame with no declaration of its own would + // otherwise borrow whichever class was declared last, anywhere in the log. + const thisClassOf = new Map(); + // Owner address to that object's fields, each name to its own writes. + const fieldsByOwner = new Map>(); + let sawAnyWrite = false; + let dropped = false; + + await eachEvent(log, tick, (event) => { + if (event.type === ASSIGNMENT) { + sawAnyWrite = true; + // The name only: the value is read when a row renders it. + const name = variableNameOf(event.logLine); + if (name && isStaticName(name)) { + if (writes.has(name) || writes.size < MAX_STATIC_NAMES) { + pushCapped(writes, name, event, MAX_WRITES_PER_STATIC); + } else { + dropped = true; + } + } + // The trailing address on a field write is the object the field belongs + // to, so it gathers that object's fields however far apart they were set. + const owner = name && isFieldName(name) ? reportedAddressOf(event.logLine) : null; + if (owner && name) { + let held = fieldsByOwner.get(owner); + if (!held && fieldsByOwner.size < MAX_FIELD_OWNERS) { + held = new Map(); + fieldsByOwner.set(owner, held); + } + if (held) { + pushCapped(held, name, event, MAX_WRITES_PER_FIELD); + } else { + dropped = true; + } + } + if (name === 'this') { + const frame = event.parent; + const thisClass = frame && thisClassOf.get(frame); + const object = thisClass && reportedAddressOf(event.logLine); + if (frame && object) { + keepClass(classes, object, thisClass, { + at: event.eventIndex, + until: lastDescendantIndex(frame), + constructed: frame.type === CONSTRUCTOR_ENTRY, + }); + } + } + const address = bareAddressOf(event.logLine); + if (address) { + wanted.add(address); + } else { + // An address named inside a value, such as a field holding a + // reference. + for (const nested of nestedAddressesOf(event.logLine)) { + wanted.add(nested); + } + } + } else if (event.type === SCOPE_BEGIN) { + const scope = parseVariableScope(event.logLine); + // The declared type of `this` is the concrete class, where the value's + // own declared type is often only an interface. + if (scope?.name === 'this' && scope.declaredType && event.parent) { + thisClassOf.set(event.parent, scope.declaredType); + } + if (scope?.isStatic) { + const first = declared.get(scope.name); + // The earliest declaration: the point from which it is in scope. + if (!first || first.eventIndex > event.eventIndex) { + declared.set(scope.name, { + declaredType: scope.declaredType, + eventIndex: event.eventIndex, + }); + } + } + } + }); + + const byAddress = new Map(); + // A second read, and only where the first found an address to resolve. + if (wanted.size) { + await eachEvent(log, tick, (event) => { + if (event.type !== ASSIGNMENT) { + return; + } + // The reported address names the value only where the variable is that + // value. A `this.field` line reports the *owner*, so one such address + // usually carries two or more different values, and indexing it would + // answer about an object with one of its fields. + const name = variableNameOf(event.logLine); + if (!name || isFieldName(name)) { + return; + } + const address = reportedAddressOf(event.logLine); + // A line whose value *is* the address tells us nothing about it. + if (address && wanted.has(address) && !bareAddressOf(event.logLine)) { + push(byAddress, address, event); + } + }); + } + + // Every read below searches by eventIndex, so every list has to be in it. + const byIndex = (left: LogEvent, right: LogEvent): number => left.eventIndex - right.eventIndex; + for (const found of [...writes.values(), ...byAddress.values()]) { + found.sort(byIndex); + } + for (const fields of fieldsByOwner.values()) { + for (const found of fields.values()) { + found.sort(byIndex); + } + } + return new VariableIndex( + writes, + declared, + byAddress, + classes, + fieldsByOwner, + sawAnyWrite, + dropped, + ); + } + + /** + * Every static in scope at `cut`, grouped by class, both sorted. + * + * A static declared by this point but never assigned is in scope at its + * default, so it is listed without a value rather than left out. + */ + at(cut: number): StaticClass[] { + const byClass = new Map(); + const listed = new Set(); + const add = (name: string, row: VariableRow): void => { + listed.add(name); + push(byClass, classOf(name) ?? '', row); + }; + + for (const [name, writes] of this._writes) { + const last = lastAtOrBefore(writes, cut); + if (last) { + add( + name, + rowFor(shortName(name), last, this._declared.get(name), reportedAddressOf(last.logLine)), + ); + } + } + for (const [name, declared] of this._declared) { + if (!listed.has(name) && declared.eventIndex <= cut) { + add(name, unassignedRow(shortName(name), declared)); + } + } + return [...byClass] + .map(([className, rows]) => ({ className, rows: rows.sort(byName) })) + .sort((left, right) => left.className.localeCompare(right.className)); + } + + /** + * What the log holds for `address` as the frame stood. + * + * The address is only the identity the runtime printed for the reference. The + * object's contents are a separate event, and reach the log only where Apex + * assigned that object to a variable and could serialise it. So a log may hold + * the contents from before the frame, from after it, or not at all. + */ + addressState(address: string, cut: number): AddressState { + const writes = this._byAddress.get(address); + if (!writes?.length) { + return NOT_RECORDED; + } + const last = lastAtOrBefore(writes, cut); + return last + ? { text: parseVariableWrite(last.logLine)?.value ?? null, laterAt: null } + : { text: null, laterAt: writes[0]?.eventIndex ?? null }; + } + + /** + * The class of the object at `address` as the frame stood, or null where the + * log names none. + * + * More telling than the declared type: an interface-typed variable holding a + * `WithoutSharing` reads as `fflib_IDatabaseWriter` in its declaration, and + * the log records the implementation only on that object's own frame. + */ + classAt(address: string, cut: number): string | null { + return this._runAt(address, cut)?.run.className ?? null; + } + + /** The class run covering `cut`, and how many objects the log saw at this + * address before it. One reader of the runs, so the class a row names and the + * fields it opens on cannot disagree about which object lived here. */ + private _runAt(address: string, cut: number): { run: ClassAt; before: number } | null { + const seen = this._classes.get(address); + if (!seen) { + return null; + } + const after = firstIndexWhere(seen.length, (index) => seen[index]!.at > cut); + return after ? { run: seen[after - 1]!, before: after - 1 } : null; + } + + /** + * The fields the log recorded for the object at `address`, as the frame stood. + * + * This is what lets a value the log wrote as `{}` open: the object's own line + * carries no contents, but the writes to its fields are lines of their own. + */ + fieldsAt(address: string, cut: number): VariableRow[] { + return fieldRowsOf(this.fieldWritesAt(address, cut)); + } + + /** {@link fieldsAt} as the writes behind it, for a caller merging them with + * writes of its own. */ + fieldWritesAt(address: string, cut: number): Map { + const found = new Map(); + const held = this._fieldsByOwner.get(address); + if (!held) { + return found; + } + const from = this._objectFrom(address, cut); + for (const [name, writes] of held) { + const last = lastAtOrBefore(writes, cut); + // Before this object's own history: the field belonged to the object that + // used this address before it. + if (last && last.eventIndex >= from) { + found.set(name, last); + } + } + return found; + } + + /** + * Where this object's own history starts, for an address the log has seen hold + * more than one object. + * + * Zero where it has not: a field write can precede the `this` write that names + * the class, so bounding every object at its own run would drop it. + */ + private _objectFrom(address: string, cut: number): number { + const found = this._runAt(address, cut); + return found && found.before > 0 ? found.run.at : 0; + } + + /** + * The lookups a reader needs at one point in the log, bound to that point. + * + * An object's fields are read for every row that shows it, and every row is + * built again whenever anything opens, so a read is held rather than repeated. + */ + viewAt(cut: number): IndexView { + const fields = new Map(); + return { + resolve: (address) => this.addressState(address, cut), + classOf: (address) => this.classAt(address, cut), + fields: (address) => { + let held = fields.get(address); + if (!held) { + held = this.fieldsAt(address, cut); + fields.set(address, held); + } + return held; + }, + }; + } +} + +/** What a reader asks the log about an address, bound to one point in it. */ +export interface IndexView { + resolve(address: string): AddressState; + classOf(address: string): string | null; + fields(address: string): readonly VariableRow[]; +} + +/** What a caller wants left out of a read. */ +export interface FrameReadOptions { + /** False to leave the statics unread. {@link VariableIndex.at} walks every + * static class the log holds, which is the bulk of a read, so a caller that + * does not compare statics must not pay for them. */ + statics?: boolean; + /** False to leave the rows in the order they were read. A caller that groups + * every row by name throws the order away, and `localeCompare` over the + * locals of 35,000 calls is 55ms of it. */ + sorted?: boolean; +} + +/** + * What is in scope at `eventIndex`, or null where the log has no such event or + * it sits in no frame. + * + * Pass `index` as null to answer the frame alone, before the index is built. + */ +export function frameVariablesFor( + store: LogStore, + eventIndex: number, + index: VariableIndex | null, + options: FrameReadOptions = {}, +): FrameVariables | null { + const selected = store.eventByIndex(eventIndex); + const stack = store.stackByEventIndex(eventIndex); + const frame = stack[stack.length - 1]; + if (!selected || !frame) { + return null; + } + + // The cut is on the eventIndex axis, never on a timestamp: real logs repeat + // timestamps, so they order nothing. A frame reads as it finished, an event + // inside one as the log reached it. + const cut = selected.isParent ? lastDescendantIndex(selected) : selected.eventIndex; + // Not always the frame the selection sits in: see `scopeFrame`. + const { frame: scope, scan: own } = scopeFrame(stack, cut, frame); + const thisType = frameClassOf(scope); + + const locals: VariableRow[] = []; + let thisRow: VariableRow | null = null; + for (const [name, write] of own.writes) { + // A static assigned here is still a static, and the index answers for it. + if (name !== 'this' && name.includes('.')) { + continue; + } + const row = rowFor(name, write, own.declared.get(name), reportedAddressOf(write.logLine)); + // `this` is the instance the frame runs on, so it heads its own group. + if (name === 'this') { + thisRow = row; + } else { + locals.push(row); + } + } + for (const [name, declared] of own.declared) { + if (name !== 'this' && !own.writes.has(name)) { + locals.push(unassignedRow(name, declared)); + } + } + + // Two sources, one rule: the latest write to a field wins. The index finds + // every write against the frame's object wherever it was made, so a field a + // returned constructor set is in scope here; the stack walk finds the writes + // whose line reported no address, which the index never sees. + // + // Every field of the object, not only the ones the frame's own class wrote: a + // base method reads fields a base constructor set, and the log names no class + // hierarchy to tell an inherited field from a subclass's own. So a base frame + // can list a field only the subclass declares. Filtering by the writing + // frame's class would lose the inherited case, which is the common one. + const object = thisAddressOf(own.writes); + const found = fieldWrites(stack, scope, own, thisType, cut); + if (object && index) { + for (const [name, write] of index.fieldWritesAt(object, cut)) { + const held = found.get(name); + if (!held || held.eventIndex < write.eventIndex) { + found.set(name, write); + } + } + } + const fields = fieldRowsOf(found, options.sorted !== false); + + return { + frameLabel: scope.text, + cut, + thisType, + locals: options.sorted === false ? locals : locals.sort(byName), + thisRow, + fields, + statics: options.statics === false ? [] : (index?.at(cut) ?? []), + truncated: stack.some((entry) => entry.isTruncated) || selected.isTruncated, + }; +} + +const indexes = new WeakMap(); +const building = new WeakMap>(); + +/** + * The variable index for `log`, read once and then shared. + * + * Built on the first ask rather than at load: a 100MB log must not pay for a + * section nobody opened. + */ +export function variableIndexFor(log: ApexLog, options: WalkOptions = {}): Promise { + const held = indexes.get(log); + if (held) { + return Promise.resolve(held); + } + let inFlight = building.get(log); + if (!inFlight) { + inFlight = VariableIndex.build(log, options) + .then((index) => { + indexes.set(log, index); + return index; + }) + // A failed build must not be cached, or nothing would ever retry. + .finally(() => building.delete(log)); + building.set(log, inFlight); + } + return inFlight; +} + +/** What one frame wrote and declared, up to `cut`. */ +interface FrameScan { + /** Name to its last write at or before the cut. */ + writes: Map; + /** Name to its first declaration, for the locals the frame never assigned. */ + declared: Map; + /** The frame recorded a variable line of its own, so it owns a scope even + * where every line was a static the index answers for. */ + sawAny: boolean; +} + +/** One variable line of a frame, with what it names read once. */ +interface FrameLine { + event: LogEvent; + /** The name the line names: the variable written, or the scope declared. Null + * where the line parses none. */ + name: string | null; + /** The local declaration the line made, or null where it wrote a value or + * declared a static, which is in scope everywhere and lives in the index. */ + declared: Declared | null; +} + +/** + * A frame's own variable lines, in eventIndex order, held per frame. + * + * Every read back through a frame parses the same names, and a caller frame is + * read again for every call an aggregate compares: 4,000 calls under one method + * re-parsed its lines 4,000 times. + */ +const frameLines = new WeakMap(); + +/** Which frames have been asked about, for {@link askedBefore}. */ +const linesAsked = new WeakSet(); +const objectsAsked = new WeakSet(); + +/** + * Whether `frame` has been asked about before, recording the ask either way. + * + * What decides whether a memo of the whole frame is worth building. A frame + * asked about once is read for its own sake: only the lines before the cut + * answer, so parsing the rest costs time an early selection in a 500k-child + * frame cannot spare, and holding them is pure retention - 166 bytes a line, + * 83MB on a 500k-line log. A frame asked about twice is one several calls + * escalate to ({@link scopeFrame}), which is what earns the memo. + */ +function askedBefore(asked: WeakSet, frame: LogEvent): boolean { + if (asked.has(frame)) { + return true; + } + asked.add(frame); + return false; +} + +function readFrameLines(frame: LogEvent): FrameLine[] { + const lines: FrameLine[] = []; + for (const child of frame.children) { + if (child.type === ASSIGNMENT) { + lines.push({ event: child, name: variableNameOf(child.logLine), declared: null }); + } else if (child.type === SCOPE_BEGIN) { + const scope = parseVariableScope(child.logLine); + lines.push({ + event: child, + name: scope?.name ?? null, + declared: + scope && !scope.isStatic + ? { declaredType: scope.declaredType, eventIndex: child.eventIndex } + : null, + }); + } + } + return lines; +} + +/** Where a backwards read of `lines` starts to answer at `cut`. */ +function linesBefore(lines: readonly { event: LogEvent }[], cut: number): number { + return firstIndexWhere(lines.length, (at) => lines[at]!.event.eventIndex > cut); +} + +/** One write to the object a frame runs on. */ +interface ObjectWrite { + name: string; + event: LogEvent; +} + +/** + * A frame's writes to its own object, in eventIndex order, held per frame. + * + * Held apart from {@link frameLines}: a caller frame answers only for its + * object, so a frame holding a hundred thousand locals holds a handful of + * these, and going through the line list would hold all hundred thousand to + * answer about two - 1MB a frame, for the log's lifetime. + */ +const objectWrites = new WeakMap(); + +function readObjectWrites(frame: LogEvent): ObjectWrite[] { + const found: ObjectWrite[] = []; + for (const child of frame.children) { + if (child.type !== ASSIGNMENT) { + continue; + } + const name = variableNameOf(child.logLine); + if (name && (name === 'this' || isFieldName(name))) { + found.push({ name, event: child }); + } + } + return found; +} + +/** The class from a frame's own name, held per frame: an aggregate asks it of + * every frame on the stack, for every call it compares. */ +const frameClasses = new WeakMap(); + +function frameClassOf(frame: LogEvent): string | null { + let held = frameClasses.get(frame); + if (held === undefined) { + held = classFromFrame(frame.text); + frameClasses.set(frame, held); + } + return held; +} + +/** + * Reads one frame's own lines back from `cut`. + * + * Backwards, so the first write seen for a name is the last one the frame made. + * It reads names only: a value is parsed for the handful of rows that win, not + * for every line, which matters in a frame holding a hundred thousand of them. + */ +function scanFrame(frame: LogEvent, cut: number): FrameScan { + const held = frameLines.get(frame); + if (held) { + return scanLines(held, cut); + } + if (!askedBefore(linesAsked, frame)) { + return scanChildren(frame, cut); + } + const lines = readFrameLines(frame); + frameLines.set(frame, lines); + return scanLines(lines, cut); +} + +/** A frame's scope from its held lines. */ +function scanLines(lines: readonly FrameLine[], cut: number): FrameScan { + const writes = new Map(); + const declared = new Map(); + const from = linesBefore(lines, cut); + for (let at = from; at--;) { + const line = lines[at]!; + if (line.event.type === ASSIGNMENT) { + if (line.name && !writes.has(line.name)) { + writes.set(line.name, line.event); + } + } else if (line.declared && line.name) { + // Backwards, so the earliest declaration is the one left standing: the + // point from which the name is in scope. + declared.set(line.name, line.declared); + } + } + // A line of its own before the cut, whatever it said, so the frame owns a + // scope even where every line was a static the index answers for. + return { writes, declared, sawAny: from > 0 }; +} + +/** A frame's scope read straight from its children, parsing only the lines + * before the cut: what a frame asked about once is owed. */ +function scanChildren(frame: LogEvent, cut: number): FrameScan { + const writes = new Map(); + const declared = new Map(); + let sawAny = false; + const children = frame.children; + for (let at = firstIndexWhere(children.length, (i) => children[i]!.eventIndex > cut); at--;) { + const child = children[at]!; + if (child.type === ASSIGNMENT) { + sawAny = true; + const name = variableNameOf(child.logLine); + if (name && !writes.has(name)) { + writes.set(name, child); + } + } else if (child.type === SCOPE_BEGIN) { + sawAny = true; + const scope = parseVariableScope(child.logLine); + // Only a local: a static is in scope everywhere, so the index holds it. + if (scope && !scope.isStatic) { + declared.set(scope.name, { + declaredType: scope.declaredType, + eventIndex: child.eventIndex, + }); + } + } + } + return { writes, declared, sawAny }; +} + +/** Only the writes that answer for the object a frame runs on. + * + * For a caller frame, whose locals are out of scope and whose declarations are + * never read: a frame can hold hundreds of thousands of children, so the rest + * is thrown away. */ +function thisWritesOf(frame: LogEvent, cut: number): Map { + const held = objectWrites.get(frame); + if (!held && !askedBefore(objectsAsked, frame)) { + return lastWritesByName(frame.children, cut); + } + const writes = held ?? readObjectWrites(frame); + if (!held) { + objectWrites.set(frame, writes); + } + const found = new Map(); + for (let at = linesBefore(writes, cut); at--;) { + const { event, name } = writes[at]!; + if (!found.has(name)) { + found.set(name, event); + } + } + return found; +} + +/** The last write to each of the object's names at or before `cut`, read + * straight from the children: what a frame asked about once is owed. + * + * Backwards from the cut, so the first write seen for a name is the last made. */ +function lastWritesByName(events: readonly LogEvent[], cut: number): Map { + const found = new Map(); + for (let at = firstIndexWhere(events.length, (i) => events[i]!.eventIndex > cut); at--;) { + const event = events[at]!; + const name = event.type === ASSIGNMENT ? variableNameOf(event.logLine) : null; + if (name && (name === 'this' || isFieldName(name)) && !found.has(name)) { + found.set(name, event); + } + } + return found; +} + +/** Field writes as their rows, carrying the `objectAddress` a field row must + * always have: its line reports the owner, never its own value. */ +function fieldRowsOf(writes: ReadonlyMap, sorted = true): VariableRow[] { + const rows: VariableRow[] = []; + for (const [name, write] of writes) { + rows.push(rowFor(shortName(name), write, undefined, null)); + } + return sorted ? rows.sort(byName) : rows; +} + +/** + * The frame whose locals are in scope at the selection. + * + * Not always the frame the selection sits in. A SOQL statement, a + * `STATEMENT_EXECUTE` and a system call such as `Database.query(String)` are not + * Apex code with locals of their own: they are issued *by* Apex code, and + * answering from them would report an empty scope for a query built two lines + * above it. + * + * So the nearest frame up the stack recording a variable of its own owns the + * scope. The group names that frame, so which scope answered is never a guess. + */ +function scopeFrame(stack: Stack, cut: number, innermost: LogEvent): Scope { + for (let at = stack.length; at--;) { + const frame = stack[at]; + if (frame) { + const scan = scanFrame(frame, cut); + if (scan.sawAny) { + return { frame, scan }; + } + } + } + return { frame: innermost, scan: scanFrame(innermost, cut) }; +} + +/** The frame that owns the scope, and the read that found it. */ +interface Scope { + frame: LogEvent; + scan: FrameScan; +} + +/** + * The instance fields in scope, from every frame on the stack running on the + * same object. + * + * A method of class X has a `this` of class X, and a method it calls on the same + * instance sees the same fields. So a field the constructor set and this frame + * never touched is still in scope, and reading only this frame's own writes + * would leave it out. + * + * The class alone does not settle it: a frame can run with another *instance* of + * its own class on the stack, and merging those would + * report one object's fields as another's. So where both frames name their + * object, the addresses decide. + */ +function fieldWrites( + stack: Stack, + frame: LogEvent, + own: FrameScan, + thisType: string | null, + cut: number, +): Map { + const found = new Map(); + const mine = thisAddressOf(own.writes); + for (const entry of stack) { + if (frameClassOf(entry) !== thisType) { + continue; + } + const writes = entry === frame ? own.writes : thisWritesOf(entry, cut); + const theirs = thisAddressOf(writes); + if (mine && theirs && mine !== theirs) { + continue; + } + for (const [name, write] of writes) { + if (!isFieldName(name)) { + continue; + } + const held = found.get(name); + // The latest write wins, whichever frame on the stack made it. + if (!held || held.eventIndex < write.eventIndex) { + found.set(name, write); + } + } + } + return found; +} + +/** + * The object a frame is running on, or null where the log never named it. + * + * Both a `this` write and a `this.field` write report the owning object's + * address, so either answers. + */ +function thisAddressOf(writes: ReadonlyMap): string | null { + for (const [name, write] of writes) { + if (name === 'this' || isFieldName(name)) { + const address = reportedAddressOf(write.logLine); + if (address) { + return address; + } + } + } + return null; +} + +/** + * One row, from a write the caller already knows the shape of. + * + * `objectAddress` is the caller's call: a `this.field` line reports the field's + * *owner*, never the field's own value, so a field row must always be built + * with `null`. Deciding this inside `rowFor` from the row's own `name` was the + * bug behind #373 review finding 1 — by the time a field's name reaches here it + * has already been shortened to drop the `this.` that the decision needed. + */ +function rowFor( + name: string, + write: LogEvent, + declared: Declared | undefined, + objectAddress: string | null, +): VariableRow { + const value = parseVariableWrite(write.logLine)?.value ?? ''; + return { + name, + value, + declaredType: declared?.declaredType ?? null, + address: bareAddress(value), + assigned: true, + objectAddress, + }; +} + +/** One address, and the class of the object that lived there from `at` until the + * end of the frame that named it. */ +interface ClassAt { + at: number; + until: number; + className: string; +} + +/** Where a `this` write named its class, and whether it was a construction. */ +interface NamedAt { + at: number; + until: number; + constructed: boolean; +} + +/** + * Records the class of the object at an address. + * + * Two rules keep the answer the object's own class rather than an ancestor: + * + * - a write inside a run already recorded adds nothing. A superclass + * constructor runs on the same object, inside the subclass constructor, and + * would otherwise overwrite the concrete class with its parent; + * - only a construction may start a new run. A method declares `this` as the + * type it was compiled against, which can be an ancestor, so it names the + * class only for an object constructed outside the log. + * + * An address is reused once its object is collected, hence runs rather than one + * class per address. A construction past the last run is always a new object, + * even of the same class: merging those two runs would let a collected object's + * fields read as the new one's. + */ +function keepClass( + classes: Map, + address: string, + className: string, + named: NamedAt, +): void { + const run = { at: named.at, until: named.until, className }; + const seen = classes.get(address); + if (!seen) { + classes.set(address, [run]); + return; + } + const last = seen[seen.length - 1]!; + if (named.at <= last.until || !named.constructed) { + return; + } + seen.push(run); +} + +/** A name the log declared and never wrote: in scope, at its default. */ +function unassignedRow(name: string, declared: Declared): VariableRow { + return { + name, + value: '', + declaredType: declared.declaredType, + address: null, + assigned: false, + objectAddress: null, + }; +} + +/** Visits every event below `log`, handing the frame back between slices. */ +async function eachEvent( + log: ApexLog, + tick: Tick, + visit: (event: LogEvent) => void, +): Promise { + const stack = [...log.children].reverse(); + for (let walked = 0; stack.length; walked++) { + if (walked % CHECK_EVERY === 0) { + await tick(); + } + const event = stack.pop()!; // non-empty: the loop condition just checked + visit(event); + // Pushed back to front, so popping hands them over in log order: a line's + // meaning can depend on one above it, such as a declaration before a write. + for (let at = event.children.length; at--;) { + stack.push(event.children[at]!); + } + } +} + +function push(into: Map, key: string, value: T): void { + const found = into.get(key); + if (found) { + found.push(value); + } else { + into.set(key, [value]); + } +} + +/** {@link push}, dropping the oldest once a name holds more than `cap`. + * + * Trims to `cap` only once the array reaches double it, so the cost of + * dropping amortises to O(1) a write rather than paying an array shift on + * every one past the cap. */ +function pushCapped(into: Map, key: string, value: T, cap: number): void { + push(into, key, value); + const found = into.get(key)!; + if (found.length > cap * 2) { + found.splice(0, found.length - cap); + } +} + +/** The highest eventIndex in `event`'s subtree: where the frame finished. */ +function lastDescendantIndex(event: LogEvent): number { + let node = event; + // Children are appended in log order, so the last one holds the last index. + while (node.children.length) { + node = node.children[node.children.length - 1]!; + } + return node.eventIndex; +} + +/** The class from a frame's own name, or null where its name gives none. */ +function classFromFrame(label: string): string | null { + const paren = label.indexOf('('); + const call = (paren < 0 ? label : label.slice(0, paren)).trim(); + const lastDot = call.lastIndexOf('.'); + return lastDot > 0 ? call.slice(0, lastDot) : null; +} + +/** The last write at or before `cut`, or null where every write came after. */ +function lastAtOrBefore(writes: readonly LogEvent[], cut: number): LogEvent | null { + const after = firstIndexWhere(writes.length, (index) => writes[index]!.eventIndex > cut); + return after > 0 ? (writes[after - 1] ?? null) : null; +} + +/** The leftmost index below `length` where `holds` becomes true, or `length` if + * it never does. `holds` must be false then true across the run. */ +function firstIndexWhere(length: number, holds: (index: number) => boolean): number { + let low = 0; + let high = length; + while (low < high) { + const mid = (low + high) >>> 1; + if (holds(mid)) { + high = mid; + } else { + low = mid + 1; + } + } + return low; +} + +function byName(left: VariableRow, right: VariableRow): number { + return left.name.localeCompare(right.name); +} diff --git a/log-viewer/src/core/log/keyPathIds.ts b/log-viewer/src/core/log/keyPathIds.ts index 5779e08a9..c37d9e85b 100644 --- a/log-viewer/src/core/log/keyPathIds.ts +++ b/log-viewer/src/core/log/keyPathIds.ts @@ -1,12 +1,15 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import type { LogEvent } from 'apex-log-parser'; + +import { getEventKey, getStackKey } from './eventKeys.js'; /** The path every chain starts from, which no row stands for. */ export const ROOT_PATH_ID = 0; /** - * The bucket paths of one log, each interned to an integer. + * The interned keys and bucket paths of one log. * * A row in a view whose rows merge occurrences is named by the bucket keys that * reach it, since one method holds a row under every caller it has. Joining @@ -14,48 +17,150 @@ export const ROOT_PATH_ID = 0; * cost of a mark. An id per distinct path bounds the table by the tree rather * than by the calls, and makes matching an integer test. * - * Every caller depends on one invariant: a row's id is the interned chain of the - * frames the row holds. Compose ids through {@link pathOf} or - * {@link prefixesOf} rather than folding {@link pathId} by hand, since the two - * directions are separate spaces and an id from one means nothing in the other. + * One invariant holds it together: a row's id is the interned chain of the + * frames the row holds. {@link pathIdOf} and {@link pathsEndingIn} name the rows + * a frame stands for, one direction each, and {@link chainNodeAt} reads a row's + * own frame back out of a chain. * * One table per log, held by `LogStore`: an id means nothing to another log. */ export class KeyPathIds { private keyIds = new Map(); private keys: string[] = []; + /** Each event's bucket key, as `id + 1` so an unset slot reads as 0. */ + private keyOfEvent: Int32Array; + private stackIds = new Map(); + /** Each bucket key's stack key. A bucket key holds the stack key, so this is + * per signature rather than per event. */ + private stackOfKey: number[] = []; // Indexed by path id: the paths reachable from it, its own parent, and the key // it was minted with. Index 0 is the empty path. private children: Array | undefined> = [new Map()]; private parents: number[] = [ROOT_PATH_ID]; private keyOf: number[] = [-1]; + /** The paths each key heads, which is how a frame finds the rows it stands + * for. Filled as paths are minted, so the answer costs what it holds. */ + private pathsByKey: number[][] = []; + /** One frame's chain, reused: {@link pathIdOf} never yields, so one is enough. + * Per table rather than per module, so two logs cannot share the buffer. */ + private chain: number[] = []; + + constructor(eventCount: number) { + this.keyOfEvent = new Int32Array(eventCount); + } /** - * The id for the path that reaches `key` through `parentPathId`, minted on - * first use. - * - * @param parentPathId - {@link ROOT_PATH_ID} for the outermost key of a chain + * The event's interned bucket key, kept per event: a mark reads the same + * ancestors once per occurrence it names. */ - public pathId(parentPathId: number, key: string): number { - return this.step(parentPathId, this.keyId(key)); + public keyIdOf(event: LogEvent): number { + const at = event.eventIndex; + // A frame the log's own index has no slot for is keyed but not kept. A frame + // built rather than parsed has no index at all, and that writes an ordinary + // property on the typed array, which every other such frame reads as its own. + const slotted = at >= 0 && at < this.keyOfEvent.length; + if (slotted) { + const cached = this.keyOfEvent[at]!; + if (cached) { + return cached - 1; + } + } + const id = this.keyId(getEventKey(event)); + if (slotted) { + this.keyOfEvent[at] = id + 1; + } + return id; } /** - * The id of one bucket key, interned. A walk that steps the same key many times - * interns it once and calls {@link step}, since hashing the key is what a path - * id exists to avoid. + * The event's interned stack key, which tells a recursive call from a fresh + * one. Its own space: a stack key is never a step in a path. */ - public keyId(key: string): number { - let id = this.keyIds.get(key); + public stackIdOf(event: LogEvent, keyId = this.keyIdOf(event)): number { + let id = this.stackOfKey[keyId]; if (id === undefined) { - id = this.keys.length; - this.keyIds.set(key, id); - this.keys.push(key); + const key = getStackKey(event); + id = this.stackIds.get(key) ?? this.stackIds.size; + this.stackIds.set(key, id); + this.stackOfKey[keyId] = id; } return id; } - /** {@link pathId} for a key already interned by {@link keyId}. */ + /** + * The id naming the row a frame sits in, top-down, or undefined for the log + * root, which heads no row. + */ + public pathIdOf(event: LogEvent): number | undefined { + if (!event.parent) { + return undefined; + } + const chain = this.chain; + chain.length = 0; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + chain.push(this.keyIdOf(node)); + } + let id = ROOT_PATH_ID; + for (let depth = chain.length - 1; depth >= 0; depth--) { + id = this.step(id, chain[depth]!); + } + return id; + } + + /** + * The paths whose own key is one of `keyIds`: the rows that stand for those + * frames in a bottom-up view, wherever they sit. + * + * A bottom-up row is the frame at its own depth, so a frame heads the bucket + * for it and any caller row for it under another bucket. The chains above it + * are other frames' rows, which is why they are not here. + */ + public pathsEndingIn(keyIds: ReadonlySet): number[] { + const found: number[] = []; + for (const keyId of keyIds) { + // A path holds one key, so no path is reached twice. + const paths = this.pathsByKey[keyId]; + if (paths) { + found.push(...paths); + } + } + return found; + } + + /** + * The frame in the chain that `pathId` names, or null where the chain does not + * run through it: the caller a bottom-up row is, at the depth the row sits at. + * + * The walk that decides membership stands on that frame when it gets there, so + * a caller row's frames come out of it rather than out of a second climb. + * + * Reads without minting, unlike {@link step}: a query that grew the table would + * leave a node behind for every frame it was asked about. Ids only rise as a + * chain deepens, so the walk stops once it passes the depth asked about. + */ + public chainNodeAt(event: LogEvent, pathId: number): LogEvent | null { + let id = ROOT_PATH_ID; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + const next = this.children[id]?.get(this.keyIdOf(node)); + if (next === undefined || next > pathId) { + // Never minted, so no row stands for it; or past the row's own depth. + return null; + } + id = next; + if (id === pathId) { + return node; + } + } + return null; + } + + /** + * The id for the path that reaches an interned key through `parentPathId`, + * minted on first use. For a walk that already holds the key's id and composes + * a path as it goes. + * + * @param parentPathId - {@link ROOT_PATH_ID} for the outermost key of a chain + */ public step(parentPathId: number, keyId: number): number { const reachable = (this.children[parentPathId] ??= new Map()); let id = reachable.get(keyId); @@ -68,37 +173,25 @@ export class KeyPathIds { this.children.push(undefined); this.parents.push(parentPathId); this.keyOf.push(keyId); + (this.pathsByKey[keyId] ??= []).push(id); } return id; } - /** - * The id for a whole chain, outermost key first: what names a top-down row. - * - * @param keys - the chain innermost first, as `eventKeyChain` gives it - */ - public pathOf(keys: readonly string[]): number { - let id = ROOT_PATH_ID; - for (let depth = keys.length - 1; depth >= 0; depth--) { - id = this.pathId(id, keys[depth]!); + /** One bucket key's id, interned. */ + public keyId(key: string): number { + let id = this.keyIds.get(key); + if (id === undefined) { + id = this.keys.length; + this.keyIds.set(key, id); + this.keys.push(key); } return id; } - /** - * An id per step out along a chain: the bottom-up rows a frame heads, which are - * the frame alone, then the frame under one caller, and so on. - * - * @param keys - the chain innermost first - */ - public prefixesOf(keys: readonly string[]): number[] { - const prefixes: number[] = []; - let id = ROOT_PATH_ID; - for (const key of keys) { - id = this.pathId(id, key); - prefixes.push(id); - } - return prefixes; + /** The key an id was minted for, for a row that shows the key it merges. */ + public keyText(keyId: number): string { + return this.keys[keyId]!; } /** @@ -117,6 +210,21 @@ export class KeyPathIds { return id === pathId; } + /** The path `pathId` extends, {@link ROOT_PATH_ID} for a row of its own depth. */ + public parentOf(pathId: number): number { + return this.parents[pathId]!; + } + + /** How many keys `pathId` stands for: the depth of the row it names, 0 at the + * root. */ + public depthOf(pathId: number): number { + let depth = 0; + for (let id = pathId; id > ROOT_PATH_ID; id = this.parents[id]!) { + depth++; + } + return depth; + } + /** * The keys `pathId` stands for, outermost first. For reading a stamped id back * while debugging: nothing on a hot path calls it. diff --git a/log-viewer/src/core/log/variableLine.ts b/log-viewer/src/core/log/variableLine.ts new file mode 100644 index 000000000..211152f64 --- /dev/null +++ b/log-viewer/src/core/log/variableLine.ts @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * The fields of a variable log line, read from the raw line the parser kept. + * + * The parser models only `lineNumber` on these events and joins the rest into a + * lossy `text`, so everything here comes from `event.logLine`. + */ + +/** A write the log recorded, from a `VARIABLE_ASSIGNMENT` line. */ +export interface VariableWrite { + /** `name` for a local, `this.name` for a field, `Class.name` for a static. */ + name: string; + /** The value exactly as the log wrote it. Never parsed here. */ + value: string; + /** The heap address the log reported, where it reported one. */ + address: string | null; +} + +/** A declaration, from a `VARIABLE_SCOPE_BEGIN` line. */ +export interface VariableScope { + name: string; + declaredType: string; + isStatic: boolean; +} + +const ADDRESS = /^0x[0-9a-f]+$/i; + +/** + * Just the name from a `VARIABLE_ASSIGNMENT` line, or null where the line + * carries no name, or no value after it. + * + * For a walk of the whole log: it never touches the value, which can reach tens + * of thousands of characters. + */ +export function variableNameOf(logLine: string): string | null { + const nameStart = pipeAfter(logLine, 3); + if (nameStart < 0) { + return null; + } + const nameEnd = logLine.indexOf('|', nameStart); + // A name and no value: nothing to show, so not a write. + if (nameEnd < 0) { + return null; + } + return logLine.slice(nameStart, nameEnd).trim() || null; +} + +/** Where a line's value starts and ends, and the address that follows it. + * Null where the line carries no value at all. */ +interface ValueSpan { + start: number; + end: number; + address: string | null; +} + +/** + * The span of a `VARIABLE_ASSIGNMENT` line's value. + * + * Found by pipe *position*, never by `split('|')`, and the last field is read + * for what it is rather than assumed: + * + * - an address (`|0x7d1781a3`), which belongs to the value, not in it; + * - **empty** (`|a|null|`), the address field present with nothing in it. Taken + * as part of the value it reads as `null|`; + * - anything else, which is the value's own text. + */ +function valueSpan(logLine: string): ValueSpan | null { + const nameStart = pipeAfter(logLine, 3); + if (nameStart < 0) { + return null; + } + const start = logLine.indexOf('|', nameStart) + 1; + if (start <= 0) { + return null; + } + const lastPipe = logLine.lastIndexOf('|'); + if (lastPipe >= start) { + const tail = logLine.slice(lastPipe + 1); + if (!tail) { + return { start, end: lastPipe, address: null }; + } + if (ADDRESS.test(tail)) { + return { start, end: lastPipe, address: tail }; + } + } + return { start, end: logLine.length, address: null }; +} + +/** Reads a `VARIABLE_ASSIGNMENT` line, or null where it carries no name. */ +export function parseVariableWrite(logLine: string): VariableWrite | null { + const name = variableNameOf(logLine); + const span = name && valueSpan(logLine); + if (!name || !span) { + return null; + } + return { name, value: logLine.slice(span.start, span.end), address: span.address }; +} + +/** + * Reads a `VARIABLE_SCOPE_BEGIN` line, or null where it is too short to carry a + * declaration. + * + * The two flags trail the declared type: the first says whether the variable can + * be referenced, the second whether it is static. A type holds commas + * (`Map`) but never a pipe, so both are read from the right. + */ +export function parseVariableScope(logLine: string): VariableScope | null { + const parts = logLine.split('|'); + if (parts.length < 7) { + return null; + } + const isStatic = parts[parts.length - 1]?.trim() === 'true'; + const declaredType = parts[parts.length - 3]?.trim() ?? ''; + const name = parts[3]?.trim() ?? ''; + return name ? { name, declaredType, isStatic } : null; +} + +/** The longest an address is. Past it, a value cannot be one. */ +const ADDRESS_MAX = 32; + +/** The address a value is, where the log wrote an address in place of a value: + * the value would not serialise, so the log named where it lived instead. + * + * Length first, so a very long value is rejected without being copied. */ +export function bareAddress(value: string): string | null { + if (value.length > ADDRESS_MAX * 2) { + return null; + } + const text = value.trim(); + return ADDRESS.test(text) ? text : null; +} + +/** + * {@link bareAddress} for a whole line, for a walk of the whole log: it gives up + * on length before it slices, so a very long value costs nothing. + */ +export function bareAddressOf(logLine: string): string | null { + const span = valueSpan(logLine); + return span && span.end - span.start <= ADDRESS_MAX + ? bareAddress(logLine.slice(span.start, span.end)) + : null; +} + +/** + * The address a line reported for the value it wrote, or null where it reported + * none. + * + * Cheap on any line: the address trails the value, so this reads back from the + * end rather than through it. + */ +export function reportedAddressOf(logLine: string): string | null { + return valueSpan(logLine)?.address ?? null; +} + +/** Chars of a value scanned for the addresses it names. An address the log + * bothered to name appears early, and a value can be very long. */ +const NESTED_SCAN_MAX = 4_000; + +const NESTED_ADDRESS = /0x[0-9a-f]+/gi; + +/** + * Every address a line's value names *inside* itself, as the `"0x6c98700c"` in + * `{"m_tliFilter":"0x6c98700c"}`. + * + * The address the line reports for its own value is left out: every assignment + * reports one, so taking them all would hold a quarter of a million events. + */ +export function nestedAddressesOf(logLine: string): readonly string[] { + const span = valueSpan(logLine); + if (!span) { + return []; + } + const end = Math.min(span.end, span.start + NESTED_SCAN_MAX); + const found = logLine.indexOf('0x', span.start); + // Tested on the line itself: slicing first would allocate up to 4KB for every + // assignment in the log, and most name no address at all. + if (found < 0 || found >= end) { + return []; + } + return logLine.slice(span.start, end).match(NESTED_ADDRESS) ?? []; +} + +/** The class a static belongs to, or null for a name that names no class. */ +export function classOf(staticName: string): string | null { + const lastDot = staticName.lastIndexOf('.'); + return lastDot > 0 ? staticName.slice(0, lastDot) : null; +} + +/** A qualified name without its owner, for a row whose group already names it. */ +export function shortName(name: string): string { + const lastDot = name.lastIndexOf('.'); + return lastDot > 0 ? name.slice(lastDot + 1) : name; +} + +const FIELD_PREFIX = 'this.'; + +/** True for a name the log qualified with its class, which every static is. */ +export function isStaticName(name: string): boolean { + return name.includes('.') && !isFieldName(name); +} + +/** True for a field of the object a frame runs on. Its line reports that + * object's address, never the field's own value. */ +export function isFieldName(name: string): boolean { + return name.startsWith(FIELD_PREFIX); +} + +/** The character after the nth pipe, or -1 where the line has fewer. */ +function pipeAfter(line: string, pipes: number): number { + let at = -1; + for (let found = 0; found < pipes; found++) { + at = line.indexOf('|', at + 1); + if (at < 0) { + return -1; + } + } + return at + 1; +} diff --git a/log-viewer/src/core/log/variableValue.ts b/log-viewer/src/core/log/variableValue.ts new file mode 100644 index 000000000..7eed28e03 --- /dev/null +++ b/log-viewer/src/core/log/variableValue.ts @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { bareAddress } from './variableLine.js'; + +/** + * Reads the shape of a logged variable value, without ever trusting it as JSON. + * + * The log writes values that `JSON.parse` cannot survive: a Map serialises with + * duplicate keys, so parsing silently loses entries; an Apex `toString()` lands + * inside a JSON string; a truncated value keeps its marker; and an + * unserialisable value collapses to a bare address. So this scans, and every + * piece of text it hands back is verbatim. + * + * The log records one level, so this reads one level. Nesting a scan deeper + * would only ever find the `{}` the log put there. + */ + +/** Text over this length earns an expander: it cannot be read in a row. */ +export const EXPAND_MIN_CHARS = 80; + +/** Never lay out more than this. A logged value can be very long. */ +export const RAW_CLAMP_CHARS = 2_000; + +/** Past this the structure is not scanned: the raw text is the honest answer. */ +const MAX_SCAN_CHARS = 64_000; + +/** Entries scanned in one container. Beyond it the value reads as truncated. */ +const MAX_ENTRIES = 500; + +/** One entry of a container, exactly as the log wrote it. */ +export interface ValueEntry { + /** The key, or null for a list entry or an entry with no readable key. */ + key: string | null; + text: string; + /** The address this entry's text names, quoted or bare, where it names one. + * Read here because this is where entry quoting is known. */ + address: string | null; +} + +export type VariableValue = + /** The log wrote no value at all. */ + | { kind: 'empty' } + /** A bare heap address: the value was not serialisable, not an empty object. */ + | { kind: 'address'; text: string } + /** A number, a boolean, null, or anything this cannot name. Verbatim. */ + | { kind: 'literal'; text: string } + | { kind: 'string'; text: string; inner: string; toStringLike: boolean; truncated: boolean } + | { + kind: 'container'; + brackets: '{}' | '[]'; + entries: ValueEntry[]; + truncated: boolean; + /** The log wrote this as a string, and its text was JSON. */ + fromString: boolean; + }; + +/** A collection the log cut short, as `…, ...}`. */ +const CONTAINER_CUT = /,\s*\.\.\.$/; + +/** A string the log cut short, as `first20chars (10 more) ...`. */ +const STRING_CUT = /\(\d+ more\)\s*\.\.\.\s*$/; + +/** Reads `raw` into the shape it has. Never throws, whatever the log wrote. */ +export function parseVariableValue(raw: string): VariableValue { + const text = raw.trim(); + if (!text) { + return { kind: 'empty' }; + } + const address = bareAddress(text); + if (address) { + return { kind: 'address', text: address }; + } + if (text.startsWith('"')) { + const inner = text.endsWith('"') && text.length > 1 ? text.slice(1, -1) : text.slice(1); + // An Apex toString() inside the quotes: `"{k=v, k=v}"`. + const toStringLike = inner.startsWith('{') && inner.endsWith('}') && inner.includes('='); + return ( + (toStringLike ? null : jsonInString(inner)) ?? { + kind: 'string', + text, + inner, + toStringLike, + truncated: STRING_CUT.test(inner), + } + ); + } + const brackets = bracketsOf(text); + if (brackets && text.length <= MAX_SCAN_CHARS) { + return scanContainer(text, brackets, false); + } + return { kind: 'literal', text }; +} + +/** + * A string whose text is JSON, read as the object it holds, or null where it is + * not JSON. + * + * Strictly gated on a quoted key, or on being a list. An Apex `toString()` has + * neither, and reading structure out of one would claim the log recorded + * something it did not. A string whose text is JSON is rare, so this is for a + * String field carrying a `JSON.serialize` result. + */ +function jsonInString(inner: string): VariableValue | null { + // A serialised string arrives escaped, and unescaping is recovery, not + // reformatting: the text is the log's own, with the log's own escaping undone. + const text = inner.includes('\\"') ? inner.replaceAll('\\"', '"') : inner; + const brackets = bracketsOf(text); + if (!brackets || text.length > MAX_SCAN_CHARS) { + return null; + } + const scanned = scanContainer(text, brackets, true); + if (!scanned.entries.length) { + return null; + } + const isJson = brackets === '[]' || scanned.entries.some((entry) => entry.key !== null); + return isJson ? scanned : null; +} + +/** True where the value cannot be read in a row and so earns an expander. */ +export function isExpandable(value: VariableValue): boolean { + switch (value.kind) { + case 'container': + // A chevron that opens on nothing would teach a depth the log lacks. + return value.entries.length > 0; + case 'string': + return value.inner.length > EXPAND_MIN_CHARS; + case 'literal': + return value.text.length > EXPAND_MIN_CHARS; + default: + return false; + } +} + +/** + * The one line a collapsed row shows. Clamped, so a huge value costs a row and + * not a layout. + */ +export function previewOf(value: VariableValue, maxChars = EXPAND_MIN_CHARS): string { + switch (value.kind) { + case 'empty': + return ''; + case 'address': + return value.text; + case 'container': { + // `{}` reads as `{}`. Naming it "empty object" would claim the log knew. + const [open, close] = value.brackets; + if (!value.entries.length) { + return `${open}${close}`; + } + let body = ''; + for (const entry of value.entries) { + body += `${body ? ', ' : ''}${entry.key === null ? entry.text : `${entry.key}: ${entry.text}`}`; + if (body.length > maxChars) { + break; + } + } + return clamp(`${open}${body}${value.truncated ? ', …' : ''}${close}`, maxChars); + } + default: + return clamp(value.text, maxChars); + } +} + +/** The raw text an expanded row shows, cut to what can be laid out. */ +export function clampRaw(raw: string): { text: string; clamped: boolean } { + return raw.length > RAW_CLAMP_CHARS + ? { text: raw.slice(0, RAW_CLAMP_CHARS), clamped: true } + : { text: raw, clamped: false }; +} + +function bracketsOf(text: string): '{}' | '[]' | null { + if (text.startsWith('{') && text.endsWith('}')) { + return '{}'; + } + return text.startsWith('[') && text.endsWith(']') ? '[]' : null; +} + +type Container = Extract; + +function scanContainer(text: string, brackets: '{}' | '[]', fromString: boolean): Container { + const body = text.slice(1, -1).trim(); + if (!body) { + return { kind: 'container', brackets, entries: [], truncated: false, fromString }; + } + const pieces = splitTopLevel(body); + let truncated = pieces.length > MAX_ENTRIES || CONTAINER_CUT.test(body); + const entries = pieces + .slice(0, MAX_ENTRIES) + .map((piece) => piece.trim()) + .filter((piece) => { + if (piece === '...') { + truncated = true; + return false; + } + return piece.length > 0; + }) + .map(entryOf); + return { kind: 'container', brackets, entries, truncated, fromString }; +} + +/** + * One entry, key kept apart from value where the log wrote a quoted key. + * + * Duplicate keys stay, in the order the log wrote them: a Map serialises with + * repeats, and dropping them would hide entries the transaction held. + */ +function entryOf(piece: string): ValueEntry { + if (!piece.startsWith('"')) { + return { key: null, ...valued(piece) }; + } + const closing = closingQuote(piece); + if (closing < 0 || piece[closing + 1] !== ':') { + return { key: null, ...valued(piece) }; + } + return { key: piece.slice(1, closing), ...valued(piece.slice(closing + 2).trim()) }; +} + +/** An entry's text, and the address it names. The log quotes a nested address, + * as the `"0x6c98700c"` in `{"delegate":"0x6c98700c"}`. */ +function valued(text: string): Pick { + const inner = text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text; + return { text, address: bareAddress(inner) }; +} + +/** + * A container assembled from writes of their own, for an object the log wrote as + * `{}` and described in lines of its own. + * + * Read the same way a parsed container is, so an entry names the address its + * text names. Truncation is the caller's to state: the object was never + * serialised, so its own line cut nothing short. + */ +export function assembledContainer( + parts: readonly { key: string | null; text: string }[], + truncated: boolean, +): VariableValue { + return { + kind: 'container', + brackets: '{}', + entries: parts.map((part) => ({ key: part.key, ...valued(part.text) })), + truncated, + fromString: false, + }; +} + +/** Splits on the commas that separate entries: not those inside a string, and + * not those inside a nested value. */ +function splitTopLevel(body: string): string[] { + const pieces: string[] = []; + let depth = 0; + let inString = false; + let start = 0; + for (let at = 0; at < body.length; at++) { + const char = body[at]; + if (inString) { + if (char === '\\') { + at++; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + } else if (char === '{' || char === '[') { + depth++; + } else if (char === '}' || char === ']') { + depth--; + } else if (char === ',' && depth === 0) { + pieces.push(body.slice(start, at)); + start = at + 1; + } + } + pieces.push(body.slice(start)); + return pieces; +} + +function closingQuote(piece: string): number { + for (let at = 1; at < piece.length; at++) { + if (piece[at] === '\\') { + at++; + } else if (piece[at] === '"') { + return at; + } + } + return -1; +} + +function clamp(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text; +} diff --git a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts index 9c218d7f1..13a7f6022 100644 --- a/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts +++ b/log-viewer/src/core/metrics/__tests__/eventMetrics.test.ts @@ -16,20 +16,45 @@ const limits = { } as unknown as GovernorLimits; describe('usageParts', () => { - it('reads used / limit with the percentage and the self reading', () => { - const parts = usageParts(3, 100, String, '1'); + it("reads the selection against the log's own total, with the limit as a qualifier", () => { + const parts = usageParts(3, 12, 100, String, '1'); - expect(parts.primary).toBe('3 / 100'); - expect(parts.qualifiers).toEqual(['3.00%', 'self 1']); + expect(parts.primary).toBe('3 of 12'); + expect(parts.qualifiers).toEqual(['25.00% of log', '3.00% of the 100 limit', 'self 1']); }); - // No denominator means no share of anything, so nothing to qualify. - it('gives the count alone, and no percentage, where there is no limit', () => { - const parts = usageParts(7, 0, String, null); + // The log reported no limit, so there is no share of one to give. + it('keeps the contribution and drops the limit where none was reported', () => { + const parts = usageParts(3, 12, 0, String, null); + + expect(parts.primary).toBe('3 of 12'); + expect(parts.qualifiers).toEqual(['25.00% of log']); + }); + + // A whole-log reading's share of itself is 100%, which says nothing. + it('carries no denominator when the selection is the whole log', () => { + const parts = usageParts(7, 7, 0, String, null); expect(parts.primary).toBe('7'); expect(parts.qualifiers).toEqual([]); }); + + // Only the whole-log reading drops the denominator: a reading past the log's own total is an + // anomaly, and a bare number would hide it. + it('keeps the denominator where the selection reads past the log total', () => { + const parts = usageParts(15, 12, 0, String, null); + + expect(parts.primary).toBe('15 of 12'); + expect(parts.qualifiers).toEqual(['125.00% of log']); + }); + + // Heap is signed: a selection that frees more than it allocates gave the transaction heap back. + it('keeps a net-negative reading signed', () => { + const parts = usageParts(-5, 300, 0, String, null); + + expect(parts.primary).toBe('-5 of 300'); + expect(parts.qualifiers).toEqual(['-1.67% of log']); + }); }); describe('EVENT_METRICS', () => { diff --git a/log-viewer/src/core/metrics/eventMetrics.ts b/log-viewer/src/core/metrics/eventMetrics.ts index 165c0dd18..890307331 100644 --- a/log-viewer/src/core/metrics/eventMetrics.ts +++ b/log-viewer/src/core/metrics/eventMetrics.ts @@ -3,7 +3,7 @@ */ import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; -import { formatInteger } from '../utility/Util.js'; +import { formatInteger, sharePercent } from '../utility/Util.js'; /** * The statement a database metric belongs to — which grid a selection came from, and @@ -77,28 +77,41 @@ export function selfLabel(self: string): string { /** A metric reading, split so a caller can lay the parts out however it likes. */ export interface UsageParts { - /** `used / limit`, or the count alone where there is no limit. */ + /** `used of the log's total`, or the count alone where the reading is the whole log. */ primary: string; - /** The percentage and any self reading — secondary, in reading order. */ + /** The shares, the limit reading and any self reading — secondary, in reading order. */ qualifiers: string[]; } /** - * `used / limit` with its derived percentage and any self reading, so the primary - * number reads first. Without a known limit there is no denominator and no percentage. + * A reading against what the transaction consumed, with any limit as a qualifier. + * + * The primary number answers the question a per-selection reading is asked — how much of the + * transaction's own consumption this is — so it is spelled "of", never "/", and reads on every log + * whether or not one reported limits. A whole-log reading carries no denominator — its share of + * itself is 100% — and neither does a log total of zero or less, which a signed metric like net + * heap can reach. A selection reading *past* the log's own total does keep one, rather than hiding + * the anomaly behind a bare number. A limit the log reported follows as a qualifier. + * + * @param total - This selection's consumption. + * @param logTotal - The transaction's consumption of the same metric. + * @param limit - The limit the log reported, or 0 where it reported none. + * @param format - How to write each number. + * @param self - The selection's own share, already formatted, or null where it adds nothing. */ export function usageParts( total: number, + logTotal: number, limit: number, format: (value: number) => string, self: string | null, ): UsageParts { - const fraction = limit > 0 ? total / limit : null; + const noDenominator = logTotal <= 0 || logTotal === total; return { - primary: limit > 0 ? `${format(total)} / ${format(limit)}` : format(total), - // Percentage first: it qualifies the ratio immediately before it. + primary: noDenominator ? format(total) : `${format(total)} of ${format(logTotal)}`, qualifiers: [ - fraction !== null ? `${(fraction * 100).toFixed(2)}%` : null, + noDenominator ? null : `${sharePercent(total, logTotal).toFixed(2)}% of log`, + limit > 0 ? `${sharePercent(total, limit).toFixed(2)}% of the ${format(limit)} limit` : null, self && selfLabel(self), ].filter((part): part is string => !!part), }; diff --git a/log-viewer/src/core/utility/Util.ts b/log-viewer/src/core/utility/Util.ts index 2f5e145ea..780dc5cee 100644 --- a/log-viewer/src/core/utility/Util.ts +++ b/log-viewer/src/core/utility/Util.ts @@ -173,14 +173,29 @@ export function debounce(callBack: (...args: T) => unknown) }; } +/** + * Resolve true once `element` is on screen. + * + * Without a `signal` the promise waits for as long as the element stays off + * screen, holding its observer. Pass one from a caller that can ask more than + * once: aborting releases the observer and resolves false. + */ export async function isVisible( element: HTMLElement, options?: IntersectionObserverInit, + signal?: AbortSignal, ): Promise { return new Promise((resolve) => { + if (signal?.aborted) { + resolve(false); + return; + } + const observer = new IntersectionObserver((entries, observerInstance) => { for (const entry of entries) { if (entry.isIntersecting) { + // The signal outlives this call, so the listener goes with the wait. + signal?.removeEventListener('abort', release); resolve(true); observerInstance.disconnect(); return; @@ -188,6 +203,12 @@ export async function isVisible( } }, options); + const release = (): void => { + observer.disconnect(); + resolve(false); + }; + signal?.addEventListener('abort', release, { once: true }); + observer.observe(element); }); } diff --git a/log-viewer/src/core/utility/__tests__/Util.test.ts b/log-viewer/src/core/utility/__tests__/Util.test.ts index e9903a640..05f9f18f5 100644 --- a/log-viewer/src/core/utility/__tests__/Util.test.ts +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -1,9 +1,9 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { describe, expect, it } from '@jest/globals'; +import { beforeEach, describe, expect, it } from '@jest/globals'; -import { computeWallClockMs, formatByteSize, formatWallClockTime } from '../Util.js'; +import { computeWallClockMs, formatByteSize, formatWallClockTime, isVisible } from '../Util.js'; describe('formatWallClockTime', () => { it('should format midnight as 00:00:00.000', () => { @@ -80,3 +80,41 @@ describe('formatByteSize', () => { expect(formatByteSize(-1_500_000)).toBe('-1.5 MB'); }); }); + +describe('isVisible', () => { + /** Observers standing, so a release can be counted. */ + let observing = 0; + + /** Reports nothing, so only the abort can settle the wait. */ + class NeverIntersects { + observe(): void { + observing++; + } + disconnect(): void { + observing--; + } + } + + beforeEach(() => { + observing = 0; + globalThis.IntersectionObserver = NeverIntersects as unknown as typeof IntersectionObserver; + }); + + it('releases its observer where the wait is aborted', async () => { + const controller = new AbortController(); + const waiting = isVisible({} as HTMLElement, undefined, controller.signal); + + controller.abort(); + + await expect(waiting).resolves.toBe(false); + expect(observing).toBe(0); + }); + + it('observes nothing for a signal that aborted first', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(isVisible({} as HTMLElement, undefined, controller.signal)).resolves.toBe(false); + expect(observing).toBe(0); + }); +}); diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 597d12460..231f28f0d 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -6,36 +6,32 @@ import '#vscode-elements/vscode-option.js'; import '../../../components/VsSelect.js'; import '#vscode-elements/vscode-toolbar-button.js'; import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog } from 'apex-log-parser'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; import { eventBus } from '../../../core/events/EventBus.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { - LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, rowDetailSelection, - rowOccurrences, + rowFrames, } from '../../../components/locatedRow.js'; -import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; +import { InspectorTabController } from '../../../components/InspectorTabController.js'; +import { revealFirstOf } from '../../../components/inspectorTab.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; +import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; import { createBottomUpTable } from '../../call-tree/components/BottomUpTable.js'; -import { - applyColumnView, - buildColumnMenuItems, - CALL_TREE_VIEWS, - getColumnView, - getTableFields, - resolveColumnView, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { CALL_TREE_VIEWS } from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; +import { findRootBucket } from '../../call-tree/utils/bucketRows.js'; import { categoryColoringStyles, groupedRowFormatter, @@ -43,6 +39,9 @@ import { } from '../../call-tree/utils/CategoryColoring.js'; import { expandCollapseAll } from '../../call-tree/utils/ExpandCollapse.js'; +import { onTableReshaped } from '../../../tabulator/module/tableReshape.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.js'; + import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; // styles @@ -73,11 +72,6 @@ export class AnalysisView extends LitElement { box-sizing: border-box; } - /* The frame under the pointer in the inspector. */ - .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } - .analysis-view { display: flex; flex-direction: column; @@ -127,12 +121,13 @@ export class AnalysisView extends LitElement { analysisTable: Tabulator | null = null; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id → visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'callTree', + read: (settings) => settings.callTree, + views: CALL_TREE_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (this.analysisTable ? [this.analysisTable] : []), + }); private contextMenu: ContextMenu | null = null; tableContainer: HTMLDivElement | null = null; findMap: { [key: number]: RowComponent } = {}; @@ -152,47 +147,33 @@ export class AnalysisView extends LitElement { /** Releases the category-colouring settings subscription; set while connected. */ private _categoryColoringOff: (() => void) | null = null; - private _selectionClearUnsubscribe: (() => void) | null = null; /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - private _inspectorRevealUnsubscribe: (() => void) | null = null; - private _inspectorLocateUnsubscribe: (() => void) | null = null; + private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); - private _emphasis = new InspectorEmphasis(); - constructor() { - super(); + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => void this._find(e), + 'lv-find-match': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); + private readonly _inspector = new InspectorTabController(this, 'analysis', { + // A row is a method bucket rather than one event, so a frame is translated + // into the paths of the rows it heads. + mark: (eventIndexes) => this._markLocated(eventIndexes), // An inspector finding names one event; the grid holds it in the bucket for // its method, so that bucket is what gets revealed. - this._inspectorRevealUnsubscribe = eventBus.on('inspector:reveal', (detail) => { - if (detail.source === 'analysis') { - void this._revealEventIndex(detail.eventIndex); - } - }); - // Mark the buckets the inspector points at. A row is a method bucket rather - // than one event, so a frame is translated into the paths of the rows it heads. - this._inspectorLocateUnsubscribe = eventBus.on('inspector:locate', (detail) => { - if (detail.source === 'analysis') { - this._markLocated(this._emphasis.report(detail.eventIndexes, detail.sticky)); - } - }); - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-match', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); - - // Escape (app-wide) deselects here; the table reports the clear itself. It - // also drops a mark held by a picked inspector row, which is no selection of - // this table's own. - this._selectionClearUnsubscribe = eventBus.on('selection:clear', (detail) => { - if (detail.source === 'analysis') { - this.analysisTable?.deselectRow(); - this._markLocated(this._emphasis.pick([])); - } - }); - } + reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), + clear: () => { + // The table reports the clear itself, which is what reaches the inspector. + this.analysisTable?.deselectRow(); + }, + // A row buckets calls, so a merged pick moves to the first of them. + revealMerged: revealFirstOf((eventIndex, signal) => this._revealEventIndex(eventIndex, signal)), + }); override connectedCallback(): void { super.connectedCallback(); @@ -203,15 +184,6 @@ export class AnalysisView extends LitElement { super.disconnectedCallback(); this._categoryColoringOff?.(); this._categoryColoringOff = null; - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-match', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); - this._selectionClearUnsubscribe?.(); - this._selectionClearUnsubscribe = null; - this._inspectorRevealUnsubscribe?.(); - this._inspectorRevealUnsubscribe = null; - this._inspectorLocateUnsubscribe?.(); - this._inspectorLocateUnsubscribe = null; this._locatedRow.clear(); } @@ -231,30 +203,35 @@ export class AnalysisView extends LitElement { * inspector keeps the findings it was clicked in rather than being rebuilt around * the row it just asked for. */ - private async _revealEventIndex(eventIndex: number): Promise { + private async _revealEventIndex(eventIndex: number, signal: AbortSignal): Promise { const table = this.analysisTable; - // `instances` is populated on root buckets only, which is what the grid lists. - const match = table - ?.getRows() - .find((row) => - (row.getData() as BottomUpRow).instances?.some((event) => event.eventIndex === eventIndex), - ); - if (!table || !match) { + const root = this.timelineRoot; + if (!table || !root) { + return; + } + const event = eventByEventIndex(root, eventIndex); + if (!event) { + return; + } + // The grid is bottom-up, so the frame heads a top-level bucket its own key + // finds, without reading what any bucket holds. + const match = findRootBucket(table.getRows(), event); + if (!match) { return; } // Show Details keeps only rows with a duration, so the buckets for debug // lines, thrown exceptions and query plans are filtered out — exactly the // events a finding points at. Turn the filter off rather than reveal nothing. - const data = match.getData(); - if ( - !this.filterState.showDetails && - !table.getRows('active').some((row) => row.getData() === data) - ) { + if (!this.filterState.showDetails && !this._showDetailsFilter(match.getData() as BottomUpRow)) { this._handleShowDetailsChange(); await this.updateComplete; } + if (signal.aborted) { + return; + } + await this._echoGuard.runAsync(() => //@ts-expect-error This is a custom function added in by RowNavigation custom module table.goToRow(match, { scrollIfVisible: false, focusRow: false }), @@ -263,13 +240,6 @@ export class AnalysisView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.callTree?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } updated(changedProperties: PropertyValues): void { @@ -311,14 +281,16 @@ export class AnalysisView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${repeat( CALL_TREE_VIEWS, (view) => view.id, (view) => - html`${view.id}`, )} @@ -382,27 +354,12 @@ export class AnalysisView extends LitElement { } private _handleColumnViewChange(event: Event) { - const target = event.target as HTMLInputElement; - const id = target.value || 'General'; - this._setColumnView(id); - updateSetting('callTree.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; - } - - private _setColumnView(id: string) { - this.columnView = id; - if (this.analysisTable) { - applyColumnView(this.analysisTable, this._columnViewFields(id), ALWAYS_VISIBLE); - } + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } /** Applies the active view and wires the header menu once the table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -414,17 +371,7 @@ export class AnalysisView extends LitElement { if (!this.contextMenu || !this.analysisTable) { return; } - this.contextMenu.show( - buildColumnMenuItems( - this.analysisTable, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - x, - y, - ); + this.contextMenu.show(this._columns.menuItems(this.analysisTable), x, y); } private _openColumnMenu(event: Event) { @@ -437,13 +384,7 @@ export class AnalysisView extends LitElement { if (!this.contextMenu?.isVisible() || !this.analysisTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.analysisTable, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.analysisTable); } private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { @@ -453,47 +394,23 @@ export class AnalysisView extends LitElement { return; } if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('callTree.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - const field = itemId.slice('col:'.length); - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - applyColumnView(table, fields, ALWAYS_VISIBLE); - updateSetting('callTree.columnOverrides', this.columnOverrides); + this._columns.toggle(table, itemId.slice('col:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); } } private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - const table = this.analysisTable; - if (!table || !this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); - } - updateSetting('callTree.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } _copyToClipboard() { @@ -508,12 +425,11 @@ export class AnalysisView extends LitElement { return (this.tableContainer ??= this.renderRoot?.querySelector('#analysis-table')); } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _groupBy(event: Event) { const target = event.target as HTMLInputElement; + // Grouping renumbers the matches both ways round, and `dataGrouped` reports + // only the way that leaves the table grouped. + this._dropSearch(); const fieldName = target.value === 'Caller Namespace' ? 'callerNamespace' : target.value.toLowerCase(); if (this.analysisTable) { @@ -533,6 +449,7 @@ export class AnalysisView extends LitElement { if (!table) { return; } + this._dropSearch(); table.blockRedraw(); table.clearFilter(false); if (!this.filterState.showDetails) { @@ -556,7 +473,7 @@ export class AnalysisView extends LitElement { } table.blockRedraw(); expandCollapseAll(table.getRows(), expand); - table.element?.querySelector('.tabulator-tableholder')?.focus(); + tableHolder(table.element)?.focus(); table.restoreRedraw(); } @@ -572,7 +489,7 @@ export class AnalysisView extends LitElement { }); } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const isTableVisible = !!this.analysisTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { return; @@ -626,19 +543,7 @@ export class AnalysisView extends LitElement { rootMethod, { showDetailsFilter: this._showDetailsFilter, - onFilterCacheClear: () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, - onRenderStarted: () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, - rowFormatter: groupedRowFormatter(rootMethod), + rowFormatter: groupedRowFormatter, }, { placeholder: 'No Analysis Available', @@ -649,19 +554,7 @@ export class AnalysisView extends LitElement { ); this.analysisTable = table; - this.analysisTable.on('dataSorted', () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }); - - this.analysisTable.on('dataGrouped', () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }); + onTableReshaped(this.analysisTable, () => this._dropSearch()); // Feed the inspector. Analysis rows merge many calls, so they // scope to every call they count. @@ -671,19 +564,20 @@ export class AnalysisView extends LitElement { } eventBus.emit('detail:select', { source: 'analysis', - selection: rowDetailSelection(rows[0]), + selection: rowDetailSelection(rows[0], this.timelineRoot, 'callers'), // The grid ranks methods by self time and expands to their callers, so // the inspector opens on the forward view instead. view: 'callers', }); }); - // Tell the inspector which calls the pointer is over, so it can mark the rows - // that stand for them; a bucket merges calls, so it names every call it counts. + // Tell the inspector which frames the pointer is over, so it can mark the + // rows that stand for them. A row under a bucket is one of its callers, so it + // names that caller rather than the calls it conducted. this.analysisTable.on('rowMouseEnter', (_e, row) => { eventBus.emit('detail:locate', { source: 'analysis', - eventIndexes: rowOccurrences(row), + eventIndexes: rowFrames(row, this.timelineRoot, 'callers'), }); }); this.analysisTable.on('rowMouseLeave', () => { @@ -698,6 +592,14 @@ export class AnalysisView extends LitElement { document.dispatchEvent(new CustomEvent('lv-find-results', { detail: { totalMatches: 0 } })); } + /** Drop the search where its match numbering no longer describes the table. */ + _dropSearch() { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + } + _clearSearchHighlights() { this.findArgs.text = ''; this.findArgs.count = 0; @@ -707,5 +609,3 @@ export class AnalysisView extends LitElement { this.totalMatches = 0; } } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts b/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts index 910e961b1..b13c97508 100644 --- a/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts +++ b/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts @@ -208,8 +208,8 @@ export class LogDiagnosticsView extends LitElement { } .rollup__seg:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: var(--lana-stroke); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } /* How long the finding's own events took, and what that is of the log. Only @@ -416,8 +416,8 @@ export class LogDiagnosticsView extends LitElement { } .evidence--link:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: var(--lana-stroke); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } .evidence__go { diff --git a/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts b/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts index 38c7b6463..54f71680f 100644 --- a/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts +++ b/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts @@ -157,7 +157,7 @@ export class SelfTimeSpreadView extends LitElement { private _row(row: SingleRow, title: string, value: number, extras: TemplateResult | '') { return html`