feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueries, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileTree, type SubdirId, type FileEntry, FILE_SUBDIRS } from './FileTree';
|
||||
import { MonacoFileEditor } from './MonacoFileEditor';
|
||||
import { SaveAsScriptDialog } from './SaveAsScriptDialog';
|
||||
import { ScriptDiffReview } from './ScriptDiffReview';
|
||||
import { BrowserSessionsPanel } from './BrowserSessionsPanel';
|
||||
import { McpPanel } from './McpPanel';
|
||||
import { AgentsMdPanel } from './AgentsMdPanel';
|
||||
import { NewFileForm } from './NewFileForm';
|
||||
import { PetsPanel } from './PetsPanel';
|
||||
import { SshConnectionsPanel } from './SshConnectionsPanel';
|
||||
import { NotesPanel } from './NotesPanel';
|
||||
import { SubscriptionsPanel } from './SubscriptionsPanel';
|
||||
import { SkillsPanel } from './SkillsPanel';
|
||||
/** All subdirs shown in the tree — both real file-based and virtual. */
|
||||
const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'scripts', 'browser-macros', 'templates', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory'];
|
||||
|
||||
const SUBDIR_INFO: { id: SubdirId; icon: string; title: string; desc: string; agency: string }[] = [
|
||||
{
|
||||
id: 'agents-md',
|
||||
icon: '📖',
|
||||
title: 'AGENTS.md',
|
||||
desc: 'タスク起動時に system prompt へ自動注入される、ユーザー専用の永続的な指示。 「常に丁寧な日本語で答える」「Tailwind を優先」等、毎タスクで覚えて欲しい好み・ルールを書く。 最大 64KB。 ファイル形式は markdown。',
|
||||
agency: 'ユーザー編集 / エージェントが自動参照',
|
||||
},
|
||||
{
|
||||
id: 'scripts',
|
||||
icon: '📜',
|
||||
title: 'scripts/',
|
||||
desc: 'AI 生成の汎用 Node スクリプト。エージェントが RunUserScript ツールで実行 (kind: "script")。Chromium 起動なし、main({ params }) シグネチャ。データ整形・API 呼び出し・計算・ファイル変換等の繰り返し処理に向く。',
|
||||
agency: 'エージェント/ユーザー両方 / 軽量・高速',
|
||||
},
|
||||
{
|
||||
id: 'browser-macros',
|
||||
icon: '🤖',
|
||||
title: 'browser-macros/',
|
||||
desc: 'Playwright ベースのブラウザマクロ。recordings/ から "Save as Script" で生成、または UI で手書き。RunUserScript ツール (kind: "browser-macro") で実行。main({ context, params }) シグネチャで context は Playwright BrowserContext。session_profile_id で保存済みログインを利用可能。.next.js は self-healing 失敗時の自動パッチ候補で、Diff レビュー後に accept/reject。',
|
||||
agency: 'エージェント実行 / UI で recordings → スクリプト化',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
icon: '📄',
|
||||
title: 'templates/',
|
||||
desc: '定型文・雛形の置き場。UI で作成・編集する。エージェントが ReadUserTemplate で本文を読むか、RenderUserTemplate で frontmatter.params の {{var}} を埋めた結果を取得できる。報告書の雛形・メール文面・コードボイラープレート等を貯めておくと、繰り返しタスクで「雛形を埋めて」と指示しやすい。',
|
||||
agency: 'ユーザー作成 / エージェントが ReadUserTemplate / RenderUserTemplate で利用',
|
||||
},
|
||||
{
|
||||
id: 'recordings',
|
||||
icon: '🎬',
|
||||
title: 'recordings/',
|
||||
desc: 'BrowseWeb 操作トレース (JSON)。BrowseWeb 呼び出しで recordTo パラメータを指定すると、成功したアクションがバッファされ、タスク終了時にここへ書き出される。"Save as Script" でブラウザマクロに変換できる (browser-macros/ へ保存)。',
|
||||
agency: 'エージェントが記録 / UI でスクリプト化',
|
||||
},
|
||||
{
|
||||
id: 'pets',
|
||||
icon: '◉',
|
||||
title: 'pets/',
|
||||
desc: 'Codex Pets 互換のキャラクターをユーザーごとに import する場所。Chat 画面右下に表示され、タスク状態やツール呼び出しに応じて小さく反応する。',
|
||||
agency: 'ユーザー管理 / Chat UI が参照',
|
||||
},
|
||||
{
|
||||
id: 'browser-sessions',
|
||||
icon: '🌐',
|
||||
title: 'browser-sessions/',
|
||||
desc: 'ブラウザのログインプロファイル管理。CAPTCHA や 2FA の壁を越えて取得した cookie/storage を user-scoped に暗号化保存し、browser-macros から session_profile_id で参照する。',
|
||||
agency: 'ユーザー管理 (noVNC でログイン → save) / browser-macros が利用',
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
icon: '🗑',
|
||||
title: 'trash/',
|
||||
desc: '削除ファイルの退避先。ハードデリートはせず、`{YYYYMMDD-HHMMSS}-{rand4hex}-{name}` 命名で着地する。script の accept/reject で旧版もここへ。閲覧は read-only、復元したい場合は内容コピーで元 subdir に PUT。30 日経過したファイルはサーバ起動時 / 24h 毎に自動削除 (`tools.trash_retention_days` で変更可)。',
|
||||
agency: 'ソフト削除 / read-only / 30 日自動 cleanup',
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
icon: '🧠',
|
||||
title: 'memory/',
|
||||
desc: 'エージェントの永続事実置き場。`MEMORY.md` (index) がタスク起動時に system prompt へ自動注入 (32 KB cap)。`{name}.md` は frontmatter (type ∈ user/feedback/project/reference) + 本文の構造。UpdateUserMemory / ReadUserMemory ツール経由でエージェントが管理。',
|
||||
agency: 'エージェント管理 / UI からは read-only',
|
||||
},
|
||||
{
|
||||
id: 'mcp',
|
||||
icon: '🔌',
|
||||
title: 'mcp/',
|
||||
desc: 'MCP サーバーの登録・接続管理・設定変更をまとめて行えます。OAuth / API key 認証、ツール一覧の取得、接続状態の確認がここで完結します。credentials は AES-256-GCM で暗号化して保存されます。',
|
||||
agency: 'ユーザー管理 / 管理者は global サーバーも追加可能',
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
icon: '📚',
|
||||
title: 'skills/',
|
||||
desc: 'エージェントのスキル (参照知識・手順書) を管理します。URL からインストール、手動作成、編集、削除が可能。スキルはタスク実行時にエージェントへコンテキストとして注入されます。',
|
||||
agency: 'ユーザー管理 / エージェントが ReadSkill で参照',
|
||||
},
|
||||
{
|
||||
id: 'ssh-connections',
|
||||
icon: '🔐',
|
||||
title: 'ssh-connections/',
|
||||
desc: 'エージェントの SshExec / SshUpload / SshDownload ツールが利用する SSH 接続を管理します。秘密鍵は envelope encryption (AES-256-GCM + per-user DEK) で保存され、ホストキーは TOFU でユーザー確認後に固定されます。グローバル接続は管理者が登録し、ピースごとに grant を付与した時のみ参照可能です。',
|
||||
agency: 'ユーザー管理 / グローバル接続は管理者が登録',
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
icon: '📝',
|
||||
title: 'notes/',
|
||||
desc: '他のエージェントや他のユーザーと共有したい情報を Markdown で書く場所です。visibility を設定して公開範囲を制御できます。SearchNotes / ReadNote / WriteNote ツールでエージェントがアクセスできます。',
|
||||
agency: 'ユーザー作成 / エージェントが SearchNotes / ReadNote / WriteNote で利用',
|
||||
},
|
||||
{
|
||||
id: 'subscribed-notes',
|
||||
icon: '🔔',
|
||||
title: 'Subscribed Notes',
|
||||
desc: '他のユーザーが公開している notes フォルダーを購読・発見します。search モードは SearchNotes ツールで横断検索でき、inject モードは LLM コンテキストに自動注入します。',
|
||||
agency: 'ユーザー管理 / エージェントが自動参照 (inject モード)',
|
||||
},
|
||||
];
|
||||
|
||||
interface FolderListResponse {
|
||||
files: FileEntry[];
|
||||
}
|
||||
|
||||
async function apiFolderList(subdir: SubdirId): Promise<FileEntry[]> {
|
||||
const res = await fetch(`/api/users/me/folder/list?subdir=${subdir}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`List failed: ${res.status}`);
|
||||
const data: FolderListResponse = await res.json();
|
||||
return data.files ?? [];
|
||||
}
|
||||
|
||||
interface NoteDiscoverRow {
|
||||
folder: string;
|
||||
file_name: string;
|
||||
updated_at: number;
|
||||
content_size: number;
|
||||
}
|
||||
|
||||
/** Fetch all own notes via the discover API (unlimited depth, returns folder/file pairs). */
|
||||
async function apiNotesList(): Promise<FileEntry[]> {
|
||||
const res = await fetch('/api/notes/discover?owner_id=me&limit=200', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Notes list failed: ${res.status}`);
|
||||
const data: { rows: NoteDiscoverRow[] } = await res.json();
|
||||
return (data.rows ?? []).map((r) => ({
|
||||
// Use "folder/file.md" as the virtual file name so FileTree shows the full path
|
||||
name: `${r.folder}/${r.file_name}`,
|
||||
size: r.content_size,
|
||||
mtime: new Date(r.updated_at).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function apiFolderGet(subdir: SubdirId, path: string): Promise<string> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function apiFolderPut(subdir: SubdirId, path: string, body: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
body,
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
|
||||
}
|
||||
|
||||
async function apiFolderDelete(subdir: SubdirId, path: string): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
|
||||
{ method: 'DELETE', credentials: 'include' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/** Virtual subdirs don't have real files on disk */
|
||||
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
||||
|
||||
/** Subdirs where users can create new files from the UI */
|
||||
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['scripts', 'browser-macros', 'templates']);
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
interface UserFolderTabProps {
|
||||
showToast?: ShowToast;
|
||||
}
|
||||
|
||||
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
|
||||
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('scripts');
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [editorDirty, setEditorDirty] = useState(false);
|
||||
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Fetch the current user (needed for SubscriptionsPanel)
|
||||
const meQuery = useQuery<{ id: string }>({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const currentUserId = meQuery.data?.id ?? '';
|
||||
|
||||
// Only file-based subdirs are fetched; notes uses a separate discover endpoint
|
||||
// because notes live at depth 2 (notes/<folder>/<file>.md) and folder/list only shows depth 1.
|
||||
const fileSubdirs = FILE_SUBDIRS.filter((s) => s !== 'notes');
|
||||
const subdirResults = useQueries({
|
||||
queries: fileSubdirs.map(subdir => ({
|
||||
queryKey: ['userfolder', 'list', subdir],
|
||||
queryFn: () => apiFolderList(subdir),
|
||||
staleTime: 10_000,
|
||||
})),
|
||||
});
|
||||
|
||||
// Separate query for notes that uses the discover API instead of the folder-list API
|
||||
const notesListQuery = useQuery<FileEntry[]>({
|
||||
queryKey: ['userfolder', 'list', 'notes'],
|
||||
queryFn: () => apiNotesList(),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const subdirFilesMap: Partial<Record<SubdirId, { subdir: SubdirId; files: FileEntry[]; loading: boolean }>> = Object.fromEntries(
|
||||
fileSubdirs.map((subdir, i) => [
|
||||
subdir,
|
||||
{
|
||||
subdir,
|
||||
files: subdirResults[i]!.data ?? [],
|
||||
loading: subdirResults[i]!.isLoading,
|
||||
},
|
||||
])
|
||||
);
|
||||
// Inject notes separately using the discover-based listing (depth-2 aware)
|
||||
subdirFilesMap['notes'] = {
|
||||
subdir: 'notes',
|
||||
files: notesListQuery.data ?? [],
|
||||
loading: notesListQuery.isLoading,
|
||||
};
|
||||
|
||||
// Build the tree data: real subdirs get files, virtual ones get empty placeholders
|
||||
const SUBDIRS = ALL_SUBDIRS;
|
||||
const subdirQueries = SUBDIRS.map(subdir => {
|
||||
if (VIRTUAL_SUBDIRS.has(subdir)) {
|
||||
return { subdir, files: [], loading: false };
|
||||
}
|
||||
return subdirFilesMap[subdir] ?? { subdir, files: [], loading: false };
|
||||
});
|
||||
|
||||
// File content query — only when a file is selected (and not virtual subdir)
|
||||
const fileQuery = useQuery<string>({
|
||||
queryKey: ['userfolder', 'file', selectedSubdir, selectedFile],
|
||||
queryFn: () => apiFolderGet(selectedSubdir!, selectedFile!),
|
||||
enabled: !!(selectedSubdir && selectedFile && !VIRTUAL_SUBDIRS.has(selectedSubdir)),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ subdir, file }: { subdir: SubdirId; file: string }) =>
|
||||
apiFolderDelete(subdir, file),
|
||||
onSuccess: (_data, { subdir, file }) => {
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', subdir] });
|
||||
if (selectedSubdir === subdir && selectedFile === file) {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
},
|
||||
onError: (err, { subdir, file }) => {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
const label = `${subdir}/${file} の削除に失敗`;
|
||||
if (showToast) showToast(`${label}: ${msg}`, 'error');
|
||||
else console.error(`${label}: ${msg}`);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedSubdirData = subdirQueries.find(q => q.subdir === selectedSubdir);
|
||||
const selectedFileMeta = selectedSubdirData?.files.find(f => f.name === selectedFile);
|
||||
|
||||
const handleSave = async (content: string) => {
|
||||
if (!selectedSubdir || !selectedFile) return;
|
||||
await apiFolderPut(selectedSubdir, selectedFile, content);
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
|
||||
qc.setQueryData(
|
||||
['userfolder', 'file', selectedSubdir, selectedFile],
|
||||
content,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (subdir: SubdirId, file: string) => {
|
||||
if (!window.confirm(`Delete ${subdir}/${file}?`)) return;
|
||||
deleteMutation.mutate({ subdir, file });
|
||||
};
|
||||
|
||||
function handleSelectSubdir(subdir: SubdirId) {
|
||||
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
|
||||
if (selectedSubdir === subdir) {
|
||||
setSelectedSubdir(null);
|
||||
setSelectedFile(null);
|
||||
} else {
|
||||
setSelectedSubdir(subdir);
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectFile(subdir: SubdirId, file: string) {
|
||||
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
|
||||
setSelectedSubdir(subdir);
|
||||
setSelectedFile(file);
|
||||
}
|
||||
|
||||
// Determine right-pane content
|
||||
const isVirtualSelected = selectedSubdir !== null && VIRTUAL_SUBDIRS.has(selectedSubdir);
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-2 p-2 overflow-hidden">
|
||||
{/* Left: file tree */}
|
||||
<div
|
||||
className="bg-white border border-hairline rounded-md overflow-hidden flex flex-col"
|
||||
style={{ width: 'clamp(200px, 22vw, 280px)', flexShrink: 0 }}
|
||||
>
|
||||
<div className="flex-shrink-0 px-3 py-2.5 border-b border-hairline">
|
||||
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
|
||||
User Folder
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<FileTree
|
||||
subdirData={subdirQueries}
|
||||
selectedSubdir={selectedSubdir}
|
||||
selectedFile={selectedFile}
|
||||
onSelectSubdir={handleSelectSubdir}
|
||||
onSelectFile={handleSelectFile}
|
||||
onDeleteFile={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: editor / virtual panel */}
|
||||
<div className="flex-1 min-w-0 bg-white border border-hairline rounded-md overflow-hidden flex flex-col">
|
||||
{/* agents-md virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'agents-md' && (
|
||||
<AgentsMdPanel onDirtyChange={setEditorDirty} />
|
||||
)}
|
||||
|
||||
{/* browser-sessions virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'browser-sessions' && (
|
||||
<BrowserSessionsPanel />
|
||||
)}
|
||||
|
||||
{/* mcp virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'mcp' && (
|
||||
<McpPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* skills virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'skills' && (
|
||||
<SkillsPanel />
|
||||
)}
|
||||
|
||||
{/* pets virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'pets' && (
|
||||
<PetsPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* ssh-connections virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'ssh-connections' && (
|
||||
<SshConnectionsPanel showToast={showToast} />
|
||||
)}
|
||||
|
||||
{/* subscribed-notes virtual pane */}
|
||||
{isVirtualSelected && selectedSubdir === 'subscribed-notes' && (
|
||||
<SubscriptionsPanel currentUserId={currentUserId} />
|
||||
)}
|
||||
|
||||
{/* notes/ pane — uses discover API for listing (depth 2) + NotesPanel editor */}
|
||||
{selectedSubdir === 'notes' && (
|
||||
<NotesPanel
|
||||
filePath={selectedFile}
|
||||
onSaved={() => {
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
|
||||
}}
|
||||
onSelectFile={(path) => {
|
||||
setSelectedFile(path);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File-based content (non-notes subdirs) */}
|
||||
{!isVirtualSelected && selectedSubdir !== 'notes' && (
|
||||
<>
|
||||
{/* Save as Script toolbar — shown only in recordings/ when a .json file is selected */}
|
||||
{selectedSubdir === 'recordings' && selectedFile?.endsWith('.json') && (
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border-b border-hairline bg-surface-2/50">
|
||||
<span className="text-2xs text-slate-500 flex-1">
|
||||
Recording: <span className="font-mono">{selectedFile}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSaveAsDialogOpen(true)}
|
||||
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
|
||||
>
|
||||
Save as Script
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{selectedSubdir && selectedFile ? (
|
||||
/* If a .next.js patch file is selected in browser-macros/, show the diff review pane */
|
||||
selectedSubdir === 'browser-macros' && selectedFile.endsWith('.next.js') ? (
|
||||
<ScriptDiffReview
|
||||
scriptName={selectedFile.slice(0, -'.next.js'.length)}
|
||||
showToast={showToast}
|
||||
onClose={(acceptedScript) => {
|
||||
if (acceptedScript) {
|
||||
setSelectedFile(acceptedScript);
|
||||
} else {
|
||||
setSelectedFile(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : fileQuery.isLoading ? (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
|
||||
Loading…
|
||||
</div>
|
||||
) : fileQuery.isError ? (
|
||||
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
|
||||
Failed to load file.
|
||||
</div>
|
||||
) : (
|
||||
<MonacoFileEditor
|
||||
subdir={selectedSubdir}
|
||||
filename={selectedFile}
|
||||
content={fileQuery.data ?? ''}
|
||||
mtime={selectedFileMeta?.mtime ?? ''}
|
||||
size={selectedFileMeta?.size ?? 0}
|
||||
onSave={handleSave}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
{selectedSubdir && WRITABLE_USER_SUBDIRS.has(selectedSubdir) ? (
|
||||
/* Focused view for a selected writable subdir: info + new-file form */
|
||||
(() => {
|
||||
const info = SUBDIR_INFO.find(i => i.id === selectedSubdir);
|
||||
if (!info) return null;
|
||||
const files = selectedSubdirData?.files ?? [];
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex gap-3">
|
||||
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>
|
||||
{info.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-semibold text-slate-900">{info.title}</h2>
|
||||
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">{info.desc}</p>
|
||||
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{info.agency}</p>
|
||||
</div>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div className="mb-4 text-xs text-slate-500">
|
||||
現在 {files.length} 件のファイルがあります。左のツリーから選択して編集できます。
|
||||
</div>
|
||||
)}
|
||||
<NewFileForm
|
||||
subdir={selectedSubdir as 'scripts' | 'browser-macros' | 'templates'}
|
||||
existingFilenames={files.map(f => f.name)}
|
||||
onCreate={async (filename, skeleton) => {
|
||||
await apiFolderPut(selectedSubdir, filename, skeleton);
|
||||
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
|
||||
setSelectedFile(filename);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
/* Full overview when no subdir is selected (or non-writable subdir selected without a file) */
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">User Folder</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
セッションをまたいで永続化される、ユーザーごとの作業空間です。
|
||||
左のツリーから subdirectory を開き、ファイルを選択して編集できます。
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-5">
|
||||
{SUBDIR_INFO.map(({ id, icon, title, desc, agency }) => (
|
||||
<li key={id} className="flex gap-3">
|
||||
<span className="text-xl leading-none mt-0.5 select-none" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-semibold text-slate-900">{title}</div>
|
||||
<p className="text-[13px] text-slate-600 mt-1 leading-relaxed">{desc}</p>
|
||||
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{agency}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save as Script dialog — navigates to browser-macros on success */}
|
||||
{saveAsDialogOpen && selectedFile?.endsWith('.json') && (
|
||||
<SaveAsScriptDialog
|
||||
recordingName={selectedFile.endsWith('.json') ? selectedFile.slice(0, -5) : selectedFile}
|
||||
onClose={() => setSaveAsDialogOpen(false)}
|
||||
onSuccess={(scriptName) => {
|
||||
setSaveAsDialogOpen(false);
|
||||
// Navigate to the new macro in browser-macros/
|
||||
setSelectedSubdir('browser-macros');
|
||||
setSelectedFile(scriptName);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user