/ src / hooks / useCopyOnSelect.ts
useCopyOnSelect.ts
 1  import { useEffect, useRef } from 'react'
 2  import { useTheme } from '../components/design-system/ThemeProvider.js'
 3  import type { useSelection } from '../ink/hooks/use-selection.js'
 4  import { getGlobalConfig } from '../utils/config.js'
 5  import { getTheme } from '../utils/theme.js'
 6  
 7  type Selection = ReturnType<typeof useSelection>
 8  
 9  /**
10   * Auto-copy the selection to the clipboard when the user finishes dragging
11   * (mouse-up with a non-empty selection) or multi-clicks to select a word/line.
12   * Mirrors iTerm2's "Copy to pasteboard on selection" — the highlight is left
13   * intact so the user can see what was copied. Only fires in alt-screen mode
14   * (selection state is ink-instance-owned; outside alt-screen, the native
15   * terminal handles selection and this hook is a no-op via the ink stub).
16   *
17   * selection.subscribe fires on every mutation (start/update/finish/clear/
18   * multiclick). Both char drags and multi-clicks set isDragging=true while
19   * pressed, so a selection appearing with isDragging=false is always a
20   * drag-finish. copiedRef guards against double-firing on spurious notifies.
21   *
22   * onCopied is optional — when omitted, copy is silent (clipboard is written
23   * but no toast/notification fires). FleetView uses this silent mode; the
24   * fullscreen REPL passes showCopiedToast for user feedback.
25   */
26  export function useCopyOnSelect(
27    selection: Selection,
28    isActive: boolean,
29    onCopied?: (text: string) => void,
30  ): void {
31    // Tracks whether the *previous* notification had a visible selection with
32    // isDragging=false (i.e., we already auto-copied it). Without this, the
33    // finish→clear transition would look like a fresh selection-gone-idle
34    // event and we'd toast twice for a single drag.
35    const copiedRef = useRef(false)
36    // onCopied is a fresh closure each render; read through a ref so the
37    // effect doesn't re-subscribe (which would reset copiedRef via unmount).
38    const onCopiedRef = useRef(onCopied)
39    onCopiedRef.current = onCopied
40  
41    useEffect(() => {
42      if (!isActive) return
43  
44      const unsubscribe = selection.subscribe(() => {
45        const sel = selection.getState()
46        const has = selection.hasSelection()
47        // Drag in progress — wait for finish. Reset copied flag so a new drag
48        // that ends on the same range still triggers a fresh copy.
49        if (sel?.isDragging) {
50          copiedRef.current = false
51          return
52        }
53        // No selection (cleared, or click-without-drag) — reset.
54        if (!has) {
55          copiedRef.current = false
56          return
57        }
58        // Selection settled (drag finished OR multi-click). Already copied
59        // this one — the only way to get here again without going through
60        // isDragging or !has is a spurious notify (shouldn't happen, but safe).
61        if (copiedRef.current) return
62  
63        // Default true: macOS users expect cmd+c to work. It can't — the
64        // terminal's Edit > Copy intercepts it before the pty sees it, and
65        // finds no native selection (mouse tracking disabled it). Auto-copy
66        // on mouse-up makes cmd+c a no-op that leaves the clipboard intact
67        // with the right content, so paste works as expected.
68        const enabled = getGlobalConfig().copyOnSelect ?? true
69        if (!enabled) return
70  
71        const text = selection.copySelectionNoClear()
72        // Whitespace-only (e.g., blank-line multi-click) — not worth a
73        // clipboard write or toast. Still set copiedRef so we don't retry.
74        if (!text || !text.trim()) {
75          copiedRef.current = true
76          return
77        }
78        copiedRef.current = true
79        onCopiedRef.current?.(text)
80      })
81      return unsubscribe
82    }, [isActive, selection])
83  }
84  
85  /**
86   * Pipe the theme's selectionBg color into the Ink StylePool so the
87   * selection overlay renders a solid blue bg instead of SGR-7 inverse.
88   * Ink is theme-agnostic (layering: colorize.ts "theme resolution happens
89   * at component layer, not here") — this is the bridge. Fires on mount
90   * (before any mouse input is possible) and again whenever /theme flips,
91   * so the selection color tracks the theme live.
92   */
93  export function useSelectionBgColor(selection: Selection): void {
94    const [themeName] = useTheme()
95    useEffect(() => {
96      selection.setSelectionBgColor(getTheme(themeName).selectionBg)
97    }, [selection, themeName])
98  }