/* Copyright 2026 Marimo. All rights reserved. */ import { Popover as PopoverPrimitive } from "radix-ui"; const PopoverAnchor = PopoverPrimitive.Anchor; import { FilePenIcon } from "lucide-react"; import { type JSX, useEffect, useRef, useState } from "react"; import { FILE_ICON as FILE_TYPE_ICONS, guessFileIconType as guessFileType, } from "@/components/editor/file-tree/file-icons"; import type { FileInfo } from "@/core/network/types"; import { useAsyncData } from "@/hooks/useAsyncData"; import { Paths } from "@/utils/paths"; import { cn } from "../../../utils/cn"; import { Command, CommandEmpty, CommandInput, CommandItem, CommandList, } from "../../ui/command"; import { Popover, PopoverContent } from "../../ui/popover"; import "./filename-input.css"; import { getFeatureFlag } from "@/core/config/feature-flag"; import { useRequestClient } from "@/core/network/requests"; import { ErrorBoundary } from "../boundary/ErrorBoundary"; interface FilenameInputProps { resetOnBlur?: boolean; placeholderText?: string; initialValue?: string | null; className?: string; flexibleWidth?: boolean; onNameChange: (value: string) => void; } export const FilenameInput = ({ resetOnBlur = false, placeholderText, initialValue = null, flexibleWidth = false, onNameChange, className, }: FilenameInputProps): JSX.Element => { const { sendListFiles } = useRequestClient(); const [searchValue, setSearchValue] = useState(initialValue); const [suggestions, setSuggestions] = useState([]); const [focused, setFocused] = useState(false); const inputRef = useRef(null); const skipReset = useRef(false); useEffect(() => { setSearchValue(initialValue); }, [initialValue]); const onFocus = () => { setFocused(true); }; const onBlur = (evt: React.FocusEvent) => { // If we are coming from a click event from inside the popover, don't blur if (evt.relatedTarget?.closest(".filename-input")) { return; } setFocused(false); if (resetOnBlur) { setSearchValue(initialValue); } }; const dirname = Paths.dirname(searchValue || ""); const basename = Paths.basename(searchValue || ""); const filteredSuggestions = suggestions.filter((suggestion) => Paths.basename(suggestion.path).startsWith(basename), ); const { isPending } = useAsyncData(async () => { if (!focused) { setSuggestions([]); return; } const data = await sendListFiles({ path: dirname }); setSuggestions(data.files); }, [dirname, focused]); const suggestedNamed = getSuggestion(searchValue, suggestions, initialValue); const handleNameChange = () => { if (suggestedNamed) { // Don't reset the value skipReset.current = true; onNameChange(suggestedNamed); inputRef.current?.blur(); } }; const shouldShowList = suggestedNamed || filteredSuggestions.length > 0; const suggestionsList = shouldShowList && ( {!isPending && No files} {suggestedNamed && ( {" "} {initialValue ? "Rename to: " : "Save as: "} {Paths.basename(suggestedNamed)} )} {filteredSuggestions.map((suggestion) => { const fileType = suggestion.isDirectory ? "directory" : guessFileType(suggestion.path); const Icon = FILE_TYPE_ICONS[fileType]; const handleCommand = () => { if (suggestion.isDirectory) { setSearchValue(`${suggestion.path}/`); } else { setSearchValue(suggestion.path); } }; return ( {Paths.basename(suggestion.path)} ); })} ); const size = Math.max(20, searchValue?.length || placeholderText?.length || 0) * 10; return ( { if (e.key === "Escape") { e.currentTarget.blur(); } }} icon={null} ref={inputRef} onValueChange={setSearchValue} placeholder={placeholderText} autoComplete="off" style={flexibleWidth ? { maxWidth: size } : undefined} className={cn( className, "w-full px-4 py-1 my-1 h-9 font-mono text-foreground/60", )} /> {suggestionsList} ); }; function getSuggestion( search: string | undefined | null, existing: FileInfo[], currentFilename: string | null, ): string | undefined { if (!search) { return; } if (search.endsWith("/")) { return; } // Matches allowed files in marimo/_utils/marimo_path.py const extensionsToLeave = getFeatureFlag("markdown") ? new Set(["py", "md", "markdown", "qmd"]) : new Set(["py"]); if (extensionsToLeave.has(Paths.extension(search))) { // If ends with an allowed extension, leave as is } else if (search.endsWith(".")) { search = `${search}py`; } else if (search.endsWith(".p")) { search = `${search}y`; } else { search = `${search}.py`; } if ( existing.some((s) => s.path === search || Paths.basename(s.path) === search) ) { return; } if ( currentFilename && (currentFilename === search || Paths.basename(currentFilename) === search) ) { return; } return search; }