147 lines
5.7 KiB
TypeScript
147 lines
5.7 KiB
TypeScript
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<FileSection>('output');
|
||
const [currentPath, setCurrentPath] = useState('');
|
||
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
|
||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||
// fetch のリクエスト連番。古い (taskId/section/path) のレスポンスが後勝ちで entries を
|
||
// 上書きするのを防ぐため、最新の seq の結果だけ反映する。
|
||
const reqSeq = useRef(0);
|
||
// 複数選択(チェック済みファイルの相対パス集合)と書込中フラグ・メッセージ。
|
||
const [selected, setSelected] = useState<Set<string>>(() => 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,
|
||
};
|
||
}
|