authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 12:36:54+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 10:36:54+00:00
log08e4932f0465327968368b22be343b58a07d476a
tree9f8b11dd6eee820f68a7b56d8d6898cce88d25e9
parent1b4830ff06bf80e505dbf669deef189af85f77d7
signature Signed by PGP key B5690EEEBB952194

Simplify files panel header (#4828)


4 files changed, 97 insertions(+), 40 deletions(-)

apps/web/src/components/RightPanelTabs.tsx+1
...@@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
358 <div358 <div
359 className={cn(359 className={cn(
360 "workspace-topbar gap-1 pl-2",360 "workspace-topbar gap-1 pl-2",
361 !ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",
361 props.mode === "inline" ? "pr-28" : "pr-3",362 props.mode === "inline" ? "pr-28" : "pr-3",
362 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",363 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
363 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,364 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
apps/web/src/components/files/FileBrowserPanel.tsx+72-34
...@@ -3,12 +3,15 @@ import type {...@@ -3,12 +3,15 @@ import type {
3 ContextMenuOpenContext as TreeContextMenuOpenContext,3 ContextMenuOpenContext as TreeContextMenuOpenContext,
4} from "@pierre/trees";4} from "@pierre/trees";
5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
6import { FileTree, useFileTree } from "@pierre/trees/react";6import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
8import { RefreshCw, Search } from "lucide-react";8import { RotateCw } from "lucide-react";
9import { useEffect, useMemo, useRef } from "react";9import { useEffect, useMemo, useRef } from "react";
1010
11import { Button } from "~/components/ui/button";
12import { InputGroup, InputGroupInput } from "~/components/ui/input-group";
11import { toastManager } from "~/components/ui/toast";13import { toastManager } from "~/components/ui/toast";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
12import { useComposerHandleContext } from "~/composerHandleContext";15import { useComposerHandleContext } from "~/composerHandleContext";
13import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";16import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
14import { useTheme } from "~/hooks/useTheme";17import { useTheme } from "~/hooks/useTheme";
...@@ -42,6 +45,55 @@ function treePath(entry: ProjectEntry): string {...@@ -42,6 +45,55 @@ function treePath(entry: ProjectEntry): string {
42 return entry.kind === "directory" ? `${entry.path}/` : entry.path;45 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
43}46}
4447
48function 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
69function 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
45export default function FileBrowserPanel({97export default function FileBrowserPanel({
46 environmentId,98 environmentId,
47 cwd,99 cwd,
...@@ -176,9 +228,17 @@ export default function FileBrowserPanel({...@@ -176,9 +228,17 @@ export default function FileBrowserPanel({
176 }228 }
177 },229 },
178 paths: [],230 paths: [],
179 search: true,231 search: false,
180 unsafeCSS: TREE_UNSAFE_CSS,232 unsafeCSS: TREE_UNSAFE_CSS,
181 });233 });
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
183 useEffect(() => {243 useEffect(() => {
184 if (previousTreePathsRef.current === treePaths) return;244 if (previousTreePathsRef.current === treePaths) return;
...@@ -187,11 +247,6 @@ export default function FileBrowserPanel({...@@ -187,11 +247,6 @@ export default function FileBrowserPanel({
187 model.resetPaths(treePaths);247 model.resetPaths(treePaths);
188 }, [entryKinds, model, treePaths]);248 }, [entryKinds, model, treePaths]);
189249
190 const fileCount = useMemo(
191 () => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0),
192 [entries],
193 );
194
195 // Tag tree drags with the composer mention payload. The row is read from250 // Tag tree drags with the composer mention payload. The row is read from
196 // the composed event path (the tree's shadow root is open), so this does251 // the composed event path (the tree's shadow root is open), so this does
197 // not depend on running after the tree's own dragstart handler; the drag252 // not depend on running after the tree's own dragstart handler; the drag
...@@ -223,32 +278,15 @@ export default function FileBrowserPanel({...@@ -223,32 +278,15 @@ export default function FileBrowserPanel({
223 className="flex min-h-0 flex-1 flex-col bg-background"278 className="flex min-h-0 flex-1 flex-col bg-background"
224 data-file-browser-panel={`${environmentId}:${cwd}`}279 data-file-browser-panel={`${environmentId}:${cwd}`}
225 >280 >
226 <div className="flex h-9 shrink-0 items-center gap-2 border-b border-border/60 px-3">281 <div className="surface-subheader gap-1 px-2" data-surface-subheader>
227 <div className="min-w-0 flex-1">282 <RefreshFilesButton isPending={entriesQuery.isPending} onRefresh={entriesQuery.refresh} />
228 <div className="truncate text-xs font-medium text-foreground">{projectName}</div>283 <FileSearchField
229 <div className="truncate text-[10px] leading-none text-muted-foreground">284 name="project-files-search"
230 {entriesQuery.isPending && entriesQuery.data === null285 ariaLabel={`Search ${projectName} files`}
231 ? "Indexing…"286 value={search.value}
232 : `${fileCount.toLocaleString()} files`}287 onValueChange={handleSearchValueChange}
233 {entriesQuery.data?.truncated ? " · partial" : ""}288 onClose={search.close}
234 </div>289 />
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>
252 </div>290 </div>
253 {entriesQuery.error && entriesQuery.data === null ? (291 {entriesQuery.error && entriesQuery.data === null ? (
254 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>292 <div className="p-4 text-xs leading-relaxed text-destructive">{entriesQuery.error}</div>
apps/web/src/components/preview/PreviewChromeRow.tsx+1-1
...@@ -166,7 +166,7 @@ export function PreviewChromeRow({...@@ -166,7 +166,7 @@ export function PreviewChromeRow({
166 </Tooltip>166 </Tooltip>
167 </div>167 </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">
170 <Tooltip>170 <Tooltip>
171 <TooltipTrigger171 <TooltipTrigger
172 render={172 render={
apps/web/src/components/ui/input-group.tsx+23-5
...@@ -7,13 +7,31 @@ import { cn } from "~/lib/utils";...@@ -7,13 +7,31 @@ import { cn } from "~/lib/utils";
7import { Input, type InputProps } from "~/components/ui/input";7import { Input, type InputProps } from "~/components/ui/input";
8import { Textarea, type TextareaProps } from "~/components/ui/textarea";8import { Textarea, type TextareaProps } from "~/components/ui/textarea";
99
10function InputGroup({ className, ...props }: React.ComponentProps<"div">) {10const 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
27function InputGroup({
28 className,
29 variant,
30 ...props
31}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupVariants>) {
11 return (32 return (
12 <div33 <div
13 className={cn(34 className={cn(inputGroupVariants({ variant }), className)}
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 )}
17 data-slot="input-group"35 data-slot="input-group"
18 role="group"36 role="group"
19 {...props}37 {...props}