feat(app): add horizontal file sidebar scrolling + right click menu

This commit is contained in:
Aarav Sareen 2026-09-06 12:05:14 +05:30 committed by GitHub
parent cf1923c238
commit e64b2bc137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 716 additions and 195 deletions

View file

@ -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<HTMLElement>(".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<HTMLElement>(".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()

View file

@ -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}
<span class="flex-1 min-w-0 text-start text-12-medium whitespace-nowrap truncate">
<span data-slot="file-tree-v2-label" class="flex-1 shrink-0 text-start text-12-medium whitespace-nowrap">
<bdi dir="auto">
{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 (
<div
ref={setRoot}
@ -235,6 +256,7 @@ export default function FileTreeV2(props: {
top: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
@ -244,29 +266,36 @@ export default function FileTreeV2(props: {
<Show
when={row().node.type === "directory"}
fallback={
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
<OpenInAppContextMenuV2
state={openIn}
path={() =>
resolveOpenInAppPath(location().directory, row().node.absolute || row().node.originalPath)
}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
<FileTreeNodeV2
node={row().node}
level={row().level}
active={active()}
draggable={draggable()}
kinds={props.kinds}
as="button"
type="button"
class="relative"
onFocus={() => setFocused(row().node.path)}
onBlur={() => setFocused(undefined)}
onClick={() => selectFile(row().node, props.onFileClick)}
onDblClick={() => selectFile(row().node, props.onFileDoubleClick)}
>
<GuideLines level={row().level} />
<Show when={row().level > 0}>
<div class="w-4 shrink-0" />
</Show>
<span class="filetree-iconpair size-4">
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
</FileTreeNodeV2>
</OpenInAppContextMenuV2>
}
>
<FileTreeNodeV2
@ -303,3 +332,13 @@ export default function FileTreeV2(props: {
</div>
)
}
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`
})
}

View file

@ -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 (
<div
ref={setRoot}
@ -114,47 +135,48 @@ export function SessionFileList(props: {
style={{
position: "absolute",
top: "0",
left: "0",
"inset-inline-start": "0",
width: "100%",
"min-width": "max-content",
height: `${item().size}px`,
transform: `translateY(${item().start}px)`,
}}
>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-left: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
<Show when={directory()}>
<OpenInAppContextMenuV2 state={openIn} path={() => resolveOpenInAppPath(location().directory, path)}>
<button
type="button"
id={props.optionID?.(path)}
role={props.role ? "option" : undefined}
aria-selected={props.role ? selected() : undefined}
data-slot="file-tree-v2-row"
data-path={path}
data-selected={selected() ? "" : undefined}
data-highlighted={highlightedRow() ? "" : undefined}
style="padding-inline-start: 8px"
onFocus={() => setFocused(path)}
onBlur={() => setFocused(undefined)}
onClick={() => props.onFileClick(path)}
onDblClick={() => props.onFileDoubleClick?.(path)}
>
<span class="filetree-iconpair size-4">
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
</span>
<span data-slot="file-tree-v2-label" class="flex flex-1 shrink-0 items-center whitespace-nowrap">
<Show when={directory()}>
{(value) => <span class="text-12-medium text-text-muted shrink-0">{value()}</span>}
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
</span>
)}
</Show>
</button>
</button>
</OpenInAppContextMenuV2>
</div>
)}
</Show>

View file

@ -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 (
<Show when={props.directory() && state.canOpen()}>
@ -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 }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Menu.RadioGroup
value={state.current().id}
onChange={(value) => {
state.selectApp(value as OpenApp)
}}
>
<For each={state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
disabled={state.opening()}
onSelect={() => {
state.selectApp(option.id)
state.setMenu("open", false)
state.openDir(option.id)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
state.setMenu("open", false)
state.copyPath()
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@ -95,3 +60,116 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Show>
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
reveal?: boolean
selection?: boolean
close?: () => void
}) {
const language = useLanguage()
const path = () => props.path?.()
return (
<>
<Menu.Group>
<Menu.GroupLabel>{language.t("session.header.openIn")}</Menu.GroupLabel>
<Show
when={props.selection !== false}
fallback={
<For each={props.state.options()}>
{(option) => (
<Menu.Item
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.Item>
)}
</For>
}
>
<Menu.RadioGroup
value={props.state.current().id}
onChange={(value) => {
props.state.selectApp(value as OpenApp)
}}
>
<For each={props.state.options()}>
{(option) => (
<Menu.RadioItem
value={option.id}
closeOnSelect
disabled={props.state.opening()}
onSelect={() => {
props.state.selectApp(option.id)
props.close?.()
props.state.openPath(option.id, path(), props.reveal)
}}
>
<AppIcon id={option.icon} />
{option.label}
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Show>
</Menu.Group>
<Menu.Separator />
<Menu.Item
onSelect={() => {
props.close?.()
props.state.copyPath(path())
}}
>
<Icon name="copy" size="small" class="text-icon-weak" />
{language.t("session.header.open.copyPath")}
</Menu.Item>
</>
)
}
export function OpenInAppContextMenuV2(
props: ParentProps<{
state?: OpenInAppState
path: () => string
}>,
) {
const state = props.state
if (!state) return props.children
const [open, setOpen] = createSignal(false)
return (
<Show when={state.canOpen() && props.path()} fallback={props.children}>
<Menu.Context modal={false} onOpenChange={setOpen}>
<Menu.Context.Trigger
as="div"
class="h-full w-full min-w-max"
data-slot="file-tree-v2-context-trigger"
data-context-menu-open={open() ? "" : undefined}
>
{props.children}
</Menu.Context.Trigger>
<Menu.Context.Portal>
<Menu.Context.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2
state={state}
path={props.path}
reveal
selection={false}
close={() => setOpen(false)}
/>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
</Show>
)
}

View file

@ -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")
})
})

View file

@ -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)
}

View file

@ -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<string, Promise<boolean>>()
export const MAC_OPEN_APPS = [
{
id: "vscode",
@ -108,9 +112,7 @@ export function detectOpenAppOS(platform: ReturnType<typeof usePlatform>): 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<typeof useLanguage>, 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<Record<OpenApp, boolean>>)
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<Record<OpenApp, boolean>>)
})
@ -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<typeof usePlatform>, 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
}

View file

@ -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,

View file

@ -124,6 +124,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
<ScrollView
data-slot="session-review-v2-sidebar-tree"
class="group/file-tree-v2"
orientation="both"
thumbVisibility="scroll"
viewportRef={props.viewportRef}
>

View file

@ -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);

View file

@ -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)
})
})

View file

@ -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<typeof setTimeout> | 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 = () => (
<div
ref={(el) => {
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 = () => (
<div
ref={(el) => {
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) {
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
data-orientation={local.orientation}
style={local.style}
onPointerEnter={() => {
if (hoverRoot()) setState("isHovered", true)
@ -363,9 +438,14 @@ export function ScrollView(props: ScrollViewProps) {
</div>
{/* Thumb Overlay — optionally portaled into an external track */}
<Show when={showThumb()}>
<Show when={thumbMount()} fallback={renderThumb()}>
{(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
<Show when={state.showVerticalThumb}>
<Show when={thumbMount()} fallback={renderVerticalThumb()}>
{(mount) => <Portal mount={mount()}>{renderVerticalThumb()}</Portal>}
</Show>
</Show>
<Show when={state.showHorizontalThumb}>
<Show when={thumbMount()} fallback={renderHorizontalThumb()}>
{(mount) => <Portal mount={mount()}>{renderHorizontalThumb()}</Portal>}
</Show>
</Show>
</div>

View file

@ -1,20 +1,30 @@
@property --file-tree-v2-row-overlay {
syntax: "<color>";
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"] {