Skip to content

Refactor copy paths UI into unified component with gesture support#87

Merged
fgilio merged 7 commits intomainfrom
claude/improve-copy-path-buttons-aoeQO
May 6, 2026
Merged

Refactor copy paths UI into unified component with gesture support#87
fgilio merged 7 commits intomainfrom
claude/improve-copy-path-buttons-aoeQO

Conversation

@fgilio
Copy link
Copy Markdown
Owner

@fgilio fgilio commented May 6, 2026

Summary

Consolidates copy-paths functionality into a single reusable Alpine component that supports both bulk (multi-file) and single-file modes, with improved gesture handling including left-click and long-press interactions.

Key Changes

  • New Alpine component (copy-paths-button.js): Unified factory supporting two modes:

    • bulk mode: copies filtered visible files from parent scope
    • single mode: copies a fixed path passed as parameter
  • Gesture handling:

    • Left-click copies relative paths immediately
    • Right-click or 400ms long-press opens menu with three copy options (file names, relative paths, full paths)
    • Proper timer cancellation on mouseup/mouseleave to prevent accidental menu opens
  • New Blade component (copy-paths-button.blade.php): Replaces copy-paths-menu.blade.php with support for both modes and improved accessibility

  • Removed legacy component: copy-paths-menu.blade.php no longer needed

  • Removed inline logic: Deleted copyVisibleFilePaths() method from review-page Alpine root, now handled by the component

  • Updated consumers:

    • Sidebar: uses bulk mode via new component
    • Status strip: uses bulk mode via new component
    • File header: uses single mode with fixed path
  • Comprehensive test suite (copy-paths-button.test.js): 151 lines covering:

    • Click behavior and path formatting (relative, basename, full)
    • Long-press timer mechanics and cancellation
    • Label pluralization
    • Both bulk and single modes

Implementation Details

  • Uses UMD pattern for the Alpine factory to support both module and global contexts
  • Gesture wrapper sits inside <flux:dropdown> wrapping only the trigger button, allowing menu items to work normally
  • Leverages Flux's _popoverable.setState() API for opening the dropdown menu
  • Fallback to native showPopover() API if Flux integration unavailable
  • Test helpers mock the full Alpine scope including dispatch, refs, and state management

https://claude.ai/code/session_01SSdfH6M5HLtWjeLJTDa3Ck

Summary by CodeRabbit

  • New Features

    • Unified copy-paths button component (single/bulk): left-click copies relative paths; long-press/right-click opens menu to copy name/relative/full. Replaces previous copy-menu UI across file headers, status strip, and sidebars.
  • Tests

    • Added unit and end-to-end tests for copy-paths behavior, plus screenshot-focused browser tests.
  • Chores / CI

    • New screenshots workflow and publish helper; test-suite and script updates to support screenshot generation and publishing.
  • Documentation

    • Added guidance for screenshot tests.

claude added 2 commits May 5, 2026 20:24
…ck menu

Both the file-header copy button and the bulk sidebar/status-strip
buttons now share one <x-copy-paths-button> component. Left-click copies
relative path(s); right-click or 400 ms long-press opens the existing
3-option menu (file names / relative paths / full paths). Tooltip surfaces
the primary action plus a "Right-click for options" hint.

The capture-phase wrapper sits inside <flux:dropdown> around just the
trigger, so menu-item clicks bypass it. Long-press / right-click open the
menu via the overlay's _popoverable.setState — <ui-dropdown> doesn't expose
its own open(); finding the overlay via lastElementChild avoids matching
<flux:tooltip.content> on its popover attribute.
- Drop orphaned copyVisibleFilePaths() from review-page (callers all moved
  to the new factory's copyAs()).
- Trim duplicate Flux-internals doc block in copy-paths-button.js; the
  WHY now lives only on openMenu() where it's non-obvious.
- Restore real timers between describe blocks in the JS test.
- Use toEqualCanonicalizing in the browser tests so exact membership and
  count assert in one expression.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds a unified Alpine.js factory copyPathsButton and Blade component <x-copy-paths-button> (single/bulk modes), replaces the removed x-copy-paths-menu usages, adds JS Vitest and browser/screenshot tests, and introduces a screenshots CI workflow and publishing helper script.

Changes

Copy-Paths Button & Integration

Layer / File(s) Summary
Data / Constants
public/js/copy-paths-button.js
Adds LONG_PRESS_MS and KIND_LABEL constants for timing and UI labels.
Component Implementation
public/js/copy-paths-button.js
Implements copyPathsButton({ mode='bulk', singlePath='', repoPath='' }): derives paths (single vs. filtered entries), handles left-click copy, long-press menu, copyAs(kind), openMenu(), cancelLongPress(), and autoInstall(root); exports factory, autoInstall, and LONG_PRESS_MS.
Blade Component
resources/views/components/copy-paths-button.blade.php
New Blade component wiring x-data="copyPathsButton(...)", rendering trigger, tooltip, and dropdown items; includes copy-paths-button.js.
Removal of Old UI
resources/views/components/copy-paths-menu.blade.php
Deleted legacy copy-paths-menu Blade component and its markup.
View Integrations
resources/views/components/diff/file-header.blade.php, resources/views/components/status-strip.blade.php, resources/views/pages/⚡review-page.blade.php, resources/views/livewire/⚡diff-file.blade.php
Replaced prior x-copy-paths-menu / inline helper with <x-copy-paths-button>; added :repo-path bindings; removed copyVisibleFilePaths helper.
Unit / JS Tests
tests/Js/copy-paths-button.test.js
New Vitest suite covering bulk/single modes, click vs long-press behavior, menu actions, timer cancellation, label pluralization, and dropdown mocks.
Browser / Screenshot Tests
tests/Browser/FileInteractionTest.php, tests/Screenshots/CopyPathsButtonScreenshotTest.php, tests/Screenshots/CLAUDE.md, tests/Pest.php
Updated browser tests to use test-id triggers; added screenshot tests and docs; broadened Pest Browser suite to include Screenshots.
CI Screenshots Workflow & Helpers
.github/workflows/screenshots.yml, .github/scripts/publish-screenshots.sh, composer.json, phpunit.xml, .gitignore
Adds workflow to run screenshot tests, a publish script that pushes PNGs to a screenshots branch and updates a sticky PR comment, composer script test:screenshots, phpunit Screenshots suite entry, and case rename in .gitignore.

Sequence Diagram

sequenceDiagram
    participant User
    participant Component as copyPathsButton
    participant Clipboard as Clipboard/Toast

    User->>Component: Left-click
    Component->>Component: onClick() -> copyAs('relative')
    Component->>Clipboard: dispatch copy-to-clipboard with relative paths + toast
    Clipboard-->>User: show toast / clipboard updated

    User->>Component: Press & hold (> LONG_PRESS_MS)
    Component->>Component: onMouseDown() -> start long-press timer
    Component->>Component: openMenu() (timer fires)
    User->>Component: Select option (e.g., 'full')
    Component->>Component: copyAs('full')
    Component->>Clipboard: dispatch copy-to-clipboard with full paths + toast
    Clipboard-->>User: show toast / clipboard updated
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • fgilio/rfa#72: Modifies the diff file header and copy-paths UI; related to integrations and copy-to-clipboard behavior.

Poem

A button waits, then springs to give,
Click copies paths so you can live.
Hold a while, choices unfold,
Name, relative, full—three told.
Toasts sing softly: clipboard gold. ✨📋

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main change: refactoring copy paths UI into a unified component with gesture support (left-click, right-click, long-press).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
public/js/copy-paths-button.js (1)

104-107: ⚡ Quick win

Add a guard flag to prevent autoInstall() from re-registering during SPA navigation.

The autoInstall() function currently lacks a sentinel to prevent re-execution. On Livewire SPA navigation, this script can run again, stacking listeners and re-registering the Alpine component.

Suggested fix
 function autoInstall(root) {
+    if (root.__copyPathsButtonAttached) {
+        return;
+    }
+    root.__copyPathsButtonAttached = true;
+
     const init = () => root.Alpine.data('copyPathsButton', copyPathsButton);
     root.Alpine ? init() : root.document.addEventListener('alpine:init', init);
 }

Per guideline: "Guard against SPA-navigation re-execution in Livewire by using a guard flag pattern: if (window.__fooAttached) return; window.__fooAttached = true;"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/js/copy-paths-button.js` around lines 104 - 107, The autoInstall
function can run multiple times during Livewire SPA navigation and should use a
guard flag to prevent re-registration: at the top of autoInstall add a global
sentinel check (e.g. window.__copyPathsButtonAttached) that returns early if
set, and set it to true before registering the Alpine component; update the body
that calls root.Alpine.data('copyPathsButton', copyPathsButton) (and the
document event listener path) only when the sentinel was not previously set so
copyPathsButton and its listeners are registered exactly once.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@public/js/copy-paths-button.js`:
- Around line 104-107: The autoInstall function can run multiple times during
Livewire SPA navigation and should use a guard flag to prevent re-registration:
at the top of autoInstall add a global sentinel check (e.g.
window.__copyPathsButtonAttached) that returns early if set, and set it to true
before registering the Alpine component; update the body that calls
root.Alpine.data('copyPathsButton', copyPathsButton) (and the document event
listener path) only when the sentinel was not previously set so copyPathsButton
and its listeners are registered exactly once.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b720e9db-5118-4815-8d10-a006638ec8f4

📥 Commits

Reviewing files that changed from the base of the PR and between 8eb56d4 and fc2dff4.

📒 Files selected for processing (8)
  • public/js/copy-paths-button.js
  • resources/views/components/copy-paths-button.blade.php
  • resources/views/components/copy-paths-menu.blade.php
  • resources/views/components/diff/file-header.blade.php
  • resources/views/components/status-strip.blade.php
  • resources/views/pages/⚡review-page.blade.php
  • tests/Browser/FileInteractionTest.php
  • tests/Js/copy-paths-button.test.js
💤 Files with no reviewable changes (1)
  • resources/views/components/copy-paths-menu.blade.php

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc2dff4fe5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread public/js/copy-paths-button.js Outdated
Comment on lines +46 to +47
if (kind === 'name') return this.pathBase(p);
if (kind === 'full') return this.buildFullPath(p);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide fallback helpers for single-file copy menus

When the shared diff-file header is rendered outside the review page, e.g. in resources/views/pages/⚡context-page.blade.php, its ancestor Alpine scope does not define pathBase() or buildFullPath(); it only defines scrollToContextFile() and init(). Right-clicking the new single-file copy button there advertises “Copy file names” / “Copy full paths”, but choosing either option reaches these calls and throws instead of copying. The single-mode component needs local fallbacks or the required helpers/values passed in rather than assuming the review-page scope is always present.

Useful? React with 👍 / 👎.

claude added 2 commits May 6, 2026 03:07
The single-mode button is rendered inside <x-diff.file-header>, which is
also used by ⚡context-page. That page's Alpine root only defines
scrollToContextFile / init — no pathBase / buildFullPath / repoPath. With
the previous factory, right-clicking and choosing "Copy file names" or
"Copy full paths" would throw `this.pathBase is not a function` /
`this.buildFullPath is not a function` (Codex P2).

Fix: drop the factory's reliance on inherited helpers. Basename is a
local lastIndexOf split. Full-path uses an init-param repoPath, falling
back to inherited this.repoPath for bulk mode (which is review-page
only). file-header now takes a :repo-path prop, fed from ⚡diff-file's
$repoPath, and forwards it to the copy-paths-button.

Also add the project's standard `__copyPathsButtonAttached` sentinel to
autoInstall so SPA navigation doesn't double-register the Alpine
component (CodeRabbit nit; matches diff-file.js / branch-explorer.js /
keymap-store.js / smart-poll.js / etc.).

JS tests gain a singleScope() helper with no parent helpers in scope,
locking down the regression for "name", "full", empty repoPath
fall-through, and trailing-slash normalization.
Reviewers can now see PR-scoped UI screenshots inline in a sticky comment.

- tests/Screenshots/*ScreenshotTest.php captures named PNGs via Pest's
  browser plugin; calls global visit() so BrowserTestIdentifier picks them
  up outside tests/Browser/. Wired with the Browsable + CreatesTestRepo
  traits and a new Screenshots phpunit testsuite + composer test:screenshots.
- .github/workflows/screenshots.yml runs the suite on PRs (skipped for
  forks since GITHUB_TOKEN is read-only there), then runs
  publish-screenshots.sh to push the PNGs to a screenshots orphan branch
  under pr-<n>/<sha>/ and post/update a sticky comment referencing them
  via raw.githubusercontent.com.
- First consumer: copy-paths-button bulk + single-file menu states, to
  illustrate the pattern for future PRs.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

Screenshots

Updated for e81fb8c.

copy-paths-bulk-default copy-paths-bulk-default
copy-paths-bulk-menu-open copy-paths-bulk-menu-open
copy-paths-single-menu-open copy-paths-single-menu-open

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/scripts/publish-screenshots.sh:
- Around line 78-87: Replace the word-splitting for-loop that uses command
substitution over ls with a null-delimited find/while read loop to safely
iterate filenames containing spaces or special characters: use find "$STAGE"
-maxdepth 1 -type f -name '*.png' -print0 piped into while IFS= read -r -d ''
png; do ... done, derive the basename and strip the .png suffix into name
(instead of parameter expansion on the ls output), and continue to build url
using REPO, BRANCH and SUBPATH as before; ensure you use read -r -d '' and quote
variable expansions (e.g., "$png", "$name") to avoid reintroducing
word-splitting.

In `@resources/views/components/copy-paths-button.blade.php`:
- Around line 33-37: The context menu is only reachable via pointer events; add
keyboard parity by wiring Enter/Space (and optionally ArrowDown or ContextMenu
key) to the same handlers: call openMenu() on keydown.enter/keydown.space and
invoke onClick($event) on keydown.enter/keydown.space where immediate copy is
expected, and ensure onKeyup/keydown handlers call cancelLongPress() as
appropriate; update the same for the other instance referenced (lines 44-45) so
keyboard users can open the copy-options menu and select name/full-path without
a mouse.

In `@tests/Js/copy-paths-button.test.js`:
- Around line 43-50: Pin document focus and visibility in these DOM-timer tests
by stubbing document.hasFocus and overriding document.hidden in the test
lifecycle: in the beforeEach that calls vi.useFakeTimers() and sets component =
attach(bulkScope(), 'bulk'), add a vi.spyOn(document,
'hasFocus').mockReturnValue(true) and Object.defineProperty(document, 'hidden',
{ value: false, configurable: true }) to force focused/visible state; in the
afterEach that calls vi.useRealTimers(), restore the hasFocus spy (or call
vi.restoreAllMocks()) and revert document.hidden (either by deleting the
configurable property or restoring the saved original value) so subsequent tests
aren’t affected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 71cb4ffe-8d77-4482-a39d-308f0d4325b1

📥 Commits

Reviewing files that changed from the base of the PR and between fc2dff4 and 5008623.

📒 Files selected for processing (13)
  • .github/scripts/publish-screenshots.sh
  • .github/workflows/screenshots.yml
  • .gitignore
  • composer.json
  • phpunit.xml
  • public/js/copy-paths-button.js
  • resources/views/components/copy-paths-button.blade.php
  • resources/views/components/diff/file-header.blade.php
  • resources/views/livewire/⚡diff-file.blade.php
  • tests/Js/copy-paths-button.test.js
  • tests/Pest.php
  • tests/Screenshots/CLAUDE.md
  • tests/Screenshots/CopyPathsButtonScreenshotTest.php

Comment thread .github/scripts/publish-screenshots.sh Outdated
Comment thread resources/views/components/copy-paths-button.blade.php
Comment on lines +43 to +50
beforeEach(() => {
vi.useFakeTimers();
component = attach(bulkScope(), 'bulk');
});

afterEach(() => {
vi.useRealTimers();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stabilize DOM tests by pinning focus/visibility state.

These tests use timers and UI gestures, but they don’t override document.hasFocus / document.hidden, which can introduce happy-dom-dependent flakes.

Suggested patch
 beforeEach(() => {
     vi.useFakeTimers();
+    vi.spyOn(document, 'hasFocus').mockReturnValue(false);
+    Object.defineProperty(document, 'hidden', {
+        configurable: true,
+        get: () => false,
+    });
     component = attach(bulkScope(), 'bulk');
 });
 
 afterEach(() => {
+    vi.restoreAllMocks();
     vi.useRealTimers();
 });

As per coding guidelines, “Override happy-dom's default focus and visibility states per test using vi.spyOn(document, 'hasFocus')... and Object.defineProperty(document, 'hidden', ...).”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
beforeEach(() => {
vi.useFakeTimers();
component = attach(bulkScope(), 'bulk');
});
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
});
component = attach(bulkScope(), 'bulk');
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Js/copy-paths-button.test.js` around lines 43 - 50, Pin document focus
and visibility in these DOM-timer tests by stubbing document.hasFocus and
overriding document.hidden in the test lifecycle: in the beforeEach that calls
vi.useFakeTimers() and sets component = attach(bulkScope(), 'bulk'), add a
vi.spyOn(document, 'hasFocus').mockReturnValue(true) and
Object.defineProperty(document, 'hidden', { value: false, configurable: true })
to force focused/visible state; in the afterEach that calls vi.useRealTimers(),
restore the hasFocus spy (or call vi.restoreAllMocks()) and revert
document.hidden (either by deleting the configurable property or restoring the
saved original value) so subsequent tests aren’t affected.

Fixes:
- Menu items now read 'Copy file name' / 'Copy relative path' / 'Copy full
  path' in single mode and 'Copy N file names' / 'Copy N relative paths' /
  'Copy N full paths' in bulk, matching the existing primaryLabel pattern.
  Implemented as a single _label(noun) helper exposed via three getters
  (nameLabel/relativeLabel/fullLabel) bound to the menu items via x-text.
  Updated browser/screenshot tests' name= filters and added JS unit tests
  covering both modes.

Reviewer follow-ups:
- Keyboard parity: Shift+F10 and the ContextMenu key now open the menu
  alongside right-click. Tooltip body updated.
- publish-screenshots.sh: switched the screenshot iteration loop from
  ls + word-splitting to a null-delimited find loop (shellcheck SC2012).
github-actions Bot added a commit that referenced this pull request May 6, 2026
`clicking line with existing draft re-opens it` was reading inputValue()
in the same statement that triggered the form remount, racing the
Livewire round-trip that hydrates `formBody`. The Pest browser wrapper's
waitForFunction is single-shot (see Csrf419RecoveryTest), so use the
project pattern: poll the locator's inputValue() from PHP until it
populates, with a 5s ceiling. Confirmed stable under x5 local re-runs.
github-actions Bot added a commit that referenced this pull request May 6, 2026
Previous attempt polled inputValue() but the textarea opened from a
fresh form (formBody='') because the line-mousedown handler reads
\$wire.fileComments *synchronously* to find the existing draft. The
draft-comment testid renders after the server morph but the wire-property
sync to the client-side \$wire proxy can race the next click on a loaded
CI shard. waitForEvent('networkidle') after the badge appears covers
both halves of the round-trip end-to-end. 3/3 local re-runs.
github-actions Bot added a commit that referenced this pull request May 6, 2026
@fgilio fgilio merged commit 6676fdd into main May 6, 2026
15 of 16 checks passed
fgilio added a commit that referenced this pull request May 6, 2026
Sibling symlink convention enforced by the agents-symlinks pre-commit
hook; missed when tests/Screenshots/CLAUDE.md landed in #87.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants