Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/web-ui/src/app/components/panels/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,7 @@ const FilesPanel: React.FC<FilesPanelProps> = ({
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions src/web-ui/src/component-library/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Modal isOpen onClose={onClose} title="Overlay behavior">
<input defaultValue="Selectable content" />
</Modal>,
);
});

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(() => {
Expand Down
18 changes: 17 additions & 1 deletion src/web-ui/src/component-library/components/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export const Modal: React.FC<ModalProps> = ({
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const modalRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const overlayPointerDownRef = useRef(false);
const previousFocusRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
const generatedTitleId = useId();
Expand Down Expand Up @@ -230,6 +231,20 @@ export const Modal: React.FC<ModalProps> = ({
};
}, [isOpen]);

const handleOverlayMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
overlayPointerDownRef.current = event.target === event.currentTarget;
}, []);

const handleOverlayClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
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;

Expand Down Expand Up @@ -414,7 +429,8 @@ export const Modal: React.FC<ModalProps> = ({
]
.filter(Boolean)
.join(' ')}
onClick={!isExiting && closeOnOverlayClick ? onClose : undefined}
onMouseDown={handleOverlayMouseDown}
onClick={handleOverlayClick}
>
<div
ref={modalRef}
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/en-US/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
},
"validation": {
"invalidFilename": "File name contains invalid characters",
"invalidFolderName": "Folder name contains invalid characters",
"emptyName": "Type a name",
"duplicateName": "A file or folder with the name \"{{name}}\" already exists",
"reservedName": "This name is a reserved device name",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-CN/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
},
"validation": {
"invalidFilename": "文件名包含非法字符",
"invalidFolderName": "文件夹名包含非法字符",
"emptyName": "请输入名称",
"duplicateName": "名为 \"{{name}}\" 的文件或文件夹已存在",
"reservedName": "该名称是系统保留名",
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/locales/zh-TW/panels/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
},
"validation": {
"invalidFilename": "檔案名包含非法字符",
"invalidFolderName": "資料夾名包含非法字符",
"emptyName": "請輸入名稱",
"duplicateName": "名為 \"{{name}}\" 的檔案或資料夾已存在",
"reservedName": "該名稱是系統保留名",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { notificationStore } from '../store/NotificationStore';
import { notificationService } from './NotificationService';

describe('NotificationService error toast', () => {
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}


Expand Down
32 changes: 20 additions & 12 deletions src/web-ui/src/tools/file-system/components/FileTreeItem.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,6 +40,7 @@ const RenameInput: React.FC<RenameInputProps> = ({ node, siblings, isRemote, onR
const submittedRef = React.useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const errorRef = useRef<HTMLDivElement>(null);
const [errorPos, setErrorPos] = useState<{ top: number; left: number } | null>(null);

// 聚焦并选中名称主体(文件去掉扩展名,目录全选)。
Expand All @@ -63,7 +64,11 @@ const RenameInput: React.FC<RenameInputProps> = ({ 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;
};

Expand Down Expand Up @@ -139,15 +144,20 @@ const RenameInput: React.FC<RenameInputProps> = ({ 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(() => {
Expand All @@ -163,6 +173,7 @@ const RenameInput: React.FC<RenameInputProps> = ({ node, siblings, isRemote, onR
? createPortal(
<div
className="bitfun-file-explorer__rename-error"
ref={errorRef}
style={{ top: errorPos.top, left: errorPos.left }}
role="alert"
>
Expand Down Expand Up @@ -221,7 +232,6 @@ export const FileTreeItem: React.FC<FileTreeItemProps> = ({
indentPx,
isSelected = false,
isExpanded = false,
isLoading = false,
className = '',
renamingPath,
onRename,
Expand Down Expand Up @@ -311,9 +321,7 @@ export const FileTreeItem: React.FC<FileTreeItemProps> = ({
>
{node.isDirectory ? (
<span className={`bitfun-file-explorer__expand-icon ${isExpanded ? 'bitfun-file-explorer__expand-icon--expanded' : ''}`} onClick={handleExpandClick}>
{isLoading ? (
<Loader2 size={16} className="bitfun-file-explorer__loading-icon" />
) : isExpanded ? (
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
Expand Down
8 changes: 4 additions & 4 deletions src/web-ui/src/tools/file-system/styles/FileExplorer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions src/web-ui/src/tools/file-system/utils/validateFileName.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 5 additions & 1 deletion src/web-ui/src/tools/file-system/utils/validateFileName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const MAX_NAME_LENGTH = 255;
export interface ValidateFileNameContext {
/** 当前是否为远程工作区(远程放宽非法字符集合,且不检查 Windows 保留名)。 */
isRemote: boolean;
/** 被校验对象是否为文件夹,用于选择准确的错误文案。 */
isDirectory?: boolean;
/** 同级已存在的名称(大小写不敏感比较),用于检测重名冲突。需已排除被校验对象自身原名。 */
siblings?: string[];
/** 单路径段名称最大长度,默认 {@link MAX_NAME_LENGTH}。 */
Expand All @@ -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 保留名(仅本地)
Expand Down
Loading