Add the read-only analysis catalog panel - #234
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
🚧 Files skipped from review as they are similar to previous changes (8)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe Interlinearizer adds a localized, resizable Analysis Catalog with usage navigation, persisted panel state, command wiring, writing-system support, and expanded test coverage. ChangesAnalysis Catalog feature
Estimated code review effort: 4 (Complex) | ~60 minutes Merge Risk: 🔵 Low · up to The panel behavior is localized, but resizing retains bounded RTL-direction and duplicate-write risks that warrant explicit owner awareness or follow-up before merge; no high-impact correctness or availability risk is indicated. Sequence Diagram(s)sequenceDiagram
participant Menu
participant InterlinearizerLoader
participant AnalysisStore
participant AnalysisCatalogPanel
participant CatalogRowView
Menu->>InterlinearizerLoader: openAnalysisCatalog
InterlinearizerLoader->>AnalysisStore: provide book catalog state
AnalysisCatalogPanel->>AnalysisStore: select catalog rows
AnalysisStore-->>AnalysisCatalogPanel: return catalog rows
AnalysisCatalogPanel->>CatalogRowView: render rows
CatalogRowView->>InterlinearizerLoader: navigate to selected usage
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
41c174d to
89835a1
Compare
Hoists the analysis store above the cross-book fade curtain so a jump to a usage in another book cannot dim the panel. Search, sort, filter, and row windowing are deferred to #231.
Also commits a released drag width from a ref rather than from inside the setDragWidth updater, which React may run more than once.
An unparseable analysis-language tag threw out of Intl.Collator and blanked the whole view; the resize handle inverted in right-to-left interfaces; and the row toggle's aria-label suppressed the analysis it named. Rows now share one localization subscription instead of one apiece.
A pointer released where the window cannot see it — over a native menu, which takes the pointer with it — left the drag running, so the panel went on resizing under a button-less pointer and committed that width at the next click anywhere. A move reporting no button held now ends the drag at the width it had reached, since that move is the only word the window gets of such a release. Arrow keys stand aside while a drag is in flight. They stepped off the width the drag began at, reporting a width the panel was not showing only for the release to overwrite it; the pointer owns the width while it is held. The drag test helper now dispatches its moves with a held button, which a real one carries and jsdom does not.
The held-focus-request test left the reference on GEN while EXO's view mounted, a state the host never produces; move it to EXO with the jump.
The simulated drag omitted `buttons`, so the move arrived reporting none — which the resize hook reads as a release it never saw, ending the drag before it recorded a width. The panel fell back to its default, and the remount assertion compared that default against itself. Dropping width persistence outright left the test green. Carry `buttons` on the move so the drag resizes, and name the expected width at both ends rather than checking the two renders agree, since the default is what a dead drag and a dropped write alike leave behind.
Any button began a drag: the move handler asks only whether some button is held, so a middle-button press followed the pointer to its release and persisted the width it reached. A right-button press does the same wherever the context menu opens on release rather than on press. Also correct the loader's width-restore comment, which described a move reporting no buttons held while the move beneath it carries one.
456d1bb to
ede5153
Compare
| * Each row owns its own layout so that its detail can be nested inside it. One element per analysis | ||
| * is what lets the list window and be walked by keyboard a row at a time. | ||
| */ | ||
| export default function CatalogRowView({ |
There was a problem hiding this comment.
⛏️ Consider wrapping this in React.memo. applyCatalogQuery filters and sorts without cloning, so rows keep their identity and the remaining props are stable; without memo, every resize-drag frame (setDragWidth fires per mousemove) and every gloss commit reconciles every row's subtree in an unwindowed list. Worth confirming #231 covers memoizing the row alongside windowing.
There was a problem hiding this comment.
Done — MemoizedCatalogRowView, matching the TokenChip / SegmentView pattern. Your premise checks out: applyCatalogQuery is filter + toSorted, so rows keep identity, and the rest of the props are stable (onUsageSelect is a useCallback, localizedStrings comes off the hoisted key array). Filed the windowing half against #231 rather than duplicating it here.
| const hiddenUsageCount = row.usages.length - visibleUsages.length; | ||
|
|
||
| const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%']; | ||
| const usageCountInBookLabel = formatReplacementString( |
There was a problem hiding this comment.
❓ usageCountInBookLabel substitutes currentBook verbatim, so the tooltip and screen-reader text read "Uses in GEN". The other place this extension fills a {book} placeholder in user-facing prose resolves it to a name first (Canon.bookIdToEnglishName, src/components/SegmentListView.tsx:220). Raw codes are right for the usage links themselves — those are written as references (GEN 1:1) — but is the code deliberate for the count label?
There was a problem hiding this comment.
Not deliberate — good catch. Switched to Canon.bookIdToEnglishName, so it reads "Uses in Genesis" while the usage links stay references. Same treatment and same caveat as SegmentListView.tsx:220 (a platform-localized name needs PAPI wiring this view doesn't have). Covered by a test that layers a resolved value over the key-as-value mock.
| {hiddenUsageCount > 0 && ( | ||
| <Button | ||
| data-testid="catalog-usages-show-all" | ||
| onClick={() => setShowsAllUsages(true)} |
There was a problem hiding this comment.
⛏️ showsAllUsages is only ever set to true, and it survives collapsing and re-expanding the row (both flags are row-local state that resets only on unmount), so a row expanded to hundreds of usages cannot be returned to the inline cap. Consider resetting it when the row collapses, or offering a "show fewer" affordance.
There was a problem hiding this comment.
Fixed. A handleToggle clears showsAllUsages alongside the expand flag, so collapsing returns the row to the inline cap. Went with the reset rather than a "show fewer" affordance — the expander is already gone by then, so there's nothing to pair it with.
|
|
||
| window.addEventListener('mousemove', handleMouseMove); | ||
| window.addEventListener('mouseup', handleMouseUp); | ||
| return () => { |
There was a problem hiding this comment.
⛏️ Commits happen only in endDrag, so if the panel unmounts mid-drag this cleanup drops the listeners and the width reached is silently discarded — the panel comes back at its previous width. The close control is not reachable mid-drag, but a draftVersion bump or isDraftLoading flipping true does unmount the panel (src/components/InterlinearizerLoader.tsx:639). A commit in the cleanup would cover it.
There was a problem hiding this comment.
Fixed, though not in that cleanup: it also runs when the drag ends normally and when the caller's callback changes identity, so committing there would fire part-way through a gesture. Added a separate mount-lifetime effect that commits dragWidthRef.current on unmount, reading the callback off a ref. Tests cover both unmounting with a drag in flight and without.
| const onKeyDown = useCallback( | ||
| (event: ReactKeyboardEvent) => { | ||
| // eslint-disable-next-line no-nested-ternary -- a two-key lookup reads worse as a map | ||
| const travel = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0; |
There was a problem hiding this comment.
⛏️ The splitter handles Left/Right only. Home/End (jump to min/max) is part of the ARIA window-splitter pattern and is a couple of lines in this same handler.
There was a problem hiding this comment.
Added. Both jump to the bounds, so they land on the aria-valuemin / aria-valuemax the splitter already announces — which also means they need no RTL mirroring, unlike the arrows. Pulled the key→intent mapping into two small helpers, which retired the no-nested-ternary disable.
| // The arrow that moves the handle the way a drag would widen the panel widens it too, | ||
| // whichever side of the container the interface language anchors it to. | ||
| const step = travel === widenTravel() ? 1 : -1; | ||
| onWidthChange(clampWidth(width + step * KEYBOARD_RESIZE_STEP_PX)); |
There was a problem hiding this comment.
❓ Each arrow press writes straight through to useWebViewState (src/components/InterlinearizerLoader.tsx:394, :687), so holding the key with OS key repeat produces one host write per repeat — the same per-frame write the drag path defers to release. The hook's own doc acknowledges the difference, so this reads as intentional: is the host write cheap enough that the keyboard path does not want the same deferral (commit on keyup, or debounce)?
There was a problem hiding this comment.
Intentional, and not only on cost grounds. displayWidth falls back to width, which comes from useWebViewState — and that hook updates its local state only when onDidUpdateWebView comes back. So on the keyboard path the write is the redraw: commit-on-keyup would leave the handle frozen for the whole hold unless the keyboard path also grew its own in-flight width and a blur commit to go with it. The drag path has no such coupling — it already draws from dragWidth — which is why deferral is free there and isn't here. Left as is; the hook doc now also states the unmount commit.
| [width, onWidthChange, clampWidth], | ||
| ); | ||
|
|
||
| return { displayWidth: dragWidth ?? width, onMouseDown, onKeyDown }; |
There was a problem hiding this comment.
⛏️ displayWidth falls back to the caller's committed width unchanged, and that width is read straight out of WebView state — only the drag and arrow paths clamp. Every reachable value is inside WIDTH_BOUNDS today, but dragWidth ?? clampWidth(width) would make the bounds authoritative and keep aria-valuenow within aria-valuemin/aria-valuemax if the bounds are ever narrowed (or a persisted value ever arrives non-numeric).
There was a problem hiding this comment.
Done — dragWidth ?? clampWidth(width). Test renders at 5000 and asserts both the drawn width and aria-valuenow come back at the maximum.
| selectAnalysis, | ||
| selectAnalysisLanguage, | ||
| selectApprovedGloss, | ||
| selectCatalogRows, |
There was a problem hiding this comment.
⛏️ This import sits between selectApprovedGloss and selectApprovedMorphemes, splitting the two Approved entries.
There was a problem hiding this comment.
Moved after selectApprovedMorphemes.
| /** | ||
| * Returns one row per distinct token analysis in the draft, each carrying the usage data the | ||
| * analysis catalog lists it by, in the analysis's own order. Narrowing and ordering are the | ||
| * caller's ({@link applyCatalogQuery}), so a keystroke re-runs only that pass. |
There was a problem hiding this comment.
⛏️ {@link applyCatalogQuery} has no resolvable target in this file — only the CatalogRow type is imported from ../utils/analysis-query (line 37), while every other {@link} in this module names an imported symbol. Either import the symbol or reword to state the guarantee ("narrowing and ordering are the caller's") without naming the collaborator.
There was a problem hiding this comment.
Reworded rather than imported. comment-rules has a "link the contract, not the collaborator" rule that makes naming the callee wrong even where it would resolve, so the sentence now just states the guarantee: "Narrowing and ordering are the caller's, so a keystroke re-runs only that pass."
| // The store below waits for the draft: it seeds on mount alone, and the draft version that | ||
| // remounts it does not bump when the load completes. Nothing is lost by waiting — while the | ||
| // draft loads there is only ever a placeholder or an error panel to show. | ||
| <div className="tw:flex tw:flex-col tw:flex-1 tw:min-h-0">{loadingOrErrorPanel}</div> |
There was a problem hiding this comment.
⛏️ This branch lost the data-testid="book-fade-wrapper" element and its opacity transition. Harmless today — draft loading only happens at mount, when fadePhase is idle — but any future path that leaves isDraftLoading true during a cross-book fade would render the placeholder outside the curtain, and a test reaching for book-fade-wrapper while the draft loads would throw rather than fail informatively.
There was a problem hiding this comment.
Fixed by extracting a BookFadeWrapper component both branches render, so the draft-loading branch gets the test id and the fade back without a second copy of the zero-duration logic. The store still sits above the curtain, so the catalog stays undimmed. Copying the JSX instead would have left the fadePhase === 'out' arm unreachable on that branch and broken the coverage gate, which is what pushed it to a shared component.
The splitter gains Home/End and commits a drag the panel is unmounted holding; rows memoize and return to the inline usage cap when collapsed.
alex-rawlings-yyc
left a comment
There was a problem hiding this comment.
@alex-rawlings-yyc made 10 comments.
Reviewable status: 0 of 20 files reviewed, 10 unresolved discussions (waiting on imnasnainaec).
| selectAnalysis, | ||
| selectAnalysisLanguage, | ||
| selectApprovedGloss, | ||
| selectCatalogRows, |
There was a problem hiding this comment.
Moved after selectApprovedMorphemes.
| /** | ||
| * Returns one row per distinct token analysis in the draft, each carrying the usage data the | ||
| * analysis catalog lists it by, in the analysis's own order. Narrowing and ordering are the | ||
| * caller's ({@link applyCatalogQuery}), so a keystroke re-runs only that pass. |
There was a problem hiding this comment.
Reworded rather than imported. comment-rules has a "link the contract, not the collaborator" rule that makes naming the callee wrong even where it would resolve, so the sentence now just states the guarantee: "Narrowing and ordering are the caller's, so a keystroke re-runs only that pass."
| * Each row owns its own layout so that its detail can be nested inside it. One element per analysis | ||
| * is what lets the list window and be walked by keyboard a row at a time. | ||
| */ | ||
| export default function CatalogRowView({ |
There was a problem hiding this comment.
Done — MemoizedCatalogRowView, matching the TokenChip / SegmentView pattern. Your premise checks out: applyCatalogQuery is filter + toSorted, so rows keep identity, and the rest of the props are stable (onUsageSelect is a useCallback, localizedStrings comes off the hoisted key array). Filed the windowing half against #231 rather than duplicating it here.
| const hiddenUsageCount = row.usages.length - visibleUsages.length; | ||
|
|
||
| const usageCountLabel = localizedStrings['%interlinearizer_analysisCatalog_usageCount%']; | ||
| const usageCountInBookLabel = formatReplacementString( |
There was a problem hiding this comment.
Not deliberate — good catch. Switched to Canon.bookIdToEnglishName, so it reads "Uses in Genesis" while the usage links stay references. Same treatment and same caveat as SegmentListView.tsx:220 (a platform-localized name needs PAPI wiring this view doesn't have). Covered by a test that layers a resolved value over the key-as-value mock.
| {hiddenUsageCount > 0 && ( | ||
| <Button | ||
| data-testid="catalog-usages-show-all" | ||
| onClick={() => setShowsAllUsages(true)} |
There was a problem hiding this comment.
Fixed. A handleToggle clears showsAllUsages alongside the expand flag, so collapsing returns the row to the inline cap. Went with the reset rather than a "show fewer" affordance — the expander is already gone by then, so there's nothing to pair it with.
| // The store below waits for the draft: it seeds on mount alone, and the draft version that | ||
| // remounts it does not bump when the load completes. Nothing is lost by waiting — while the | ||
| // draft loads there is only ever a placeholder or an error panel to show. | ||
| <div className="tw:flex tw:flex-col tw:flex-1 tw:min-h-0">{loadingOrErrorPanel}</div> |
There was a problem hiding this comment.
Fixed by extracting a BookFadeWrapper component both branches render, so the draft-loading branch gets the test id and the fade back without a second copy of the zero-duration logic. The store still sits above the curtain, so the catalog stays undimmed. Copying the JSX instead would have left the fadePhase === 'out' arm unreachable on that branch and broken the coverage gate, which is what pushed it to a shared component.
|
|
||
| window.addEventListener('mousemove', handleMouseMove); | ||
| window.addEventListener('mouseup', handleMouseUp); | ||
| return () => { |
There was a problem hiding this comment.
Fixed, though not in that cleanup: it also runs when the drag ends normally and when the caller's callback changes identity, so committing there would fire part-way through a gesture. Added a separate mount-lifetime effect that commits dragWidthRef.current on unmount, reading the callback off a ref. Tests cover both unmounting with a drag in flight and without.
| const onKeyDown = useCallback( | ||
| (event: ReactKeyboardEvent) => { | ||
| // eslint-disable-next-line no-nested-ternary -- a two-key lookup reads worse as a map | ||
| const travel = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : 0; |
There was a problem hiding this comment.
Added. Both jump to the bounds, so they land on the aria-valuemin / aria-valuemax the splitter already announces — which also means they need no RTL mirroring, unlike the arrows. Pulled the key→intent mapping into two small helpers, which retired the no-nested-ternary disable.
| // The arrow that moves the handle the way a drag would widen the panel widens it too, | ||
| // whichever side of the container the interface language anchors it to. | ||
| const step = travel === widenTravel() ? 1 : -1; | ||
| onWidthChange(clampWidth(width + step * KEYBOARD_RESIZE_STEP_PX)); |
There was a problem hiding this comment.
Intentional, and not only on cost grounds. displayWidth falls back to width, which comes from useWebViewState — and that hook updates its local state only when onDidUpdateWebView comes back. So on the keyboard path the write is the redraw: commit-on-keyup would leave the handle frozen for the whole hold unless the keyboard path also grew its own in-flight width and a blur commit to go with it. The drag path has no such coupling — it already draws from dragWidth — which is why deferral is free there and isn't here. Left as is; the hook doc now also states the unmount commit.
| [width, onWidthChange, clampWidth], | ||
| ); | ||
|
|
||
| return { displayWidth: dragWidth ?? width, onMouseDown, onKeyDown }; |
There was a problem hiding this comment.
Done — dragWidth ?? clampWidth(width). Test renders at 5000 and asserts both the drawn width and aria-valuenow come back at the maximum.
A drag seeded from a committed width outside the bounds drew the panel past its announced maximum on the press alone. The jump and arrow keys now skip writing a width the panel already holds.
Hoists the analysis store above the cross-book fade curtain so a jump to a
usage in another book cannot dim the panel. Search, sort, filter, and row
windowing are deferred to #231.
This change is
Summary by CodeRabbit
New Features
Bug Fixes