agent-ecosystem/src/renderer/components/team/editor/NewFileDialog.tsx
iliya f4f02d5536 feat: enhance task management with new file renaming feature and notification settings
- Added a new file renaming functionality in the editor, allowing users to rename files and directories in place.
- Introduced notification settings for team inbox messages and task clarifications, enabling users to receive native OS notifications for important updates.
- Updated the README to reflect the new features and provide a clearer overview of the task management capabilities.
- Improved the application icon handling for notifications across different platforms.
2026-03-01 17:52:54 +02:00

131 lines
4.4 KiB
TypeScript

/**
* Inline input for creating a new file or directory in the file tree.
*
* Auto-focuses, validates on the client side, submits on Enter, cancels on Escape.
* Uses click-outside detection instead of onBlur for dismissal — onBlur is
* unreliable when the input lives inside a virtualizer + DnD context + Radix
* context menu (all of which can steal focus transiently).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { FilePlus, FolderPlus } from 'lucide-react';
// =============================================================================
// Types
// =============================================================================
interface NewFileDialogProps {
type: 'file' | 'directory';
parentDir: string;
onSubmit: (name: string) => void;
onCancel: () => void;
}
// =============================================================================
// Validation
// =============================================================================
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
const INVALID_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
function validateName(name: string): string | null {
const trimmed = name.trim();
if (trimmed.length === 0) return 'Name cannot be empty';
if (trimmed === '.' || trimmed === '..') return 'Invalid name';
if (INVALID_CHARS.test(trimmed)) return 'Name contains invalid characters';
if (trimmed.length > 255) return 'Name is too long';
return null;
}
// =============================================================================
// Component
// =============================================================================
export const NewFileDialog = ({
type,
parentDir: _parentDir,
onSubmit,
onCancel,
}: NewFileDialogProps): React.ReactElement => {
const [value, setValue] = useState('');
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Focus input after Radix context menu finishes its focus restoration
useEffect(() => {
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}, []);
// Click-outside → cancel (replaces unreliable onBlur)
useEffect(() => {
const handlePointerDown = (e: PointerEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onCancel();
}
};
// Delay listener registration so the context menu close click isn't caught
const timer = setTimeout(() => {
document.addEventListener('pointerdown', handlePointerDown, true);
}, 150);
return () => {
clearTimeout(timer);
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [onCancel]);
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
const validationError = validateName(trimmed);
if (validationError) {
setError(validationError);
return;
}
onSubmit(trimmed);
}, [value, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
e.stopPropagation();
},
[handleSubmit, onCancel]
);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
setError(null);
}, []);
const Icon = type === 'file' ? FilePlus : FolderPlus;
return (
<div ref={containerRef} className="flex flex-col px-2 py-1">
<div className="flex items-center gap-1.5">
<Icon className="size-3.5 shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
aria-label={type === 'file' ? 'New file name' : 'New folder name'}
/>
</div>
{error && <span className="mt-0.5 pl-5 text-[10px] text-red-400">{error}</span>}
</div>
);
};