Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/workspace/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ function MobileFolderWorkspaceShell({
swipeDirection="down"
disablePointerDismissal={false}
>
<DrawerContent showCloseButton={false} className="h-[70vh] p-0">
<DrawerContent showCloseButton={false} className="h-[95vh] p-0">
<DrawerTitle className="sr-only">Terminal</DrawerTitle>
<div className="h-full min-h-0 overflow-hidden">
<TerminalPanel />
Expand Down
18 changes: 18 additions & 0 deletions src/components/ai-elements/file-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
iconClassName?: string
suffix?: ReactNode
suffixClassName?: string
/**
* Right-aligned trailing widget (e.g. a "more" menu button). Rendered
* inside a `FileTreeActions` wrapper so click/keydown don't bubble up to
* the row's own handlers (e.g. the expand/collapse click). Clicks on the
* widget are the caller's responsibility.
*/
actions?: ReactNode
/**
* Props applied to the folder's header row (the trigger button) — e.g.
* `draggable` and drag/drop handlers for file-tree DnD. Placed on the header
Expand Down Expand Up @@ -225,6 +232,7 @@ export const FileTreeFolder = ({
iconClassName,
suffix,
suffixClassName,
actions,
rowProps,
dropActive,
dropTargetDir,
Expand Down Expand Up @@ -331,6 +339,7 @@ export const FileTreeFolder = ({
{suffix}
</span>
) : null}
{actions ? <FileTreeActions>{actions}</FileTreeActions> : null}
</button>
</CollapsibleTrigger>
<CollapsibleContent>
Expand Down Expand Up @@ -365,6 +374,13 @@ export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
/** Nesting depth (0 = top level). See {@link FileTreeFolderProps.depth}: when
* provided the row is full-width and indents its content via padding. */
depth?: number
/**
* Right-aligned trailing widget (e.g. a "more" menu button). Rendered
* inside a `FileTreeActions` wrapper so click/keydown don't bubble up to
* the row's own handlers (e.g. opening a file preview). Clicks on the
* widget are the caller's responsibility.
*/
actions?: ReactNode
}

export const FileTreeFile = ({
Expand All @@ -374,6 +390,7 @@ export const FileTreeFile = ({
depth,
className,
style,
actions,
children,
...props
}: FileTreeFileProps) => {
Expand Down Expand Up @@ -427,6 +444,7 @@ export const FileTreeFile = ({
<FileTreeName>{name}</FileTreeName>
</>
)}
{actions ? <FileTreeActions>{actions}</FileTreeActions> : null}
</div>
</FileTreeFileContext.Provider>
)
Expand Down
39 changes: 36 additions & 3 deletions src/components/layout/aux-panel-file-tree-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useTabStore } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import { useIsMobile } from "@/hooks/use-mobile"
import { useLongPressToOpenMenu } from "@/hooks/use-long-press-to-open-menu"
import {
useWorkspaceActions,
useWorkspaceFileTabs,
Expand All @@ -33,6 +34,7 @@ import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-e
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
import { WorkspaceUploadDialog } from "@/components/layout/workspace-upload-dialog"
import { OpenInSubContent } from "@/components/layout/open-in-menu"
import { RowMoreButton } from "@/components/layout/row-more-button"
import {
createFileTreeEntry,
deleteFileTreeEntry,
Expand Down Expand Up @@ -578,6 +580,7 @@ function RootDropFolder({
path={FILE_TREE_ROOT_PATH}
name={name}
className="font-medium"
actions={<RowMoreButton />}
dropActive={dropActive || desktopDropActive}
dropTargetDir=""
depth={0}
Expand Down Expand Up @@ -689,6 +692,11 @@ function RenderNode({
const isGitignoreIgnored =
ancestorGitignoreIgnored || gitignoreIgnoredPaths.has(node.path)

// Touch / pen long-press opens this row's context menu. Desktop right-click
// is handled by Radix's own contextmenu listener on the trigger; this hook
// composes alongside it (mouse pointers are ignored).
const longPressHandlers = useLongPressToOpenMenu()

const systemExplorerLabel =
typeof navigator === "undefined"
? t("openInFileManager")
Expand Down Expand Up @@ -736,7 +744,16 @@ function RenderNode({

return (
<ContextMenu>
<ContextMenuTrigger>
{/*
asChild merges the Radix trigger's pointerdown / contextmenu handlers
and `WebkitTouchCallout: none` style into the FileTreeFile's own div —
without it, the trigger renders a bare span around a div (which the
HTML parser splits into siblings) and iOS Safari's native callout
eats the long-press gesture before the 700ms Radix timer can open the
menu. See aux-panel-file-tree-tab.tsx around the RootDropFolder
wrapper for the same pattern.
*/}
<ContextMenuTrigger asChild {...longPressHandlers}>
<FileTreeFile
path={node.path}
name={node.name}
Expand All @@ -757,6 +774,7 @@ function RenderNode({
setDragging(false)
dnd.onEntryDragEnd()
}}
actions={<RowMoreButton />}
/>
</ContextMenuTrigger>
<ContextMenuContent>
Expand Down Expand Up @@ -915,10 +933,16 @@ function RenderNode({

return (
<ContextMenu>
<ContextMenuTrigger>
{/*
asChild merges the Radix trigger's pointerdown / contextmenu handlers
and `WebkitTouchCallout: none` style into the FileTreeFolder's own div
— same reasoning as the FileTreeFile wrapper above.
*/}
<ContextMenuTrigger asChild {...longPressHandlers}>
<FileTreeFolder
path={node.path}
name={node.name}
actions={<RowMoreButton />}
suffix={
isLinkedDir ? (
<Link2
Expand Down Expand Up @@ -2893,7 +2917,16 @@ export function FileTreeTab() {
>
{folder?.path && (
<ContextMenu>
<ContextMenuTrigger>
{/*
asChild merges the Radix trigger's pointerdown / contextmenu
handlers and `WebkitTouchCallout: none` style into the
RootDropFolder's own div. Without it the trigger renders a
bare span, whose HTML parser rules disallow div children —
the div ends up as the span's sibling, iOS Safari shows its
native callout on long-press, and the gesture never reaches
the Radix long-press timer.
*/}
<ContextMenuTrigger asChild>
<DesktopDropDirContext.Provider value={desktopDropDir}>
<RootDropFolder name={rootNodeName} dnd={treeDndValue}>
{nodes.map((node) => (
Expand Down
73 changes: 73 additions & 0 deletions src/components/layout/row-more-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { fireEvent, render } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import { RowMoreButton } from "./row-more-button"

// `next-intl`'s `useTranslations` returns the leaf string for the requested
// key. Stub it to a fixed value so the test only checks button behaviour, not
// translation plumbing.
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => `tr:${key}`,
}))

interface Fixture {
row: HTMLElement
button: HTMLElement
onContextMenu: ReturnType<typeof vi.fn>
onRowClick: ReturnType<typeof vi.fn>
}

function renderInRow(): Fixture {
const onContextMenu = vi.fn()
const onRowClick = vi.fn()
const utils = render(
<div data-testid="row" data-tree-row-path="x" onClick={onRowClick}>
<RowMoreButton />
</div>
)
// The RowMoreButton needs to find a row ancestor carrying
// `data-tree-row-path` — wrap the rendered tree in that for the dispatched
// event to bubble to. jsdom won't bubble a `contextmenu` event from a
// `div` to its `oncontextmenu` listener unless React registered it, so we
// wire one on the parent ourselves.
const row = utils.container.querySelector(
"[data-tree-row-path]"
) as HTMLElement
row.addEventListener("contextmenu", onContextMenu as EventListener)
const button = utils.getByLabelText("tr:moreActions")
return { row, button, onContextMenu, onRowClick }
}

describe("RowMoreButton", () => {
afterEach(() => {
vi.restoreAllMocks()
})

it("renders a button labelled with the moreActions translation key", () => {
const { button } = renderInRow()
expect(button.tagName).toBe("BUTTON")
expect(button.getAttribute("aria-label")).toBe("tr:moreActions")
// The icon is hidden from AT — only the label announces the control.
const icon = button.querySelector("svg")
expect(icon?.getAttribute("aria-hidden")).not.toBeNull()
})

it("dispatches a contextmenu MouseEvent on the row when clicked", () => {
const { button, onContextMenu } = renderInRow()
fireEvent.click(button, { clientX: 12, clientY: 34 })
expect(onContextMenu).toHaveBeenCalledTimes(1)
const event = onContextMenu.mock.calls[0][0] as MouseEvent
expect(event.type).toBe("contextmenu")
expect(event.button).toBe(2)
expect(event.clientX).toBe(12)
expect(event.clientY).toBe(34)
expect(event.bubbles).toBe(true)
expect(event.cancelable).toBe(true)
})

it("does not bubble the click up to the row's own onClick", () => {
const { button, onRowClick } = renderInRow()
fireEvent.click(button)
expect(onRowClick).not.toHaveBeenCalled()
})
})
76 changes: 76 additions & 0 deletions src/components/layout/row-more-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client"

import { MoreHorizontal } from "lucide-react"
import type { MouseEvent as ReactMouseEvent } from "react"
import { useTranslations } from "next-intl"

import { cn } from "@/lib/utils"

interface RowMoreButtonProps {
/** Optional className overrides. */
className?: string
/**
* Translation namespace override. Defaults to `Folder.fileTreeTab`. Exposed
* because the same button is reused in places whose menus live under a
* different translation key (e.g. the git-changes tab).
*/
i18nNamespace?: "Folder.fileTreeTab" | "Folder.gitChangesTab"
}

/**
* Tiny horizontal-three-dots button rendered on the right of a tree row.
* Clicking it dispatches a synthetic `contextmenu` MouseEvent on the row so
* the existing Radix `ContextMenu` opens at the button's coordinates.
*
* The row itself owns the context menu (it's the `ContextMenuTrigger` via
* `asChild`); this button is just an alternate, always-visible entry point —
* primarily so touch users have a way to open the menu without resorting to
* long-press (which we want to keep free for drag).
*
* The click is `stopPropagation`-ed so it doesn't fire the row's own
* `onClick` (which would open the file preview / toggle the folder).
*/
export function RowMoreButton({
className,
i18nNamespace = "Folder.fileTreeTab",
}: RowMoreButtonProps) {
const t = useTranslations(i18nNamespace)
return (
<button
type="button"
aria-label={t("moreActions")}
data-row-more-button
onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
// The click event must not reach the row's onClick (open preview) or
// bubble to the ContextMenuTrigger and fire its onClick (which Radix
// may also wire to "open on click" in some configurations). We want
// the synthetic contextmenu below to be the sole opener.
event.stopPropagation()
event.preventDefault()
// Locate the nearest row element. Both FileTreeFile and FileTreeFolder
// stamp `data-tree-row-path` on the interactive element that wraps
// the row, so the trigger and the button share a DOM ancestor we can
// find by walking up.
const row = event.currentTarget.closest<HTMLElement>(
"[data-tree-row-path]"
)
if (!row) return
row.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: event.clientX,
clientY: event.clientY,
})
)
}}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
className
)}
>
<MoreHorizontal className="size-3.5" aria-hidden />
</button>
)
}
Loading