diff --git a/packages/ui/shared/src/fs/index.ts b/packages/ui/shared/src/fs/index.ts index 4b5803a3..eb2f4fd6 100644 --- a/packages/ui/shared/src/fs/index.ts +++ b/packages/ui/shared/src/fs/index.ts @@ -7,13 +7,18 @@ export interface VfsPathRef { } export type VfsCopyConflictMode = 'auto' | 'manual'; -export type VfsCopyConflictStrategy = 'error' | 'overwrite' | 'skip' | 'rename'; +export type VfsCopyConflictStrategy = 'error' | 'overwrite' | 'skip' | 'rename' | 'if_newer'; +export type VfsTransferOperation = 'copy' | 'move' | 'delete'; export interface VfsCopyRequest { + operation?: VfsTransferOperation; source: VfsPathRef; - destination: VfsPathRef; + destination?: VfsPathRef; conflictMode?: VfsCopyConflictMode; conflictStrategy?: VfsCopyConflictStrategy; + includePatterns?: string[]; + excludePatterns?: string[]; + excludeDefault?: boolean; } export interface VfsCopyConflict { diff --git a/packages/ui/src/contexts/StreamContext.tsx b/packages/ui/src/contexts/StreamContext.tsx index 5d0471cd..dd58de0d 100644 --- a/packages/ui/src/contexts/StreamContext.tsx +++ b/packages/ui/src/contexts/StreamContext.tsx @@ -21,6 +21,10 @@ export const useStream = () => { return context; }; +export const useOptionalStream = () => { + return useContext(StreamContext); +}; + interface StreamProviderProps { children: ReactNode; url?: string; diff --git a/packages/ui/src/modules/storage/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx index d43429f0..52139369 100644 --- a/packages/ui/src/modules/storage/FileBrowserPanel.tsx +++ b/packages/ui/src/modules/storage/FileBrowserPanel.tsx @@ -24,6 +24,8 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { IMAGE_EXTS, VIDEO_EXTS, CODE_EXTS } from '@/modules/storage/helpers'; import { T } from '@/i18n'; +import { useOptionalStream } from '@/contexts/StreamContext'; +import type { AppEvent } from '@/types-server'; import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; import { useSelection } from '@/modules/storage/hooks/useSelection'; @@ -238,6 +240,56 @@ const FileBrowserPanel: React.FC = ({ } }); + const stream = useOptionalStream(); + const refreshTimerRef = useRef(null); + const scheduleScopedRefresh = useCallback(() => { + if (refreshTimerRef.current) { + window.clearTimeout(refreshTimerRef.current); + } + refreshTimerRef.current = window.setTimeout(() => { + fetchDir(currentPath || '/'); + refreshTimerRef.current = null; + }, 250); + }, [currentPath, fetchDir]); + + useEffect(() => { + if (!stream) return; + const normalize = (p?: string) => (p || '').replace(/^\/+|\/+$/g, ''); + const unsubscribe = stream.subscribe((event: AppEvent) => { + if (event.kind !== 'system') return; + if (event.type !== 'vfs-copy' && event.type !== 'vfs-index') return; + + if (event.type === 'vfs-index') { + const evMount = String(event.data?.mount || ''); + const evTarget = normalize(String(event.data?.targetPath || '')); + const here = normalize(currentPath); + if (evMount === mount && (evTarget === '' || here === evTarget || here.startsWith(`${evTarget}/`) || evTarget.startsWith(`${here}/`))) { + scheduleScopedRefresh(); + } + return; + } + + // vfs-copy + const srcMount = String(event.data?.sourceMount || ''); + const dstMount = String(event.data?.destinationMount || ''); + const srcPath = normalize(String(event.data?.sourcePath || '')); + const dstPath = normalize(String(event.data?.destinationPath || '')); + const here = normalize(currentPath); + const mountMatches = mount === srcMount || mount === dstMount; + const pathMatches = here === '' || here === srcPath || here === dstPath || srcPath.startsWith(`${here}/`) || dstPath.startsWith(`${here}/`); + if (mountMatches && pathMatches) { + scheduleScopedRefresh(); + } + }); + return () => { + if (refreshTimerRef.current) { + window.clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + unsubscribe(); + }; + }, [stream, mount, currentPath, scheduleScopedRefresh]); + // ── View Mode & Zoom ───────────────────────────────────────── const displayNodes = useMemo(() => [...sorted, ...(uploads as INode[])], [sorted, uploads]);