From c33c1d0855369d9ee9c15dfa47badd8549883ffe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 10 Sep 2026 14:17:25 +0200 Subject: [PATCH] fix(app): spell out a result's path when its label cannot A row shows the file name and its nearest parent, which is not always enough: packages/app/src/utils/index.ts and packages/server/src/utils/index.ts render identically. Pointing at a row, or selecting it, now replaces the elided parent with every directory above the file, so a pointer user reads the exact workspace-relative path without the preview getting a header back. At rest the column stays scannable. The accessible name was already exact and is unchanged. The app's Tooltip was tried first and cannot serve this surface: it portals into the shared overlay root, which is pinned to the browser/overlay plane at z-index 1, while the Command Center renders through React Native Web's Modal at z-index 9999. The tooltip mounts, measures and reports itself visible, but elementFromPoint at its own coordinates returns the row behind it, so a reader never sees it. Every other desktop overlay in the app portals into the overlay root instead of using RN's Modal, which is what docs/floating-panels.md prescribes; moving the Command Center onto it would fix this for every scope and is a larger change than one row affordance. --- .../browser/workspace-content-search.spec.ts | 15 ++++++ .../helpers/workspace-content-search.ts | 35 ++++++++++++ .../workspace-content-search/index.tsx | 53 ++++++++++++------- .../internal/result-path.test.ts | 22 ++++++-- .../internal/result-path.ts | 10 +++- 5 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/app/e2e/browser/workspace-content-search.spec.ts b/packages/app/e2e/browser/workspace-content-search.spec.ts index 02c73b7b74..5c3c539f59 100644 --- a/packages/app/e2e/browser/workspace-content-search.spec.ts +++ b/packages/app/e2e/browser/workspace-content-search.spec.ts @@ -165,3 +165,18 @@ test("opening the same occurrence again navigates the pane it is already showing }) => { await reopenSameOccurrenceAfterMovingAway(page, await withWorkspace()); }); + +import { revealExactPathOnHover } from "../support/helpers/workspace-content-search"; + +test("pointing at a result reveals its exact path when the row label cannot", async ({ + page, + withWorkspace, +}, testInfo) => { + const { panel, rows } = await revealExactPathOnHover(page, await withWorkspace()); + await rows.last().hover(); + await panel.screenshot({ path: testInfo.outputPath("hover-exact-path.png") }); + // Pointing must not disturb the keyboard selection or the Enter contract. + await page.keyboard.press("Enter"); + await expect(panel).toBeHidden(); + await expect(page.getByTestId("file-source-editor")).toContainText("SHARED_UTIL"); +}); diff --git a/packages/app/e2e/support/helpers/workspace-content-search.ts b/packages/app/e2e/support/helpers/workspace-content-search.ts index a0d4c3131d..53e4d4ed7d 100644 --- a/packages/app/e2e/support/helpers/workspace-content-search.ts +++ b/packages/app/e2e/support/helpers/workspace-content-search.ts @@ -241,3 +241,38 @@ async function openReopenTarget(page: Page) { await page.keyboard.press("Enter"); await expect(panel).toBeHidden(); } + +/** + * Two files can share a base name and their nearest parent, so the resting row label cannot name + * them. Pointing at a row, or selecting it, spells the exact workspace-relative path out. + */ +export async function revealExactPathOnHover(page: Page, workspace: CreatedWorkspace) { + for (const relative of [ + "packages/app/src/utils/index.ts", + "packages/server/src/utils/index.ts", + ]) { + const target = path.join(workspace.repoPath, relative); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, `export const shared = "SHARED_UTIL";\n`); + } + await workspace.navigateTo(); + await page.keyboard.press("Meta+Shift+F"); + await page + .getByRole("textbox", { name: "Search saved file contents...", exact: true }) + .fill("SHARED_UTIL"); + + const panel = page.getByTestId("command-center-panel"); + const rows = panel.getByRole("button", { name: /index\.ts:1:\d+/ }); + await expect(rows).toHaveCount(2); + // The accessible name always carries the exact path. + await expect(rows.first()).toHaveAccessibleName(/^packages\/app\/src\/utils\/index\.ts:1:24 /); + await expect(rows.last()).toHaveAccessibleName(/^packages\/server\/src\/utils\/index\.ts:1:24 /); + // The second row is at rest and shows only its nearest parent, which both files share. + await expect(rows.last()).toContainText("…/utils/index.ts:1:24"); + + await rows.last().hover(); + await expect(rows.last()).toContainText("packages/server/src/utils/index.ts:1:24"); + await rows.first().hover(); + await expect(rows.first()).toContainText("packages/app/src/utils/index.ts:1:24"); + return { panel, rows }; +} diff --git a/packages/app/src/command-center/workspace-content-search/index.tsx b/packages/app/src/command-center/workspace-content-search/index.tsx index c294532772..09942b71d2 100644 --- a/packages/app/src/command-center/workspace-content-search/index.tsx +++ b/packages/app/src/command-center/workspace-content-search/index.tsx @@ -232,32 +232,44 @@ function ResultRow({ ); const line = useMemo(() => describeMatchLine(item), [item]); const file = useMemo(() => describeResultPath(item.path), [item.path]); + const location = `${item.path}:${item.line}:${item.columnStart}`; return ( - - {line.before} - {line.match} - {line.after} - - {/* The file name and its coordinates are pinned; only the parent context may shrink, so a - deep path loses its middle rather than the identity the reader is scanning for. */} - - {file.directory ? ( - - {file.directory} - - ) : null} - - {file.name}:{item.line}:{item.columnStart} - - + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => { + // Pointing at a row, or selecting it, spells every parent out; at rest the row shows only + // the nearest one so the column stays scannable. + const parents = hovered || selected ? file.head : file.directory; + return ( + <> + + {line.before} + {line.match} + {line.after} + + {/* The name and coordinates are pinned; only the parent context may shrink, so a deep + path loses its middle rather than the identity the reader is scanning for. Two files + can share a name and their nearest parent, so pointing at a row spells the rest of + the path out. */} + + {parents ? ( + + {parents} + + ) : null} + + {file.name}:{item.line}:{item.columnStart} + + + + ); + }} ); } @@ -472,6 +484,11 @@ const styles = StyleSheet.create((theme) => ({ }, muted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, error: { color: theme.colors.statusDanger }, + tooltipPath: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontFamily: theme.fontFamily.mono, + }, empty: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[6], diff --git a/packages/app/src/command-center/workspace-content-search/internal/result-path.test.ts b/packages/app/src/command-center/workspace-content-search/internal/result-path.test.ts index f13261230d..36af61bb89 100644 --- a/packages/app/src/command-center/workspace-content-search/internal/result-path.test.ts +++ b/packages/app/src/command-center/workspace-content-search/internal/result-path.test.ts @@ -2,13 +2,24 @@ import { describe, expect, it } from "vitest"; import { describeResultPath } from "./result-path"; describe("describeResultPath", () => { + it("spells out every parent for the row being pointed at", () => { + // Two files can share a name and their nearest parent; only the head tells them apart. + expect(describeResultPath("packages/app/src/utils/index.ts").head).toBe( + "packages/app/src/utils/", + ); + expect(describeResultPath("packages/server/src/utils/index.ts").head).toBe( + "packages/server/src/utils/", + ); + }); + it("keeps a root file whole", () => { - expect(describeResultPath("README.md")).toEqual({ directory: "", name: "README.md" }); + expect(describeResultPath("README.md")).toEqual({ directory: "", head: "", name: "README.md" }); }); it("keeps a shallow path whole", () => { expect(describeResultPath("docs/architecture.md")).toEqual({ directory: "docs/", + head: "docs/", name: "architecture.md", }); }); @@ -17,14 +28,19 @@ describe("describeResultPath", () => { // These two rows are both "index.tsx"; the parents are the only thing telling them apart. expect( describeResultPath("packages/app/src/command-center/workspace-content-search/index.tsx"), - ).toEqual({ directory: "…/workspace-content-search/", name: "index.tsx" }); + ).toEqual({ + directory: "…/workspace-content-search/", + head: "packages/app/src/command-center/workspace-content-search/", + name: "index.tsx", + }); expect(describeResultPath("packages/app/src/file-pane/source/index.tsx")).toEqual({ directory: "…/source/", + head: "packages/app/src/file-pane/source/", name: "index.tsx", }); }); it("keeps a literal backslash in a file name out of the directory", () => { - expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", name: "a\\b.txt" }); + expect(describeResultPath("a\\b.txt")).toEqual({ directory: "", head: "", name: "a\\b.txt" }); }); }); diff --git a/packages/app/src/command-center/workspace-content-search/internal/result-path.ts b/packages/app/src/command-center/workspace-content-search/internal/result-path.ts index c7cb8cef8e..2909ec11a7 100644 --- a/packages/app/src/command-center/workspace-content-search/internal/result-path.ts +++ b/packages/app/src/command-center/workspace-content-search/internal/result-path.ts @@ -9,6 +9,8 @@ export interface ResultPathParts { /** Directory context immediately above the file, already elided at the head when deeper. */ directory: string; + /** Every directory above the file, for the row the reader is pointing at or has selected. */ + head: string; /** File name, which is never truncated away. */ name: string; } @@ -18,8 +20,12 @@ const PARENTS_KEPT = 1; export function describeResultPath(path: string): ResultPathParts { const segments = path.split("/"); const name = segments.pop() ?? path; - if (segments.length === 0) return { directory: "", name }; + if (segments.length === 0) return { directory: "", head: "", name }; const kept = segments.slice(-PARENTS_KEPT); const elided = segments.length > kept.length; - return { directory: `${elided ? "…/" : ""}${kept.join("/")}/`, name }; + return { + directory: `${elided ? "…/" : ""}${kept.join("/")}/`, + head: `${segments.join("/")}/`, + name, + }; }