Files
maestro/ui/src/hooks/useFileBrowser.ts
T
oss-sync 29ccaf1e92
CI / build-and-test (push) Has been cancelled
sync: update from private repo (dfadcd5f)
2026-06-23 06:38:48 +00:00

147 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 effecttask 切替で '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,
};
}