diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index bd28fbebb7..fe32f84aa6 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -1369,6 +1369,7 @@ const FilesPanel: React.FC = ({ const siblingNames = getChildNames(fileTree, inputDialog.parentPath); const errorKey = validateFileName(value, { isRemote: isRemoteCurrentWorkspace, + isDirectory: inputDialog.type === 'newFolder', siblings: siblingNames, }); return errorKey ? t(errorKey, { name: value.trim() }) : null; diff --git a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.scss b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.scss index 19e8d38dfb..45ba7e3be9 100644 --- a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.scss +++ b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.scss @@ -74,6 +74,9 @@ line-height: 1.55; color: var(--color-text-secondary); text-align: left; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; > *:first-child { margin-top: 0; diff --git a/src/web-ui/src/component-library/components/Modal/Modal.test.tsx b/src/web-ui/src/component-library/components/Modal/Modal.test.tsx index 40c50356c1..f57e647e8e 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.test.tsx +++ b/src/web-ui/src/component-library/components/Modal/Modal.test.tsx @@ -90,6 +90,35 @@ describe('Modal behavior', () => { expect(document.body.querySelector('.modal--exiting')).toBeNull(); }); + it('closes only when the pointer press and release both occur on the overlay', () => { + const onClose = vi.fn(); + act(() => { + root.render( + + + , + ); + }); + + const overlay = document.body.querySelector('.modal-overlay') as HTMLDivElement; + const input = document.body.querySelector('.modal input') as HTMLInputElement; + + act(() => { + overlay.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + overlay.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + onClose.mockClear(); + act(() => { + input.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + overlay.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + overlay.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onClose).not.toHaveBeenCalled(); + }); + it('closes a standalone modal on Escape', () => { const onClose = vi.fn(); act(() => { diff --git a/src/web-ui/src/component-library/components/Modal/Modal.tsx b/src/web-ui/src/component-library/components/Modal/Modal.tsx index 6ea7099b26..c4d2b17dd9 100644 --- a/src/web-ui/src/component-library/components/Modal/Modal.tsx +++ b/src/web-ui/src/component-library/components/Modal/Modal.tsx @@ -145,6 +145,7 @@ export const Modal: React.FC = ({ const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 }); const modalRef = useRef(null); const headerRef = useRef(null); + const overlayPointerDownRef = useRef(false); const previousFocusRef = useRef(null); const onCloseRef = useRef(onClose); const generatedTitleId = useId(); @@ -230,6 +231,20 @@ export const Modal: React.FC = ({ }; }, [isOpen]); + const handleOverlayMouseDown = useCallback((event: React.MouseEvent) => { + overlayPointerDownRef.current = event.target === event.currentTarget; + }, []); + + const handleOverlayClick = useCallback((event: React.MouseEvent) => { + const startedAndEndedOnOverlay = + overlayPointerDownRef.current && event.target === event.currentTarget; + overlayPointerDownRef.current = false; + + if (!isExiting && closeOnOverlayClick && startedAndEndedOnOverlay) { + onClose(); + } + }, [closeOnOverlayClick, isExiting, onClose]); + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!draggable || !modalRef.current || !headerRef.current) return; @@ -414,7 +429,8 @@ export const Modal: React.FC = ({ ] .filter(Boolean) .join(' ')} - onClick={!isExiting && closeOnOverlayClick ? onClose : undefined} + onMouseDown={handleOverlayMouseDown} + onClick={handleOverlayClick} >
{ + afterEach(() => { + vi.useRealTimers(); + notificationService.dismissAll(); + }); + + it('uses the default toast duration and closes automatically', () => { + vi.useFakeTimers(); + const id = notificationService.error('Failed'); + + expect(notificationStore.getState().activeNotifications.some((item) => item.id === id)).toBe(true); + + vi.advanceTimersByTime(notificationStore.getState().config.defaultDuration); + + expect(notificationStore.getState().activeNotifications.some((item) => item.id === id)).toBe(false); + }); + + it('remains open when duration is explicitly zero', () => { + vi.useFakeTimers(); + const id = notificationService.error('Failed', { duration: 0 }); + + vi.advanceTimersByTime(notificationStore.getState().config.defaultDuration * 2); + + expect(notificationStore.getState().activeNotifications.some((item) => item.id === id)).toBe(true); + }); +}); diff --git a/src/web-ui/src/shared/notification-system/services/NotificationService.ts b/src/web-ui/src/shared/notification-system/services/NotificationService.ts index 8debef125e..8c7af57a04 100644 --- a/src/web-ui/src/shared/notification-system/services/NotificationService.ts +++ b/src/web-ui/src/shared/notification-system/services/NotificationService.ts @@ -34,10 +34,7 @@ class NotificationService { error(message: string, options?: ToastOptions): string { - return this.toast('error', message, { - ...options, - duration: options?.duration ?? 0 - }); + return this.toast('error', message, options); } diff --git a/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx b/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx index 2f19bfdd00..bb5faf00a1 100644 --- a/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx +++ b/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { ChevronRight, ChevronDown, FolderOpen, FileText, Loader2 } from 'lucide-react'; +import { ChevronRight, ChevronDown, FolderOpen, FileText } from 'lucide-react'; import { Input } from '../../../component-library/components/Input'; import { dragManager } from '../../../shared/services/DragManager'; import { fileTreeDragSource } from '../../../shared/context-system/drag-drop/FileTreeDragSource'; @@ -40,6 +40,7 @@ const RenameInput: React.FC = ({ node, siblings, isRemote, onR const submittedRef = React.useRef(false); const inputRef = useRef(null); const wrapperRef = useRef(null); + const errorRef = useRef(null); const [errorPos, setErrorPos] = useState<{ top: number; left: number } | null>(null); // 聚焦并选中名称主体(文件去掉扩展名,目录全选)。 @@ -63,7 +64,11 @@ const RenameInput: React.FC = ({ node, siblings, isRemote, onR }, [node.name, node.isDirectory]); const validate = (nextValue: string): string | null => { - const errorKey = validateFileName(nextValue, { isRemote, siblings }); + const errorKey = validateFileName(nextValue, { + isRemote, + isDirectory: node.isDirectory, + siblings, + }); return errorKey ? t(errorKey, { name: nextValue.trim() }) : null; }; @@ -139,15 +144,20 @@ const RenameInput: React.FC = ({ node, siblings, isRemote, onR } const rect = inputRef.current.getBoundingClientRect(); - const bubbleHeight = 28; // 浮层预估高度(单行) + const bubbleRect = errorRef.current?.getBoundingClientRect(); + const bubbleHeight = bubbleRect?.height ?? 28; + const bubbleWidth = bubbleRect?.width ?? Math.min(280, window.innerWidth - 16); const spaceBelow = window.innerHeight - rect.bottom; const flip = spaceBelow < bubbleHeight + ERROR_OFFSET; + const nextPos = { + top: Math.max(8, flip ? rect.top - bubbleHeight - ERROR_OFFSET : rect.bottom + ERROR_OFFSET), + left: Math.max(8, Math.min(rect.left, window.innerWidth - bubbleWidth - 8)), + }; - setErrorPos({ - top: flip ? rect.top - bubbleHeight - ERROR_OFFSET : rect.bottom + ERROR_OFFSET, - left: rect.left, - }); - }, [error, value]); + if (errorPos?.top !== nextPos.top || errorPos.left !== nextPos.left) { + setErrorPos(nextPos); + } + }, [error, value, errorPos]); // 窗口尺寸变化时重算气泡位置。 useEffect(() => { @@ -163,6 +173,7 @@ const RenameInput: React.FC = ({ node, siblings, isRemote, onR ? createPortal(
@@ -221,7 +232,6 @@ export const FileTreeItem: React.FC = ({ indentPx, isSelected = false, isExpanded = false, - isLoading = false, className = '', renamingPath, onRename, @@ -311,9 +321,7 @@ export const FileTreeItem: React.FC = ({ > {node.isDirectory ? ( - {isLoading ? ( - - ) : isExpanded ? ( + {isExpanded ? ( ) : ( diff --git a/src/web-ui/src/tools/file-system/styles/FileExplorer.scss b/src/web-ui/src/tools/file-system/styles/FileExplorer.scss index bf7875b4e1..af8112db19 100644 --- a/src/web-ui/src/tools/file-system/styles/FileExplorer.scss +++ b/src/web-ui/src/tools/file-system/styles/FileExplorer.scss @@ -353,7 +353,7 @@ $_indent-width: 8px; .bitfun-file-explorer__rename-error { position: fixed; z-index: 10000; - max-width: 280px; + max-width: min(280px, calc(100vw - 16px)); padding: 4px 8px; border-radius: 4px; background: var(--color-overlay-black-80, rgba(0, 0, 0, 0.8)); @@ -362,9 +362,9 @@ $_indent-width: 8px; line-height: 1.4; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); pointer-events: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; } .bitfun-file-explorer__compressed-path { diff --git a/src/web-ui/src/tools/file-system/utils/validateFileName.test.ts b/src/web-ui/src/tools/file-system/utils/validateFileName.test.ts new file mode 100644 index 0000000000..68ed82ae4b --- /dev/null +++ b/src/web-ui/src/tools/file-system/utils/validateFileName.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { validateFileName } from './validateFileName'; + +describe('validateFileName', () => { + it('uses distinct invalid-character errors for files and folders', () => { + expect(validateFileName('invalid:name', { isRemote: false })).toBe( + 'validation.invalidFilename', + ); + expect( + validateFileName('invalid:name', { isRemote: false, isDirectory: true }), + ).toBe('validation.invalidFolderName'); + }); + + it('reports duplicate names without losing the shared file/folder error', () => { + expect( + validateFileName('README.md', { + isRemote: false, + isDirectory: false, + siblings: ['readme.md'], + }), + ).toBe('validation.duplicateName'); + }); +}); diff --git a/src/web-ui/src/tools/file-system/utils/validateFileName.ts b/src/web-ui/src/tools/file-system/utils/validateFileName.ts index 541f27def7..34db749fdb 100644 --- a/src/web-ui/src/tools/file-system/utils/validateFileName.ts +++ b/src/web-ui/src/tools/file-system/utils/validateFileName.ts @@ -22,6 +22,8 @@ export const MAX_NAME_LENGTH = 255; export interface ValidateFileNameContext { /** 当前是否为远程工作区(远程放宽非法字符集合,且不检查 Windows 保留名)。 */ isRemote: boolean; + /** 被校验对象是否为文件夹,用于选择准确的错误文案。 */ + isDirectory?: boolean; /** 同级已存在的名称(大小写不敏感比较),用于检测重名冲突。需已排除被校验对象自身原名。 */ siblings?: string[]; /** 单路径段名称最大长度,默认 {@link MAX_NAME_LENGTH}。 */ @@ -47,7 +49,9 @@ export function validateFileName(raw: string, ctx: ValidateFileNameContext): str // 2. 非法字符 const validPattern = ctx.isRemote ? INVALID_CHARS_REMOTE : INVALID_CHARS_LOCAL; if (!validPattern.test(trimmed)) { - return 'validation.invalidFilename'; + return ctx.isDirectory + ? 'validation.invalidFolderName' + : 'validation.invalidFilename'; } // 3. Windows 保留名(仅本地)