diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts index 44b34e1fc40..a1b6aad404d 100644 --- a/packages/app/e2e/regression/open-file-expand-folder.spec.ts +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -7,11 +7,13 @@ const directory = "C:/OpenCode/OpenFileExpand" const projectID = "proj_open_file_expand" const sessionID = "ses_open_file_expand" const title = "Open file expand" +const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts" +const longPath = `frontend/${longFilename}` const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` test.use({ viewport: { width: 1440, height: 900 } }) -test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { +test("expands Windows paths and horizontally scrolls long filenames", async ({ page }) => { await mockOpenCodeServer(page, { directory, project: { @@ -44,7 +46,17 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa time: { created: 1700000000000, updated: 1700000000000 }, }, ], - vcsDiff: [], + vcsDiff: [ + { + file: longPath, + before: "", + after: "export const added = true\n", + additions: 1, + deletions: 0, + status: "added", + patch: "@@ -0,0 +1 @@\n+export const added = true\n", + }, + ], fileList: (path) => { if (path === "frontend\\" || path === "frontend") { return [ @@ -55,6 +67,13 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa type: "file" as const, ignored: false, }, + { + name: longFilename, + path: `frontend\\${longFilename}`, + absolute: `${directory}/${longPath}`, + type: "file" as const, + ignored: false, + }, ] } if (path) return [] @@ -75,6 +94,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa }, ] }, + findFiles: ({ query }) => (longPath.includes(query) ? [longPath] : []), fileContent: (path) => ({ type: "text", content: `contents:${path}` }), pageMessages: () => ({ items: [] }), }) @@ -119,6 +139,93 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa await frontendRow.click() await expect(frontendRow).toHaveAttribute("aria-expanded", "true") + const viewport = sidebar.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport') + const longRow = panel.getByRole("button", { name: longFilename }) + await expect(longRow).toBeVisible() + await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0) + expect( + await longRow.evaluate((element) => getComputedStyle(element.querySelector("bdi")!.parentElement!).textOverflow), + ).toBe("clip") + expect(await longRow.evaluate((element) => element.getBoundingClientRect().width)).toBeGreaterThanOrEqual( + await viewport.evaluate((element) => element.clientWidth), + ) + await expect + .poll(() => + panel.locator('[data-slot="file-tree-v2-row"]').evaluateAll((rows) => { + const widths = rows.map((row) => row.getBoundingClientRect().width) + return Math.max(...widths) - Math.min(...widths) + }), + ) + .toBeLessThanOrEqual(0.5) + await expect(longRow.locator('[data-slot="file-tree-v2-label"]')).toHaveCSS("margin-inline-end", "12px") + const status = longRow.locator('[data-slot="file-tree-v2-change"]') + await expect(status).toHaveText("A") + const statusBox = await status.boundingBox() + if (!statusBox) throw new Error("File status has no bounding box") + const viewportBox = await viewport.boundingBox() + if (!viewportBox) throw new Error("File tree viewport has no bounding box") + expect(viewportBox.x + viewportBox.width - statusBox.x - statusBox.width).toBeLessThanOrEqual(24) + + await viewport.hover() + const horizontalThumb = sidebar.locator('.scroll-view__thumb[data-orientation="horizontal"]') + await expect(horizontalThumb).toHaveCSS("opacity", "1") + await page.mouse.wheel(1_000, 0) + await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeGreaterThan(0) + await expect(horizontalThumb).toHaveAttribute("data-visible", "true") + await expect + .poll(() => + status.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return viewport.right - element.getBoundingClientRect().right + }), + ) + .toBeLessThanOrEqual(24) + + const beforeDrag = await viewport.evaluate((element) => Math.abs(element.scrollLeft)) + const thumbBox = await horizontalThumb.boundingBox() + if (!thumbBox) throw new Error("Horizontal scrollbar thumb has no bounding box") + await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2) + await page.mouse.down() + await page.mouse.move(thumbBox.x + thumbBox.width / 2 - 40, thumbBox.y + thumbBox.height / 2) + await page.mouse.up() + await expect.poll(() => viewport.evaluate((element) => Math.abs(element.scrollLeft))).toBeLessThan(beforeDrag) + + const filter = panel.getByRole("combobox", { name: "Filter files" }) + await filter.fill(longFilename) + const filteredRow = panel.getByRole("option", { name: longFilename }) + await expect(filteredRow).toBeVisible() + const filteredStatus = filteredRow.locator('[data-slot="file-tree-v2-change"]') + await expect(filteredStatus).toHaveText("A") + await expect.poll(() => viewport.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0) + + await viewport.evaluate((element) => { + element.setAttribute("dir", "rtl") + element.scrollLeft = 0 + element.dispatchEvent(new Event("scroll")) + }) + await expect + .poll(() => + filteredStatus.evaluate((element) => { + const viewport = element.closest(".scroll-view__viewport")!.getBoundingClientRect() + return element.getBoundingClientRect().left - viewport.left + }), + ) + .toBeLessThanOrEqual(24) + const rtlThumbBox = await horizontalThumb.boundingBox() + if (!rtlThumbBox) throw new Error("RTL horizontal scrollbar thumb has no bounding box") + await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2, rtlThumbBox.y + rtlThumbBox.height / 2) + await page.mouse.down() + await page.mouse.move(rtlThumbBox.x + rtlThumbBox.width / 2 - 40, rtlThumbBox.y + rtlThumbBox.height / 2) + await page.mouse.up() + await expect.poll(() => viewport.evaluate((element) => element.scrollLeft)).toBeLessThan(0) + await viewport.evaluate((element) => { + element.removeAttribute("dir") + element.scrollLeft = 0 + element.dispatchEvent(new Event("scroll")) + }) + + await filter.fill("") + const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') await expect(appRow).toBeVisible() await appRow.click() diff --git a/packages/app/src/session/files/file-tree-v2.tsx b/packages/app/src/session/files/file-tree-v2.tsx index d18fbadebbf..958b01d0a7b 100644 --- a/packages/app/src/session/files/file-tree-v2.tsx +++ b/packages/app/src/session/files/file-tree-v2.tsx @@ -24,6 +24,11 @@ import { type FileTreeV2Node, } from "@/session/files/file-tree-v2-model" import { virtualScrollElement } from "@/session/files/virtual-scroll" +import { useWorkspaceLocation } from "@/workspaces/location" +import { useOpenInApp } from "@/session/files/open-in-app" +import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button" +import { resolveOpenInAppPath } from "@/session/files/open-in-app-path" +import { usePlatform } from "@/runtime/platform/platform" export type { Kind } from "@/session/files/file-tree" @@ -99,7 +104,7 @@ const FileTreeNodeV2 = ( {...rest} > {local.children} - + {local.node.type === "directory" ? normalizeFileTreeV2Path(local.node.path).split("/").at(-1) @@ -136,6 +141,9 @@ export default function FileTreeV2(props: { onFileDoubleClick?: (file: FileNode) => void }) { const file = useFile() + const location = useWorkspaceLocation() + const platform = usePlatform() + const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined const live = () => props.allowed === undefined const draggable = () => props.draggable ?? true const active = () => normalizeFileTreeV2Path(props.active ?? "") @@ -217,6 +225,19 @@ export default function FileTreeV2(props: { ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) + createEffect(() => { + rows() + const element = root() + if (!element) return + element.style.removeProperty("width") + syncFileTreeV2Width(element) + }) + + createEffect(() => { + virtualRowKeys() + syncFileTreeV2Width(root()) + }) + return (
setFocused(row().node.path)} - onBlur={() => setFocused(undefined)} - onClick={() => selectFile(row().node, props.onFileClick)} - onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + + resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath) + } > - - 0}> -
- - - - - - + setFocused(row().node.path)} + onBlur={() => setFocused(undefined)} + onClick={() => selectFile(row().node, props.onFileClick)} + onDblClick={() => selectFile(row().node, props.onFileDoubleClick)} + > + + 0}> +
+ + + + + + + } > ) } + +export function syncFileTreeV2Width(element?: HTMLDivElement) { + if (!element) return + queueMicrotask(() => { + if (!element.isConnected) return + const width = Math.max(element.clientWidth, ...Array.from(element.children, (child) => child.scrollWidth)) + if (width <= element.clientWidth) return + element.style.width = `${width}px` + }) +} diff --git a/packages/app/src/session/files/list.tsx b/packages/app/src/session/files/list.tsx index 06224e59bd0..aa73cd5ea1b 100644 --- a/packages/app/src/session/files/list.tsx +++ b/packages/app/src/session/files/list.tsx @@ -2,10 +2,15 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import "@opencode-ai/ui/file-tree.css" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, Show } from "solid-js" -import { kindChange, kindLabel, type Kind } from "@/session/files/file-tree-v2" +import { kindChange, kindLabel, syncFileTreeV2Width, type Kind } from "@/session/files/file-tree-v2" import { normalizePath } from "@/session/review/review-diff-kinds" import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual" import { virtualScrollElement } from "@/session/files/virtual-scroll" +import { useWorkspaceLocation } from "@/workspaces/location" +import { useOpenInApp } from "@/session/files/open-in-app" +import { OpenInAppContextMenuV2 } from "@/session/files/open-in-app-button" +import { resolveOpenInAppPath } from "@/session/files/open-in-app-path" +import { usePlatform } from "@/runtime/platform/platform" // Drives the highlight/selection of the flat search-result list from the filter // input's keyboard events. @@ -49,6 +54,9 @@ export function SessionFileList(props: { onFileClick: (path: string) => void onFileDoubleClick?: (path: string) => void }) { + const location = useWorkspaceLocation() + const platform = usePlatform() + const openIn = platform.platform === "desktop" ? useOpenInApp({ path: () => location().directory }) : undefined const active = () => normalizePath(props.active ?? "") const highlighted = () => normalizePath(props.highlighted ?? "") const normalized = createMemo(() => props.files.map(normalizePath)) @@ -89,6 +97,19 @@ export function SessionFileList(props: { ) const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key)) + createEffect(() => { + normalized() + const element = root() + if (!element) return + element.style.removeProperty("width") + syncFileTreeV2Width(element) + }) + + createEffect(() => { + virtualRowKeys() + syncFileTreeV2Width(root()) + }) + return (
- + +
)} diff --git a/packages/app/src/session/files/open-in-app-button.tsx b/packages/app/src/session/files/open-in-app-button.tsx index 954f0c0fbac..d340e18a0e2 100644 --- a/packages/app/src/session/files/open-in-app-button.tsx +++ b/packages/app/src/session/files/open-in-app-button.tsx @@ -1,4 +1,4 @@ -import { For, Show } from "solid-js" +import { createSignal, For, Show, type ParentProps } from "solid-js" import { AppIcon } from "@opencode-ai/ui/app-icon" import { Icon } from "@opencode-ai/ui/icon" import { Spinner } from "@opencode-ai/ui/spinner" @@ -10,7 +10,7 @@ import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app" export function OpenInAppButton(props: { directory: () => string }) { const language = useLanguage() - const state = useOpenInApp(props) + const state = useOpenInApp({ path: props.directory }) return ( @@ -25,7 +25,7 @@ export function OpenInAppButton(props: { directory: () => string }) { onClick={(event) => { event.stopPropagation() if (state.opening()) return - state.openDir(state.current().id) + state.openPath(state.current().id) }} disabled={state.opening()} aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} @@ -52,42 +52,7 @@ export function OpenInAppButton(props: { directory: () => string }) { - - {language.t("session.header.openIn")} - { - state.selectApp(value as OpenApp) - }} - > - - {(option) => ( - { - state.selectApp(option.id) - state.setMenu("open", false) - state.openDir(option.id) - }} - > - - {option.label} - - )} - - - - - { - state.setMenu("open", false) - state.copyPath() - }} - > - - {language.t("session.header.open.copyPath")} - + state.setMenu("open", false)} /> @@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) { ) } + +type OpenInAppState = ReturnType + +function OpenInAppMenuItemsV2(props: { + state: OpenInAppState + path?: () => string + reveal?: boolean + selection?: boolean + close?: () => void +}) { + const language = useLanguage() + const path = () => props.path?.() + + return ( + <> + + {language.t("session.header.openIn")} + + {(option) => ( + { + props.state.selectApp(option.id) + props.close?.() + props.state.openPath(option.id, path(), props.reveal) + }} + > + + {option.label} + + )} + + } + > + { + props.state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + props.state.selectApp(option.id) + props.close?.() + props.state.openPath(option.id, path(), props.reveal) + }} + > + + {option.label} + + )} + + + + + + { + props.close?.() + props.state.copyPath(path()) + }} + > + + {language.t("session.header.open.copyPath")} + + + ) +} + +export function OpenInAppContextMenuV2( + props: ParentProps<{ + state?: OpenInAppState + path: () => string + }>, +) { + const state = props.state + if (!state) return props.children + const [open, setOpen] = createSignal(false) + + return ( + + + + {props.children} + + + + setOpen(false)} + /> + + + + + ) +} diff --git a/packages/app/src/session/files/open-in-app-path.test.ts b/packages/app/src/session/files/open-in-app-path.test.ts new file mode 100644 index 00000000000..f685c1e40c3 --- /dev/null +++ b/packages/app/src/session/files/open-in-app-path.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { openInAppParentPath, resolveOpenInAppPath } from "./open-in-app-path" + +describe("resolveOpenInAppPath", () => { + test("joins relative paths using the workspace separator", () => { + expect(resolveOpenInAppPath("/workspace/project", "src/file.ts")).toBe("/workspace/project/src/file.ts") + expect(resolveOpenInAppPath("C:\\workspace\\project", "src/file.ts")).toBe("C:\\workspace\\project\\src\\file.ts") + }) + + test("does not duplicate root separators", () => { + expect(resolveOpenInAppPath("/workspace/project/", "src/file.ts")).toBe("/workspace/project/src/file.ts") + expect(resolveOpenInAppPath("C:/workspace/project/", "src\\file.ts")).toBe("C:/workspace/project/src/file.ts") + }) + + test("preserves backslashes in POSIX filenames", () => { + expect(resolveOpenInAppPath("/workspace", "src\\file.ts")).toBe("/workspace/src\\file.ts") + expect(resolveOpenInAppPath("/workspace", "\\file.ts")).toBe("/workspace/\\file.ts") + }) + + test("preserves absolute POSIX, Windows, and UNC paths", () => { + expect(resolveOpenInAppPath("/workspace", "/tmp/file.ts")).toBe("/tmp/file.ts") + expect(resolveOpenInAppPath("C:/workspace", "D:\\src\\file.ts")).toBe("D:\\src\\file.ts") + expect(resolveOpenInAppPath("C:/workspace", "\\\\server\\share\\file.ts")).toBe("\\\\server\\share\\file.ts") + expect(resolveOpenInAppPath("C:/workspace", "\\src\\file.ts")).toBe("\\src\\file.ts") + }) +}) + +describe("openInAppParentPath", () => { + test("preserves POSIX and Windows roots", () => { + expect(openInAppParentPath("/file.ts")).toBe("/") + expect(openInAppParentPath("/workspace/file.ts")).toBe("/workspace") + expect(openInAppParentPath("C:\\file.ts")).toBe("C:\\") + expect(openInAppParentPath("C:\\workspace\\file.ts")).toBe("C:\\workspace") + expect(openInAppParentPath("\\\\server\\share\\file.ts")).toBe("\\\\server\\share") + }) +}) diff --git a/packages/app/src/session/files/open-in-app-path.ts b/packages/app/src/session/files/open-in-app-path.ts new file mode 100644 index 00000000000..e71f21be865 --- /dev/null +++ b/packages/app/src/session/files/open-in-app-path.ts @@ -0,0 +1,19 @@ +export function resolveOpenInAppPath(root: string, path: string) { + if (!path) return root + const windowsRoot = root.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(root) + if (path.startsWith("/") || (windowsRoot && path.startsWith("\\")) || /^[A-Za-z]:[\\/]/.test(path)) return path + if (!root) return path + + const separator = root.includes("\\") ? "\\" : "/" + const relative = windowsRoot ? path.replace(/^[\\/]+/, "") : path + return `${root.replace(/[\\/]+$/, "")}${separator}${windowsRoot ? relative.replaceAll(separator === "\\" ? "/" : "\\", separator) : relative}` +} + +export function openInAppParentPath(path: string) { + const value = path.replace(/[\\/]+$/, "") + const index = Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + if (index < 0) return path + if (index === 0) return value.slice(0, 1) + if (index === 2 && /^[A-Za-z]:/.test(value)) return value.slice(0, 3) + return value.slice(0, index) +} diff --git a/packages/app/src/session/files/open-in-app.tsx b/packages/app/src/session/files/open-in-app.tsx index 7019940d287..c46ff341bce 100644 --- a/packages/app/src/session/files/open-in-app.tsx +++ b/packages/app/src/session/files/open-in-app.tsx @@ -7,6 +7,8 @@ import { showToast } from "@/shell/notifications/toast" import { useServer } from "@/runtime/server/current" import { Schema } from "effect" import { Persistence } from "@/runtime/persistence/schema" +import { fileManagerApp } from "@/home/projects/file-manager" +import { openInAppParentPath } from "@/session/files/open-in-app-path" export const OPEN_APPS = [ "vscode", @@ -32,6 +34,8 @@ export const OpenAppPreferences = Persistence.struct({ app: Schema.Literals(OPEN_APPS), }) +const appExistence = new Map>() + export const MAC_OPEN_APPS = [ { id: "vscode", @@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType): OpenA } export function openAppFileManager(os: OpenAppOS) { - if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } - if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } - return { label: "session.header.open.fileManager", icon: "finder" as const } + return fileManagerApp(os) } export function openAppsForOS(os: OpenAppOS) { @@ -127,7 +129,7 @@ const showRequestError = (language: ReturnType, err: unknown }) } -export function useOpenInApp(input: { directory: () => string }) { +export function useOpenInApp(input: { path: () => string }) { const platform = usePlatform() const server = useServer() const language = useLanguage() @@ -149,12 +151,7 @@ export function useOpenInApp(input: { directory: () => string }) { setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) void Promise.all( - list.map((app) => - Promise.resolve(platform.checkAppExists?.(app.openWith)) - .then((value) => Boolean(value)) - .catch(() => false) - .then((ok) => [app.id, ok] as const), - ), + list.map((app) => checkAppExists(platform, app.openWith).then((ok) => [app.id, ok] as const)), ).then((entries) => { setExists(Object.fromEntries(entries) as Partial>) }) @@ -189,33 +186,35 @@ export function useOpenInApp(input: { directory: () => string }) { setPrefs("app", app) } - const openDir = (app: OpenApp | "finder") => { + const openPath = (app: OpenApp | "finder", target = input.path(), reveal = false) => { if (opening() || !canOpen() || !platform.openPath) return - const directory = input.directory() - if (!directory) return + if (!target) return + const open = (path: string, openWith?: string) => platform.openPath!(path, openWith) const item = options().find((o) => o.id === app) const openWith = item && "openWith" in item ? item.openWith : undefined setOpenRequest("app", app) - platform - .openPath(directory, openWith) + const request = + app === "finder" && reveal && platform.revealPath + ? platform.revealPath(target).then((revealed) => (revealed ? undefined : open(openInAppParentPath(target)))) + : open(target, openWith) + request .catch((err: unknown) => showRequestError(language, err)) .finally(() => { setOpenRequest("app", undefined) }) } - const copyPath = () => { - const directory = input.directory() - if (!directory) return + const copyPath = (target = input.path()) => { + if (!target) return navigator.clipboard - .writeText(directory) + .writeText(target) .then(() => { showToast({ variant: "success", icon: "circle-check", title: language.t("common.copied"), - description: directory, + description: target, }) }) .catch((err: unknown) => showRequestError(language, err)) @@ -228,8 +227,18 @@ export function useOpenInApp(input: { directory: () => string }) { options, menu, setMenu, - openDir, + openPath, selectApp, copyPath, } } + +function checkAppExists(platform: ReturnType, app: string) { + const cached = appExistence.get(app) + if (cached) return cached + const request = Promise.resolve(platform.checkAppExists?.(app)) + .then(Boolean) + .catch(() => false) + appExistence.set(app, request) + return request +} diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index c01dd61a516..9bc95b17951 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -177,7 +177,9 @@ padding: 4px 8px 12px; } -[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb[data-orientation="vertical"] { width: 16px; } @@ -188,6 +190,20 @@ background-color: var(--v2-border-border-muted, var(--border-weak-base)); } +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb[data-orientation="horizontal"] { + height: 16px; + opacity: 1; +} + +[data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb[data-orientation="horizontal"]::after { + width: auto; + height: 6px; +} + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb:hover::after, diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index cf4aef28caa..4781316d7d9 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -124,6 +124,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index adebf190c5e..80ba17c7988 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -15,15 +15,21 @@ outline: none; } +.scroll-view[data-orientation="horizontal"] .scroll-view__viewport { + overflow-x: auto; + overflow-y: hidden; +} + +.scroll-view[data-orientation="both"] .scroll-view__viewport { + overflow: auto; +} + .scroll-view__viewport::-webkit-scrollbar { display: none; } .scroll-view__thumb { position: absolute; - inset-inline-end: 0; - top: 0; - width: 12px; transition: opacity 200ms ease; cursor: default; user-select: none; @@ -31,7 +37,19 @@ opacity: 0; } -.scroll-view__thumb::after { +.scroll-view__thumb[data-orientation="vertical"] { + inset-inline-end: 0; + top: 0; + width: 12px; +} + +.scroll-view__thumb[data-orientation="horizontal"] { + left: 0; + bottom: 0; + height: 12px; +} + +.scroll-view__thumb[data-orientation="vertical"]::after { content: ""; position: absolute; left: 50%; @@ -45,6 +63,20 @@ transition: background-color 150ms ease; } +.scroll-view__thumb[data-orientation="horizontal"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 4px; + transform: translateY(-50%); + border-radius: 9999px; + background-color: var(--border-weak-base); + backdrop-filter: blur(4px); + transition: background-color 150ms ease; +} + .scroll-view__thumb:hover::after, .scroll-view__thumb[data-dragging="true"]::after { background-color: var(--border-strong-base); diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index 84131b1f17f..9d754bef582 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { canScrollKey, scrollKey, scrollTopFromThumbPointer } from "./scroll-view" +import { canScrollKey, scrollKey, scrollOffsetFromThumbPointer, scrollTopFromThumbPointer } from "./scroll-view" describe("scrollKey", () => { test("maps plain navigation keys", () => { @@ -88,3 +88,24 @@ describe("scrollTopFromThumbPointer", () => { expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200) }) }) + +describe("scrollOffsetFromThumbPointer", () => { + const input = { + viewportStart: 100, + grabOffset: 10, + clientSize: 400, + scrollClientSize: 400, + scrollSize: 1_000, + thumbSize: 100, + } + + test("maps horizontal pointer movement to scroll offset", () => { + expect(scrollOffsetFromThumbPointer({ ...input, pointer: 118 })).toBe(0) + expect(scrollOffsetFromThumbPointer({ ...input, pointer: 402 })).toBe(600) + }) + + test("reverses horizontal pointer movement for RTL", () => { + expect(scrollOffsetFromThumbPointer({ ...input, pointer: 118, reverse: true })).toBe(600) + expect(scrollOffsetFromThumbPointer({ ...input, pointer: 402, reverse: true })).toBe(0) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 1e933c34b2c..aff8250191f 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -8,7 +8,7 @@ export type ScrollViewThumbVisibility = "hover" | "scroll" export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void - orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + orientation?: "vertical" | "horizontal" | "both" /** * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling. * @@ -78,12 +78,37 @@ export function scrollTopFromThumbPointer(input: { thumbHeight: number /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */ scrollClientHeight?: number +}) { + return scrollOffsetFromThumbPointer({ + pointer: input.pointer, + viewportStart: input.viewportTop, + grabOffset: input.grabOffset, + clientSize: input.clientHeight, + scrollSize: input.scrollHeight, + thumbSize: input.thumbHeight, + scrollClientSize: input.scrollClientHeight, + }) +} + +export function scrollOffsetFromThumbPointer(input: { + pointer: number + viewportStart: number + grabOffset: number + clientSize: number + scrollSize: number + thumbSize: number + scrollClientSize?: number + reverse?: boolean }) { const padding = 8 - const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight - if (maxThumbTop <= 0) return 0 - const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) - return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight)) + const maxThumbStart = input.clientSize - padding * 2 - input.thumbSize + if (maxThumbStart <= 0) return 0 + const thumbStart = Math.max( + 0, + Math.min(input.pointer - input.viewportStart - padding - input.grabOffset, maxThumbStart), + ) + const progress = input.reverse ? 1 - thumbStart / maxThumbStart : thumbStart / maxThumbStart + return progress * Math.max(0, input.scrollSize - (input.scrollClientSize ?? input.clientSize)) } export function ScrollView(props: ScrollViewProps) { @@ -116,7 +141,8 @@ export function ScrollView(props: ScrollViewProps) { let rootRef!: HTMLDivElement let viewportRef!: HTMLDivElement - let thumbRef!: HTMLDivElement + let verticalThumbRef!: HTMLDivElement + let horizontalThumbRef!: HTMLDivElement const thumbMount = () => local.thumbContainer const thumbHover = () => local.thumbHoverTarget @@ -124,18 +150,20 @@ export function ScrollView(props: ScrollViewProps) { const [state, setState] = createStore({ isHovered: false, - isDragging: false, + dragging: undefined as "vertical" | "horizontal" | undefined, isScrolling: false, - thumbHeight: 0, - thumbTop: 0, - showThumb: false, + verticalThumbSize: 0, + verticalThumbStart: 0, + showVerticalThumb: false, + horizontalThumbSize: 0, + horizontalThumbStart: 0, + showHorizontalThumb: false, }) const isHovered = () => state.isHovered - const isDragging = () => state.isDragging + const isDragging = () => state.dragging !== undefined const isScrolling = () => state.isScrolling - const thumbHeight = () => state.thumbHeight - const thumbTop = () => state.thumbTop - const showThumb = () => state.showThumb + const vertical = () => local.orientation === "vertical" || local.orientation === "both" + const horizontal = () => local.orientation === "horizontal" || local.orientation === "both" let scrollIdleTimer: ReturnType | undefined @@ -157,33 +185,42 @@ export function ScrollView(props: ScrollViewProps) { const updateThumb = () => { if (!viewportRef) return - const { scrollTop, scrollHeight, clientHeight } = viewportRef + const trackPadding = 8 + const minThumbSize = 32 - if (scrollHeight <= clientHeight || scrollHeight === 0) { - setState("showThumb", false) - return + if (vertical()) { + const trackSize = Math.max(0, (thumbMount()?.clientHeight || viewportRef.clientHeight) - trackPadding * 2) + const size = trackSize + ? Math.min(trackSize, Math.max((viewportRef.clientHeight / viewportRef.scrollHeight) * trackSize, minThumbSize)) + : 0 + const maxScroll = viewportRef.scrollHeight - viewportRef.clientHeight + const maxStart = trackSize - size + setState("showVerticalThumb", maxScroll > 0) + setState("verticalThumbSize", size) + setState( + "verticalThumbStart", + trackPadding + (maxScroll > 0 ? (viewportRef.scrollTop / maxScroll) * maxStart : 0), + ) + } else { + setState("showVerticalThumb", false) } - setState("showThumb", true) - const trackPadding = 8 - const trackClientHeight = thumbMount()?.clientHeight || clientHeight - const trackHeight = trackClientHeight - trackPadding * 2 - - const minThumbHeight = 32 - // Calculate raw thumb height based on ratio - let height = (clientHeight / scrollHeight) * trackHeight - height = Math.max(height, minThumbHeight) - - const maxScrollTop = scrollHeight - clientHeight - const maxThumbTop = trackHeight - height - - const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 - - // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) - const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) - - setState("thumbHeight", height) - setState("thumbTop", boundedTop) + if (horizontal()) { + const trackSize = Math.max(0, (thumbMount()?.clientWidth || viewportRef.clientWidth) - trackPadding * 2) + const size = trackSize + ? Math.min(trackSize, Math.max((viewportRef.clientWidth / viewportRef.scrollWidth) * trackSize, minThumbSize)) + : 0 + const maxScroll = viewportRef.scrollWidth - viewportRef.clientWidth + const maxStart = trackSize - size + const rtl = getComputedStyle(viewportRef).direction === "rtl" + const offset = Math.max(0, Math.min(rtl ? -viewportRef.scrollLeft : viewportRef.scrollLeft, maxScroll)) + const start = maxScroll > 0 ? (offset / maxScroll) * maxStart : 0 + setState("showHorizontalThumb", maxScroll > 0) + setState("horizontalThumbSize", size) + setState("horizontalThumbStart", trackPadding + (rtl ? maxStart - start : start)) + } else { + setState("showHorizontalThumb", false) + } } onMount(() => { @@ -204,6 +241,13 @@ export function ScrollView(props: ScrollViewProps) { updateThumb() }) + createEffect(() => { + if (!horizontal() || !viewportRef) return + const observer = new MutationObserver(updateThumb) + observer.observe(viewportRef, { childList: true, subtree: true, characterData: true }) + onCleanup(() => observer.disconnect()) + }) + createEffect(() => { const target = thumbHover() if (!target) return @@ -219,58 +263,88 @@ export function ScrollView(props: ScrollViewProps) { }) }) - const onThumbPointerDown = (e: PointerEvent) => { + const onThumbPointerDown = (axis: "vertical" | "horizontal", e: PointerEvent) => { e.preventDefault() e.stopPropagation() - setState("isDragging", true) - const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top + setState("dragging", axis) + const thumb = axis === "vertical" ? verticalThumbRef : horizontalThumbRef + const grabOffset = + axis === "vertical" + ? e.clientY - thumb.getBoundingClientRect().top + : e.clientX - thumb.getBoundingClientRect().left const track = thumbMount() ?? viewportRef - thumbRef.setPointerCapture(e.pointerId) + thumb.setPointerCapture(e.pointerId) const onPointerMove = (e: PointerEvent) => { - const { scrollHeight, clientHeight } = viewportRef - viewportRef.scrollTop = scrollTopFromThumbPointer({ - pointer: e.clientY, - viewportTop: track.getBoundingClientRect().top, + const vertical = axis === "vertical" + const rtl = !vertical && getComputedStyle(viewportRef).direction === "rtl" + const offset = scrollOffsetFromThumbPointer({ + pointer: vertical ? e.clientY : e.clientX, + viewportStart: vertical ? track.getBoundingClientRect().top : track.getBoundingClientRect().left, grabOffset, - clientHeight: track.clientHeight, - scrollClientHeight: clientHeight, - scrollHeight, - thumbHeight: thumbHeight(), + clientSize: vertical ? track.clientHeight : track.clientWidth, + scrollClientSize: vertical ? viewportRef.clientHeight : viewportRef.clientWidth, + scrollSize: vertical ? viewportRef.scrollHeight : viewportRef.scrollWidth, + thumbSize: vertical ? state.verticalThumbSize : state.horizontalThumbSize, + reverse: rtl, }) + if (vertical) { + viewportRef.scrollTop = offset + return + } + viewportRef.scrollLeft = rtl ? -offset : offset } const done = (e: PointerEvent) => { - setState("isDragging", false) - thumbRef.releasePointerCapture(e.pointerId) - thumbRef.removeEventListener("pointermove", onPointerMove) - thumbRef.removeEventListener("pointerup", done) - thumbRef.removeEventListener("pointercancel", done) + setState("dragging", undefined) + thumb.releasePointerCapture(e.pointerId) + thumb.removeEventListener("pointermove", onPointerMove) + thumb.removeEventListener("pointerup", done) + thumb.removeEventListener("pointercancel", done) } - thumbRef.addEventListener("pointermove", onPointerMove) - thumbRef.addEventListener("pointerup", done) - thumbRef.addEventListener("pointercancel", done) + thumb.addEventListener("pointermove", onPointerMove) + thumb.addEventListener("pointerup", done) + thumb.addEventListener("pointercancel", done) } - const renderThumb = () => ( + const renderVerticalThumb = () => (
{ - thumbRef = el + verticalThumbRef = el }} - onPointerDown={onThumbPointerDown} + onPointerDown={(event) => onThumbPointerDown("vertical", event)} class="scroll-view__thumb" + data-orientation="vertical" data-visible={thumbVisible()} - data-dragging={isDragging()} + data-dragging={state.dragging === "vertical"} style={{ - height: `${thumbHeight()}px`, - transform: `translateY(${thumbTop()}px)`, + height: `${state.verticalThumbSize}px`, + transform: `translateY(${state.verticalThumbStart}px)`, "z-index": 100, // ensure it displays over content }} /> ) + const renderHorizontalThumb = () => ( +
{ + horizontalThumbRef = el + }} + onPointerDown={(event) => onThumbPointerDown("horizontal", event)} + class="scroll-view__thumb" + data-orientation="horizontal" + data-visible={thumbVisible()} + data-dragging={state.dragging === "horizontal"} + style={{ + width: `${state.horizontalThumbSize}px`, + transform: `translateX(${state.horizontalThumbStart}px)`, + "z-index": 100, + }} + /> + ) + // Keybinds implementation // We ensure the viewport has a tabindex so it can receive focus // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior, @@ -320,6 +394,7 @@ export function ScrollView(props: ScrollViewProps) {
{ if (hoverRoot()) setState("isHovered", true) @@ -363,9 +438,14 @@ export function ScrollView(props: ScrollViewProps) {
{/* Thumb Overlay — optionally portaled into an external track */} - - - {(mount) => {renderThumb()}} + + + {(mount) => {renderVerticalThumb()}} + + + + + {(mount) => {renderHorizontalThumb()}}
diff --git a/packages/ui/src/styles/file-tree.css b/packages/ui/src/styles/file-tree.css index eb9ff7a7ade..a7bb36d156d 100644 --- a/packages/ui/src/styles/file-tree.css +++ b/packages/ui/src/styles/file-tree.css @@ -1,20 +1,30 @@ +@property --file-tree-v2-row-overlay { + syntax: ""; + inherits: true; + initial-value: transparent; +} + [data-component="file-tree-v2"] { display: flex; flex-direction: column; gap: 2px; + width: max-content; + min-width: 100%; } [data-component="file-tree-v2"] [data-slot="file-tree-v2-row"] { + --file-tree-v2-row-overlay: transparent; + box-sizing: border-box; width: 100%; - min-width: 0; + min-width: max-content; height: 28px; display: flex; align-items: center; justify-content: flex-start; gap: 6px; padding-inline-end: 8px; - overflow: visible; + overflow: clip; border: none; border-radius: 6px; background-color: transparent; @@ -24,7 +34,12 @@ scroll-margin-block: 8px; transition: background-color 120ms ease, - color 120ms ease; + color 120ms ease, + --file-tree-v2-row-overlay 120ms ease; +} + +[data-component="file-tree-v2"] [data-slot="file-tree-v2-label"] { + margin-inline-end: 12px; } [data-component="file-tree-v2"] [data-slot="file-tree-v2-row"][data-ignored] { @@ -32,10 +47,14 @@ } [data-component="file-tree-v2"] [data-slot="file-tree-v2-row"]:hover { + --file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-hover); + background-color: var(--v2-overlay-simple-overlay-hover); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-row"][data-selected] { + --file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-pressed); + color: var(--v2-text-text-base); background-color: var(--v2-overlay-simple-overlay-pressed); } @@ -44,6 +63,14 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="file-tree-v2"] + [data-slot="file-tree-v2-context-trigger"][data-context-menu-open] + [data-slot="file-tree-v2-row"]:not([data-selected]) { + --file-tree-v2-row-overlay: var(--v2-overlay-simple-overlay-hover); + + background-color: var(--v2-overlay-simple-overlay-hover); +} + [data-component="file-tree-v2"] [data-slot="file-tree-v2-guide"] { position: absolute; top: 0; @@ -110,6 +137,9 @@ [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"] { box-sizing: border-box; flex: none; + position: sticky; + inset-inline-end: 8px; + z-index: 1; display: flex; flex-direction: row; align-items: center; @@ -126,6 +156,37 @@ font-feature-settings: "tnum" on, "lnum" on; + background: + linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base); +} + +[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::before { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-end: 100%; + width: 16px; + pointer-events: none; + background: + linear-gradient(to right, transparent, var(--file-tree-v2-row-overlay)), + linear-gradient(to right, transparent, var(--v2-background-bg-base)); +} + +[data-component="file-tree-v2"]:dir(rtl) [data-slot="file-tree-v2-change"]::before { + background: + linear-gradient(to left, transparent, var(--file-tree-v2-row-overlay)), + linear-gradient(to left, transparent, var(--v2-background-bg-base)); +} + +[data-component="file-tree-v2"] [data-slot="file-tree-v2-change"]::after { + content: ""; + position: absolute; + inset-block: 0; + inset-inline-start: 100%; + width: 16px; + pointer-events: none; + background: + linear-gradient(var(--file-tree-v2-row-overlay), var(--file-tree-v2-row-overlay)), var(--v2-background-bg-base); } [data-component="file-tree-v2"] [data-slot="file-tree-v2-change"][data-change="modified"] {