mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 03:34:41 +00:00
feat(app): add horizontal file sidebar scrolling + right click menu
This commit is contained in:
parent
cf1923c238
commit
e64b2bc137
13 changed files with 716 additions and 195 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
36
packages/app/src/session/files/open-in-app-path.test.ts
Normal file
36
packages/app/src/session/files/open-in-app-path.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
19
packages/app/src/session/files/open-in-app-path.ts
Normal file
19
packages/app/src/session/files/open-in-app-path.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue