import { useState, useEffect, useCallback, useRef } from 'react'; import { fetchLocalFiles, uploadLocalFiles, deleteLocalFiles, downloadLocalFilesZip, type LocalFileEntry, type WritableTaskSection, } from '../api'; import { filesToBase64 } from '../lib/fileBase64'; type FileSection = 'workspace' | 'input' | 'output' | 'logs'; /** アップロード・削除が許される区分(サーバの WRITABLE_SECTIONS と一致)。 */ function isWritableSection(section: FileSection): section is WritableTaskSection { return section === 'input' || section === 'output'; } export function useFileBrowser(taskId: number | null) { // 初期値は reset effect(task 切替で 'output' に倒す)と一致させる。'workspace' 初期だと // 初回 fetch が古い section で走り、reset 後の fetch とレースになる。 const [section, setSection] = useState('output'); const [currentPath, setCurrentPath] = useState(''); const [entries, setEntries] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); // fetch のリクエスト連番。古い (taskId/section/path) のレスポンスが後勝ちで entries を // 上書きするのを防ぐため、最新の seq の結果だけ反映する。 const reqSeq = useRef(0); // 複数選択(チェック済みファイルの相対パス集合)と書込中フラグ・メッセージ。 const [selected, setSelected] = useState>(() => new Set()); const [isUploading, setIsUploading] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [message, setMessage] = useState<{ text: string; kind: 'ok' | 'error' } | null>(null); // Fetch entries when taskId/section/path changes useEffect(() => { if (!taskId) return; const seq = ++reqSeq.current; fetchLocalFiles(taskId, section, currentPath) .then(r => { if (seq === reqSeq.current) setEntries(r.entries); }) .catch(() => { if (seq === reqSeq.current) setEntries([]); }); }, [taskId, section, currentPath]); // Reset when task changes useEffect(() => { setSection('output'); setCurrentPath(''); }, [taskId]); // フォルダ移動・区分切替・タスク切替で選択をクリア(別ディレクトリのパスを持ち越さない)。 useEffect(() => { setSelected(new Set()); }, [taskId, section, currentPath]); const refresh = useCallback(async () => { if (!taskId) return; const seq = ++reqSeq.current; setIsRefreshing(true); try { const r = await fetchLocalFiles(taskId, section, currentPath); if (seq === reqSeq.current) setEntries(r.entries); } catch { if (seq === reqSeq.current) setEntries([]); } finally { setIsRefreshing(false); } }, [taskId, section, currentPath]); const toggleSelect = useCallback((path: string) => { setSelected(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); const toggleSelectAll = useCallback((paths: string[]) => { setSelected(prev => { const allSelected = paths.length > 0 && paths.every(p => prev.has(p)); return allSelected ? new Set() : new Set(paths); }); }, []); const upload = useCallback(async (fileList: File[]) => { if (!taskId || fileList.length === 0 || !isWritableSection(section)) return; setIsUploading(true); setMessage(null); try { const payload = await filesToBase64(fileList); const r = await uploadLocalFiles(taskId, section, currentPath, payload); await refresh(); setMessage({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' }); } catch (e) { setMessage({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsUploading(false); } }, [taskId, section, currentPath, refresh]); const remove = useCallback(async (paths: string[]) => { if (!taskId || paths.length === 0 || !isWritableSection(section)) return; if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return; setIsDeleting(true); setMessage(null); try { const r = await deleteLocalFiles(taskId, section, paths); setSelected(new Set()); await refresh(); setMessage({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' }); } catch (e) { setMessage({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsDeleting(false); } }, [taskId, section, currentPath, refresh]); // 選択ファイルを zip でダウンロード(read 操作、全 section 可)。 const download = useCallback(async (paths: string[]) => { if (!taskId || paths.length === 0) return; setIsDownloading(true); setMessage(null); try { await downloadLocalFilesZip(taskId, section, paths); } catch (e) { setMessage({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' }); } finally { setIsDownloading(false); } }, [taskId, section]); const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : []; return { section, setSection, currentPath, setCurrentPath, entries, isRefreshing, refresh, pathSegments, // 書込(アップロード/削除) writableSection: isWritableSection(section), selected, toggleSelect, toggleSelectAll, upload, remove, download, isUploading, isDeleting, isDownloading, message, }; }