Files
maestro/docs/examples/workspace-apps/file-note/index.html
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

173 lines
6.6 KiB
HTML

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ファイル・ノート</title>
<style>
/* 自己完結: 外部 CSS/フォント禁止(CSP で遮断される)。インラインのみ。 */
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 16px;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5; color: #1e293b; background: #f8fafc;
}
h1 { font-size: 16px; margin: 0 0 12px; }
.row { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.panel { flex: 1 1 280px; min-width: 240px; }
.card { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
ul { list-style: none; margin: 0; padding: 0; max-height: 220px; overflow: auto; }
li { margin: 0; }
.file-btn {
width: 100%; text-align: left; border: 0; background: transparent;
padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; color: #334155;
}
.file-btn:hover { background: #f1f5f9; }
.file-btn[aria-current="true"] { background: #e0e7ff; font-weight: 600; }
pre {
margin: 8px 0 0; padding: 8px; background: #f1f5f9; border-radius: 6px;
font-size: 12px; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
textarea {
width: 100%; min-height: 120px; padding: 8px; border: 1px solid #cbd5e1;
border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical;
}
button.primary {
margin-top: 8px; border: 0; background: #4f46e5; color: #fff; font-weight: 700;
padding: 8px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
button.primary:disabled { opacity: .5; cursor: default; }
.muted { color: #64748b; font-size: 12px; }
.status { margin-top: 8px; font-size: 12px; min-height: 1.2em; }
.status.ok { color: #047857; }
.status.err { color: #dc2626; }
</style>
</head>
<body>
<h1>ファイル・ノート</h1>
<p class="muted">このワークスペースの <code>output/</code> を一覧・閲覧し、メモを <code>output/note.md</code> に保存します。</p>
<div class="row">
<div class="panel">
<div class="card">
<strong style="font-size:13px;">output/ のファイル</strong>
<ul id="files"><li class="muted" style="padding:6px 8px;">読み込み中…</li></ul>
</div>
</div>
<div class="panel">
<div class="card">
<strong style="font-size:13px;">プレビュー</strong>
<pre id="preview" class="muted">左の一覧からファイルを選んでください。</pre>
</div>
<div class="card" style="margin-top:12px;">
<strong style="font-size:13px;">メモ(output/note.md に保存)</strong>
<textarea id="note" placeholder="ここにメモを書いて「保存」を押すと output/note.md に保存されます。"></textarea>
<button id="save" class="primary">保存</button>
<div id="status" class="status"></div>
</div>
</div>
</div>
<script>
// ── postMessage ブリッジ(自己完結 / 外部ネットワーク無し)─────────────────
// 親 (AppRunner) が、ユーザーのセッションでファイル I/O を代理する。
// 詳細仕様: docs/workspace-apps-bridge.md
let seq = 0;
const pending = new Map();
function call(req) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
window.parent.postMessage({ ...req, id }, '*');
});
}
window.addEventListener('message', (e) => {
const r = e.data;
const p = r && pending.get(r.id);
if (!p) return;
pending.delete(r.id);
r.ok ? p.resolve(r.data) : p.reject(new Error(r.error || 'request failed'));
});
const $files = document.getElementById('files');
const $preview = document.getElementById('preview');
const $note = document.getElementById('note');
const $save = document.getElementById('save');
const $status = document.getElementById('status');
function setStatus(msg, kind) {
$status.textContent = msg;
$status.className = 'status' + (kind ? ' ' + kind : '');
}
async function loadFiles() {
try {
const { entries } = await call({ type: 'listFiles', dir: 'output' });
const files = (entries || []).filter((e) => e.kind !== 'directory');
$files.innerHTML = '';
if (files.length === 0) {
$files.innerHTML = '<li class="muted" style="padding:6px 8px;">output/ は空です。</li>';
return;
}
for (const f of files) {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'file-btn';
btn.textContent = f.name;
btn.onclick = () => selectFile(f, btn);
li.appendChild(btn);
$files.appendChild(li);
}
} catch (err) {
// output/ が無い等。空表示にして致命化しない。
$files.innerHTML = '<li class="muted" style="padding:6px 8px;">output/ がまだありません。</li>';
}
}
async function selectFile(file, btn) {
for (const b of $files.querySelectorAll('.file-btn')) b.removeAttribute('aria-current');
btn.setAttribute('aria-current', 'true');
$preview.className = 'muted';
$preview.textContent = '読み込み中…';
try {
const { content } = await call({ type: 'readFile', path: 'output/' + file.name });
$preview.className = '';
$preview.textContent = content.length > 4000 ? content.slice(0, 4000) + '\n…(省略)' : content;
} catch (err) {
$preview.className = 'muted';
$preview.textContent = '読み込めませんでした(テキストファイルのみ対応)。';
}
}
// 既存の note.md があれば編集欄に読み込む(無ければ空のまま)。
async function loadExistingNote() {
try {
const { content } = await call({ type: 'readFile', path: 'output/note.md' });
$note.value = content;
} catch {
/* 無ければ何もしない */
}
}
$save.onclick = async () => {
$save.disabled = true;
setStatus('保存中…');
try {
// output/ への書き込みは確認不要(親のサイレント許可ポリシー)。
await call({ type: 'writeFile', path: 'output/note.md', content: $note.value });
setStatus('output/note.md に保存しました。', 'ok');
await loadFiles();
} catch (err) {
setStatus('保存に失敗しました: ' + err.message, 'err');
} finally {
$save.disabled = false;
}
};
loadFiles();
loadExistingNote();
</script>
</body>
</html>