Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
<div
className={cn(
"workspace-topbar gap-1 pl-2",
!ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",
props.mode === "inline" ? "pr-28" : "pr-3",
ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
Expand Down
106 changes: 72 additions & 34 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import type {
ContextMenuOpenContext as TreeContextMenuOpenContext,
} from "@pierre/trees";
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
import { FileTree, useFileTree } from "@pierre/trees/react";
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
import { RefreshCw, Search } from "lucide-react";
import { RotateCw } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";

import { Button } from "~/components/ui/button";
import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
import { toastManager } from "~/components/ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
import { useComposerHandleContext } from "~/composerHandleContext";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { useTheme } from "~/hooks/useTheme";
Expand Down Expand Up @@ -42,6 +45,55 @@ function treePath(entry: ProjectEntry): string {
return entry.kind === "directory" ? `${entry.path}/` : entry.path;
}

function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Refresh workspace files"
onClick={props.onRefresh}
/>
}
>
<RotateCw className={cn(props.isPending && "animate-spin")} />
</TooltipTrigger>
<TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup>
</Tooltip>
);
}

function FileSearchField(props: {
ariaLabel: string;
name: string;
onClose: () => void;
onValueChange: (value: string) => void;
value: string;
}) {
return (
<InputGroup variant="ghost" className="h-7 min-w-0 flex-1 rounded-md">
<InputGroupInput
type="search"
name={props.name}
size="sm"
value={props.value}
aria-label={props.ariaLabel}
placeholder="Search files"
spellCheck={false}
onChange={(event) => props.onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
props.onClose();
event.currentTarget.blur();
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape closes panel sheet

Medium Severity

The new files search field handles Escape by closing search and blurring, but it never calls preventDefault. In sheet mode the right panel uses a Base UI dialog, so that same keypress also dismisses the whole panel instead of only clearing search.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b4e4043. Configure here.

/>
</InputGroup>
);
}

export default function FileBrowserPanel({
environmentId,
cwd,
Expand Down Expand Up @@ -176,9 +228,17 @@ export default function FileBrowserPanel({
}
},
paths: [],
search: true,
search: false,
unsafeCSS: TREE_UNSAFE_CSS,
});
const search = useFileTreeSearch(model);
const handleSearchValueChange = (value: string) => {
if (value.trim().length === 0) {
search.close();
return;
}
search.setValue(value);
};

useEffect(() => {
if (previousTreePathsRef.current === treePaths) return;
Expand All @@ -187,11 +247,6 @@ export default function FileBrowserPanel({
model.resetPaths(treePaths);
}, [entryKinds, model, treePaths]);

const fileCount = useMemo(
() => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0),
[entries],
);

// Tag tree drags with the composer mention payload. The row is read from
// the composed event path (the tree's shadow root is open), so this does
// not depend on running after the tree's own dragstart handler; the drag
Expand Down Expand Up @@ -223,32 +278,15 @@ export default function FileBrowserPanel({
className="flex min-h-0 flex-1 flex-col bg-background"
data-file-browser-panel={`${environmentId}:${cwd}`}
>
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-3">
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-foreground">{projectName}</div>
<div className="truncate text-[10px] leading-none text-muted-foreground">
{entriesQuery.isPending && entriesQuery.data === null
? "Indexing…"
: `${fileCount.toLocaleString()} files`}
{entriesQuery.data?.truncated ? " · partial" : ""}
</div>
</div>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Search workspace files"
onClick={() => model.openSearch()}
>
<Search className="size-3.5" />
</button>
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Refresh workspace files"
onClick={entriesQuery.refresh}
>
<RefreshCw className={cn("size-3.5", entriesQuery.isPending && "animate-spin")} />
</button>
<div className="surface-subheader gap-1 px-2" data-surface-subheader>
<RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={entriesQuery.refresh} />
<FileSearchField
name="project-files-search"
ariaLabel={`Search ${projectName} files`}
value={search.value}
onValueChange={handleSearchValueChange}
onClose={search.close}
/>
</div>
{entriesQuery.error && entriesQuery.data === null ? (
<div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/preview/PreviewChromeRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function PreviewChromeRow({
</Tooltip>
</div>

<InputGroup className="group/address h-7 flex-1 rounded-md border-transparent bg-transparent shadow-none before:shadow-none hover:bg-muted/40 focus-within:bg-background">
<InputGroup variant="ghost" className="group/address h-7 flex-1 rounded-md">
<Tooltip>
<TooltipTrigger
render={
Expand Down
28 changes: 23 additions & 5 deletions apps/web/src/components/ui/input-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,31 @@ import { cn } from "~/lib/utils";
import { Input, type InputProps } from "~/components/ui/input";
import { Textarea, type TextareaProps } from "~/components/ui/textarea";

function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
const inputGroupVariants = cva(
"relative inline-flex w-full min-w-0 items-center rounded-lg border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
{
defaultVariants: {
variant: "default",
},
variants: {
variant: {
default:
"border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]",
ghost:
"border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background",
},
},
},
);

function InputGroup({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
return (
<div
className={cn(
"relative inline-flex w-full min-w-0 items-center rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)] has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]",
className,
)}
className={cn(inputGroupVariants({ variant }), className)}
data-slot="input-group"
role="group"
{...props}
Expand Down
Loading