| author | |
| committer | |
| log | 08e4932f0465327968368b22be343b58a07d476a |
| tree | 9f8b11dd6eee820f68a7b56d8d6898cce88d25e9 |
| parent | 1b4830ff06bf80e505dbf669deef189af85f77d7 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 97 insertions(+), 40 deletions(-)
apps/web/src/components/RightPanelTabs.tsx+1| ... | ... | @@ -358,6 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 358 | 358 | <div |
| 359 | 359 | className={cn( |
| 360 | 360 | "workspace-topbar gap-1 pl-2", |
| 361 | !ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]", | |
| 361 | 362 | props.mode === "inline" ? "pr-28" : "pr-3", |
| 362 | 363 | ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", |
| 363 | 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 | 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, |
| 4 | 4 | } from "@pierre/trees"; |
| 5 | 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 6 | import { FileTree, useFileTree } from "@pierre/trees/react"; | |
| 6 | import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; | |
| 7 | 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; |
| 8 | import { RefreshCw, Search } from "lucide-react"; | |
| 8 | import { RotateCw } from "lucide-react"; | |
| 9 | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 10 | 10 | |
| 11 | import { Button } from "~/components/ui/button"; | |
| 12 | import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; | |
| 11 | 13 | import { toastManager } from "~/components/ui/toast"; |
| 14 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | |
| 12 | 15 | import { useComposerHandleContext } from "~/composerHandleContext"; |
| 13 | 16 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 14 | 17 | import { useTheme } from "~/hooks/useTheme"; |
| ... | ... | @@ -42,6 +45,55 @@ function treePath(entry: ProjectEntry): string { |
| 42 | 45 | return entry.kind === "directory" ? `${entry.path}/` : entry.path; |
| 43 | 46 | } |
| 44 | 47 | |
| 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 | ||
| 45 | 97 | export default function FileBrowserPanel({ |
| 46 | 98 | environmentId, |
| 47 | 99 | cwd, |
| ... | ... | @@ -176,9 +228,17 @@ export default function FileBrowserPanel({ |
| 176 | 228 | } |
| 177 | 229 | }, |
| 178 | 230 | paths: [], |
| 179 | search: true, | |
| 231 | search: false, | |
| 180 | 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 | }; | |
| 182 | 242 | |
| 183 | 243 | useEffect(() => { |
| 184 | 244 | if (previousTreePathsRef.current === treePaths) return; |
| ... | ... | @@ -187,11 +247,6 @@ export default function FileBrowserPanel({ |
| 187 | 247 | model.resetPaths(treePaths); |
| 188 | 248 | }, [entryKinds, model, treePaths]); |
| 189 | 249 | |
| 190 | const fileCount = useMemo( | |
| 191 | () => entries.reduce((count, entry) => count + (entry.kind === "file" ? 1 : 0), 0), | |
| 192 | [entries], | |
| 193 | ); | |
| 194 | ||
| 195 | 250 | // Tag tree drags with the composer mention payload. The row is read from |
| 196 | 251 | // the composed event path (the tree's shadow root is open), so this does |
| 197 | 252 | // not depend on running after the tree's own dragstart handler; the drag |
| ... | ... | @@ -223,32 +278,15 @@ export default function FileBrowserPanel({ |
| 223 | 278 | className="flex min-h-0 flex-1 flex-col bg-background" |
| 224 | 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"> | |
| 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 | /> | |
| 252 | 290 | </div> |
| 253 | 291 | {entriesQuery.error && entriesQuery.data === null ? ( |
| 254 | 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 | 166 | </Tooltip> |
| 167 | 167 | </div> |
| 168 | 168 | |
| 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 | 170 | <Tooltip> |
| 171 | 171 | <TooltipTrigger |
| 172 | 172 | render={ |
apps/web/src/components/ui/input-group.tsx+23-5| ... | ... | @@ -7,13 +7,31 @@ import { cn } from "~/lib/utils"; |
| 7 | 7 | import { Input, type InputProps } from "~/components/ui/input"; |
| 8 | 8 | import { Textarea, type TextareaProps } from "~/components/ui/textarea"; |
| 9 | 9 | |
| 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>) { | |
| 11 | 32 | return ( |
| 12 | 33 | <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)} | |
| 17 | 35 | data-slot="input-group" |
| 18 | 36 | role="group" |
| 19 | 37 | {...props} |