Skip to content

Commit b4e4043

Browse files
feat(web): simplify files panel header
- Add inline file search with refresh control - Reuse ghost input styling and normalize panel header spacing
1 parent 1b4830f commit b4e4043

4 files changed

Lines changed: 97 additions & 40 deletions

File tree

‎apps/web/src/components/RightPanelTabs.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
358358
<div
359359
className={cn(
360360
"workspace-topbar gap-1 pl-2",
361+
!ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",
361362
props.mode === "inline" ? "pr-28" : "pr-3",
362363
ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
363364
props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,

‎apps/web/src/components/files/FileBrowserPanel.tsx‎

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@ import type {
33
ContextMenuOpenContext as TreeContextMenuOpenContext,
44
} from "@pierre/trees";
55
import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6-
import { FileTree, useFileTree } from "@pierre/trees/react";
6+
import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
77
import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8-
import { RefreshCw, Search } from "lucide-react";
8+
import { RotateCw } from "lucide-react";
99
import { useEffect, useMemo, useRef } from "react";
1010

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

48+
function RefreshFilesButton(props: { isPending: boolean; onRefresh: () => void }) {
49+
return (
50+
<Tooltip>
51+
<TooltipTrigger
52+
render={
53+
<Button
54+
type="button"
55+
variant="ghost"
56+
size="icon-xs"
57+
aria-label="Refresh workspace files"
58+
onClick={props.onRefresh}
59+
/>
60+
}
61+
>
62+
<RotateCw className={cn(props.isPending && "animate-spin")} />
63+
</TooltipTrigger>
64+
<TooltipPopup>{props.isPending ? "Refreshing…" : "Refresh files"}</TooltipPopup>
65+
</Tooltip>
66+
);
67+
}
68+
69+
function FileSearchField(props: {
70+
ariaLabel: string;
71+
name: string;
72+
onClose: () => void;
73+
onValueChange: (value: string) => void;
74+
value: string;
75+
}) {
76+
return (
77+
<InputGroup variant="ghost" className="h-7 min-w-0 flex-1 rounded-md">
78+
<InputGroupInput
79+
type="search"
80+
name={props.name}
81+
size="sm"
82+
value={props.value}
83+
aria-label={props.ariaLabel}
84+
placeholder="Search files"
85+
spellCheck={false}
86+
onChange={(event) => props.onValueChange(event.target.value)}
87+
onKeyDown={(event) => {
88+
if (event.key !== "Escape") return;
89+
props.onClose();
90+
event.currentTarget.blur();
91+
}}
92+
/>
93+
</InputGroup>
94+
);
95+
}
96+
4597
export default function FileBrowserPanel({
4698
environmentId,
4799
cwd,
@@ -176,9 +228,17 @@ export default function FileBrowserPanel({
176228
}
177229
},
178230
paths: [],
179-
search: true,
231+
search: false,
180232
unsafeCSS: TREE_UNSAFE_CSS,
181233
});
234+
const search = useFileTreeSearch(model);
235+
const handleSearchValueChange = (value: string) => {
236+
if (value.trim().length === 0) {
237+
search.close();
238+
return;
239+
}
240+
search.setValue(value);
241+
};
182242

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

190-
const fileCount = useMemo(
191-
() => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0),
192-
[entries],
193-
);
194-
195250
// Tag tree drags with the composer mention payload. The row is read from
196251
// the composed event path (the tree's shadow root is open), so this does
197252
// not depend on running after the tree's own dragstart handler; the drag
@@ -223,32 +278,15 @@ export default function FileBrowserPanel({
223278
className="flex min-h-0 flex-1 flex-col bg-background"
224279
data-file-browser-panel={`${environmentId}:${cwd}`}
225280
>
226-
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-3">
227-
<div className="min-w-0 flex-1">
228-
<div className="truncate text-xs font-medium text-foreground">{projectName}</div>
229-
<div className="truncate text-[10px] leading-none text-muted-foreground">
230-
{entriesQuery.isPending && entriesQuery.data === null
231-
? "Indexing…"
232-
: `${fileCount.toLocaleString()} files`}
233-
{entriesQuery.data?.truncated ? " · partial" : ""}
234-
</div>
235-
</div>
236-
<button
237-
type="button"
238-
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
239-
aria-label="Search workspace files"
240-
onClick={() => model.openSearch()}
241-
>
242-
<Search className="size-3.5" />
243-
</button>
244-
<button
245-
type="button"
246-
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
247-
aria-label="Refresh workspace files"
248-
onClick={entriesQuery.refresh}
249-
>
250-
<RefreshCw className={cn("size-3.5", entriesQuery.isPending && "animate-spin")} />
251-
</button>
281+
<div className="surface-subheader gap-1 px-2" data-surface-subheader>
282+
<RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={entriesQuery.refresh} />
283+
<FileSearchField
284+
name="project-files-search"
285+
ariaLabel={`Search ${projectName} files`}
286+
value={search.value}
287+
onValueChange={handleSearchValueChange}
288+
onClose={search.close}
289+
/>
252290
</div>
253291
{entriesQuery.error && entriesQuery.data === null ? (
254292
<div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>

‎apps/web/src/components/preview/PreviewChromeRow.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export function PreviewChromeRow({
166166
</Tooltip>
167167
</div>
168168

169-
<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">
169+
<InputGroup variant="ghost" className="group/address h-7 flex-1 rounded-md">
170170
<Tooltip>
171171
<TooltipTrigger
172172
render={

‎apps/web/src/components/ui/input-group.tsx‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,31 @@ import { cn } from "~/lib/utils";
77
import { Input, type InputProps } from "~/components/ui/input";
88
import { Textarea, type TextareaProps } from "~/components/ui/textarea";
99

10-
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
10+
const inputGroupVariants = cva(
11+
"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)]",
12+
{
13+
defaultVariants: {
14+
variant: "default",
15+
},
16+
variants: {
17+
variant: {
18+
default:
19+
"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%)]",
20+
ghost:
21+
"border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background",
22+
},
23+
},
24+
},
25+
);
26+
27+
function InputGroup({
28+
className,
29+
variant,
30+
...props
31+
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
1132
return (
1233
<div
13-
className={cn(
14-
"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)]",
15-
className,
16-
)}
34+
className={cn(inputGroupVariants({ variant }), className)}
1735
data-slot="input-group"
1836
role="group"
1937
{...props}

0 commit comments

Comments
 (0)