diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index a2f2d48f3..ae46f4dc0 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -11,6 +11,7 @@ import { CommandEmpty, CommandItem } from '@/components/ui/command' +import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { prepareQuickOpenFiles, rankQuickOpenFiles } from '@/components/quick-open-search' import { useRuntimeFileListForWorktree } from '@/components/quick-open-file-list' import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' @@ -146,9 +147,7 @@ export default function QuickOpen(): React.JSX.Element | null { ) : ( filtered.map((item) => { - const lastSlash = item.path.lastIndexOf('/') - const dir = lastSlash >= 0 ? item.path.slice(0, lastSlash) : '' - const filename = item.path.slice(lastSlash + 1) + const { directory, filename } = splitTrailingSegment(item.path) const FileIcon = getFileTypeIcon(item.path) return ( @@ -156,11 +155,24 @@ export default function QuickOpen(): React.JSX.Element | null { key={item.path} value={item.path} onSelect={() => handleSelect(item.path)} - className="flex items-center gap-2 px-3 py-1.5" + className="min-w-0 p-0" > - - {filename} - {dir && {dir}} + {/* Why: the trigger is this inner element, not the CommandItem. + cmdk sets its own onPointerMove after spreading props, which + drops the one Radix needs to open the tooltip. */} + +
+ + {/* shrink-0 + max-w-full: the directory gives up all of its + width before the filename loses a character. */} + + {filename} + + {directory ? ( + {directory} + ) : null} +
+
) }) diff --git a/src/renderer/src/components/file-path-cursor-tooltip.test.ts b/src/renderer/src/components/file-path-cursor-tooltip.test.ts new file mode 100644 index 000000000..d33c6247b --- /dev/null +++ b/src/renderer/src/components/file-path-cursor-tooltip.test.ts @@ -0,0 +1,55 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { cursorTooltipOffsets, splitTrailingSegment } from './file-path-cursor-tooltip' + +describe('cursorTooltipOffsets', () => { + const row = { bottom: 120, left: 400 } + + it('places the tooltip under the cursor rather than the row', () => { + // Row-anchored placement would be align 0; the cursor is 64px into the row. + expect(cursorTooltipOffsets({ x: 464, y: 108 }, row)).toEqual({ align: 64, side: 6 }) + }) + + it('tracks the cursor across the row', () => { + const left = cursorTooltipOffsets({ x: 410, y: 108 }, row) + const right = cursorTooltipOffsets({ x: 610, y: 108 }, row) + + expect(right.align - left.align).toBe(200) + expect(right.side).toBe(left.side) + }) + + it('stays anchored to the cursor when the row moves under it', () => { + // The dropdown reflows while results stream in; re-measuring the row must + // keep the tooltip on the cursor, not drag it along with the row. + const before = cursorTooltipOffsets({ x: 464, y: 108 }, row) + const after = cursorTooltipOffsets({ x: 464, y: 108 }, { bottom: 148, left: 576 }) + + expect(row.left + before.align).toBe(576 + after.align) + expect(row.bottom + before.side).toBe(148 + after.side) + }) +}) + +describe('splitTrailingSegment', () => { + it('keeps the separator on the directory', () => { + expect(splitTrailingSegment('app/src/SecondaryNav.tsx')).toEqual({ + directory: 'app/src/', + filename: 'SecondaryNav.tsx' + }) + }) + + it('does not duplicate the root separator', () => { + expect(splitTrailingSegment('/foo')).toEqual({ directory: '/', filename: 'foo' }) + }) + + it('preserves Windows separators', () => { + expect(splitTrailingSegment('C:\\repo\\src\\a.ts')).toEqual({ + directory: 'C:\\repo\\src\\', + filename: 'a.ts' + }) + }) + + it('returns no directory for a bare filename', () => { + expect(splitTrailingSegment('README.md')).toEqual({ directory: '', filename: 'README.md' }) + }) +}) diff --git a/src/renderer/src/components/file-path-cursor-tooltip.tsx b/src/renderer/src/components/file-path-cursor-tooltip.tsx new file mode 100644 index 000000000..d156d3a93 --- /dev/null +++ b/src/renderer/src/components/file-path-cursor-tooltip.tsx @@ -0,0 +1,100 @@ +import React from 'react' +import { Slot } from 'radix-ui' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +// Clears the pointer without putting the label under the cursor's own hotspot. +const CURSOR_TOOLTIP_GAP = 18 + +/** + * Radix positions the tooltip against the trigger, so a cursor position has to + * be restated as offsets from the trigger's bottom-left corner to land under + * the pointer. + */ +export function cursorTooltipOffsets( + pointer: { x: number; y: number }, + trigger: { bottom: number; left: number } +): { align: number; side: number } { + return { + align: pointer.x - trigger.left, + side: pointer.y + CURSOR_TOOLTIP_GAP - trigger.bottom + } +} + +/** + * Splits a path for filename-first display, keeping the separator attached to + * the directory so `/foo` renders `foo` + `/` and Windows paths keep `\`. + */ +export function splitTrailingSegment(path: string): { directory: string; filename: string } { + const separatorIndex = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + + return separatorIndex === -1 + ? { directory: '', filename: path } + : { directory: path.slice(0, separatorIndex + 1), filename: path.slice(separatorIndex + 1) } +} + +/** + * Wraps a single result row so its full path appears in a system-styled tooltip + * anchored under the cursor. The child is the trigger; it receives a ref and a + * pointer-move handler. + */ +export function FilePathCursorTooltip({ + children, + path +}: { + children: React.ReactNode + path: string +}): React.JSX.Element { + const triggerRef = React.useRef(null) + const pointerRef = React.useRef<{ x: number; y: number } | null>(null) + const [open, setOpen] = React.useState(false) + const [offset, setOffset] = React.useState({ align: 0, side: 0 }) + + // Why: the offsets are only valid for the trigger's position at the moment + // Radix positions it. Results stream in and move rows while the open delay + // runs, so re-measure on open rather than trusting the pointer-move rect. + React.useLayoutEffect(() => { + const rect = triggerRef.current?.getBoundingClientRect() + const pointer = pointerRef.current + if (!open || !rect || !pointer) { + return + } + const next = cursorTooltipOffsets(pointer, rect) + setOffset((current) => + current.align === next.align && current.side === next.side ? current : next + ) + }, [open]) + + return ( + + + { + if (open) { + return + } + pointerRef.current = { x: event.clientX, y: event.clientY } + }} + > + {children} + + + {/* Anchored under the cursor the way a system tooltip is, rather than to + the row. Collision shifting still applies near the viewport edge. */} + + {path} + + + ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx index ce79ccea1..616b3caa0 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import type { ActiveOption } from './tab-create-entry-active-option' -import { cursorTooltipOffsets, EntryActionRow } from './TabBarCreateEntryRow' +import { EntryActionRow } from './TabBarCreateEntryRow' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -89,30 +89,3 @@ describe('EntryActionRow', () => { expect(container.textContent).not.toContain('/') }) }) - -describe('cursorTooltipOffsets', () => { - const row = { bottom: 120, left: 400 } - - it('places the tooltip under the cursor rather than the row', () => { - // Row-anchored placement would be align 0; the cursor is 64px into the row. - expect(cursorTooltipOffsets({ x: 464, y: 108 }, row)).toEqual({ align: 64, side: 6 }) - }) - - it('tracks the cursor across the row', () => { - const left = cursorTooltipOffsets({ x: 410, y: 108 }, row) - const right = cursorTooltipOffsets({ x: 610, y: 108 }, row) - - expect(right.align - left.align).toBe(200) - expect(right.side).toBe(left.side) - }) - - it('stays anchored to the cursor when the row moves under it', () => { - // The dropdown reflows while results stream in; re-measuring the row must - // keep the tooltip on the cursor, not drag it along with the row. - const before = cursorTooltipOffsets({ x: 464, y: 108 }, row) - const after = cursorTooltipOffsets({ x: 464, y: 108 }, { bottom: 148, left: 576 }) - - expect(row.left + before.align).toBe(576 + after.align) - expect(row.bottom + before.side).toBe(148 + after.side) - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index c6d0f66ed..1f1e27d1f 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -2,29 +2,12 @@ import React from 'react' import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { FilePathCursorTooltip, splitTrailingSegment } from '@/components/file-path-cursor-tooltip' import { translate } from '@/i18n/i18n' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' -// Clears the macOS pointer without putting the label under the cursor's own hotspot. -const CURSOR_TOOLTIP_GAP = 18 - -/** - * Radix positions the tooltip against the row, so a cursor position has to be - * restated as offsets from the row's bottom-left corner to land under the pointer. - */ -export function cursorTooltipOffsets( - pointer: { x: number; y: number }, - row: { bottom: number; left: number } -): { align: number; side: number } { - return { - align: pointer.x - row.left, - side: pointer.y + CURSOR_TOOLTIP_GAP - row.bottom - } -} - // Index-based (not the option id, which may contain spaces/slashes from file // paths) so it is always a valid aria-activedescendant IDREF. export function resultOptionDomId(index: number): string { @@ -58,43 +41,13 @@ export function EntryActionRow({ selected: boolean }): React.JSX.Element { const presentation = getActionPresentation(option) - const rowRef = React.useRef(null) - const pointerRef = React.useRef<{ x: number; y: number } | null>(null) - const [tooltipOpen, setTooltipOpen] = React.useState(false) - const [pointerOffset, setPointerOffset] = React.useState({ align: 0, side: 0 }) - - // Why: Radix positions against the row, so the offsets are only valid for the - // row's position at the moment it positions. Results stream in while the open - // delay runs, so re-measure on open rather than trusting the pointer-move rect. - React.useLayoutEffect(() => { - const rect = rowRef.current?.getBoundingClientRect() - const pointer = pointerRef.current - if (!tooltipOpen || !rect || !pointer) { - return - } - const next = cursorTooltipOffsets(pointer, rect) - setPointerOffset((current) => - current.align === next.align && current.side === next.side ? current : next - ) - }, [tooltipOpen]) const row = (