opencode / shell.html
kacapower's picture
Deploy password-protected OpenCode bridge with 05:30 AM IST daily rolling PIN and bucket persistence
62122e0
Raw History Blame Contribute Delete
15.4 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>opencode</title>
<style>
:root {
--bg: #0b0d10;
--bg-alt: #11151a;
--bg-hover: #1a2028;
--border: #232b35;
--text: #d7dde5;
--muted: #7d8794;
--accent: #63b3ed;
--accent-dim: #2b6cb0;
--danger: #f56565;
--radius: 6px;
--sidebar: 300px;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 13px/1.45 ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
overflow: hidden;
}
#app { display: flex; height: 100%; }
/* ---------- sidebar ---------- */
#sidebar {
width: var(--sidebar);
min-width: 200px;
max-width: 70vw;
display: flex;
flex-direction: column;
background: var(--bg-alt);
border-right: 1px solid var(--border);
}
#sidebar.collapsed { display: none; }
.bar {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
border-bottom: 1px solid var(--border);
}
.bar .title {
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
font-size: 10px;
color: var(--muted);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button, select, input {
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 8px;
outline: none;
}
button { cursor: pointer; }
button:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
button.icon { padding: 4px 7px; line-height: 1; }
select { padding: 4px 6px; }
input:focus, select:focus { border-color: var(--accent-dim); }
.search { padding: 8px; border-bottom: 1px solid var(--border); }
.search input { width: 100%; }
#tree { flex: 1; overflow: auto; padding: 4px 0 24px; }
.node { user-select: none; }
.row {
display: flex;
align-items: center;
gap: 5px;
padding: 3px 8px 3px 0;
white-space: nowrap;
cursor: pointer;
border-left: 2px solid transparent;
}
.row:hover { background: var(--bg-hover); }
.row.active { background: var(--bg-hover); border-left-color: var(--accent); }
.row .caret {
width: 12px;
color: var(--muted);
flex: none;
text-align: center;
transition: transform .12s;
}
.row .caret.open { transform: rotate(90deg); }
.row .ico { flex: none; width: 14px; text-align: center; color: var(--muted); }
.row .ico.dir { color: var(--accent); }
.row .name { overflow: hidden; text-overflow: ellipsis; }
.row .name.dir { color: var(--text); font-weight: 600; }
.row .size { margin-left: auto; color: var(--muted); font-size: 10px; padding-left: 8px; }
.children { display: none; }
.children.open { display: block; }
.empty, .hint { padding: 10px; color: var(--muted); font-size: 11px; }
.status {
border-top: 1px solid var(--border);
padding: 6px 10px;
font-size: 10px;
color: var(--muted);
display: flex;
justify-content: space-between;
}
/* ---------- resizer ---------- */
#resizer {
width: 4px;
cursor: col-resize;
background: transparent;
flex: none;
}
#resizer:hover { background: var(--accent-dim); }
/* ---------- main ---------- */
#main { flex: 1; position: relative; min-width: 0; }
#frame { width: 100%; height: 100%; border: 0; background: var(--bg); display: block; }
#float {
position: absolute; top: 8px; right: 12px; z-index: 5;
display: flex; gap: 6px; opacity: .25; transition: opacity .15s;
}
#float:hover { opacity: 1; }
#float a, #float button {
background: rgba(17,21,26,.9);
border: 1px solid var(--border);
color: var(--muted);
text-decoration: none;
padding: 3px 8px;
border-radius: var(--radius);
font-size: 11px;
}
/* ---------- modal ---------- */
#modal {
position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.6);
display: none;
padding: 4vh 4vw;
}
#modal.open { display: flex; }
#modal .panel {
margin: auto;
width: min(1100px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,.6);
}
#modal .head {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-bottom: 1px solid var(--border);
}
#modal .head .fname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#modal .head .meta { color: var(--muted); font-size: 10px; }
#modal pre {
flex: 1; margin: 0; padding: 12px 14px;
overflow: auto;
font-size: 12px; line-height: 1.5;
tab-size: 2;
white-space: pre;
}
#modal .foot { color: var(--muted); font-size: 10px; padding: 6px 12px; border-top: 1px solid var(--border); }
a.btn {
text-decoration: none; color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 3px 8px; font-size: 11px; background: var(--bg);
}
a.btn:hover { background: var(--bg-hover); border-color: var(--accent-dim); }
@media (max-width: 720px) {
#sidebar { width: 80vw; position: absolute; z-index: 20; height: 100%; }
#resizer { display: none; }
}
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<div class="bar">
<span class="title" id="rootLabel">workspace</span>
<select id="rootSelect" title="Storage root"></select>
<button class="icon" id="refreshBtn" title="Refresh">&#x27F3;</button>
<button class="icon" id="collapseBtn" title="Hide sidebar">&laquo;</button>
</div>
<div class="search"><input id="searchInput" placeholder="filter / search files…" /></div>
<div id="tree"><div class="hint">loading…</div></div>
<div class="status"><span id="statLeft">—</span><span id="statRight"></span></div>
</aside>
<div id="resizer"></div>
<main id="main">
<iframe id="frame" src="/?iframe=1" title="opencode"></iframe>
<div id="float">
<button id="showSidebar" title="Show file explorer" style="display:none">&raquo;</button>
<a id="pop" href="/?iframe=1" target="_blank" title="Open chat in new tab">&nearr;</a>
</div>
</main>
</div>
<div id="modal">
<div class="panel">
<div class="head">
<span class="fname" id="mName"></span>
<span class="meta" id="mMeta"></span>
<a class="btn" id="mRaw" target="_blank">raw</a>
<a class="btn" id="mZip" target="_blank">zip</a>
<button class="icon" id="mClose">&times;</button>
</div>
<pre id="mBody"></pre>
<div class="foot" id="mFoot"></div>
</div>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const treeEl = $('tree');
const state = { root: 'workspace', open: new Set(), active: null, search: '' };
const fmtSize = (n) => {
if (!n) return '';
const u = ['B', 'K', 'M', 'G', 'T'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (i === 0 ? n : n.toFixed(1)) + u[i];
};
const enc = encodeURIComponent;
async function api(url) {
const r = await fetch(url, { headers: { accept: 'application/json' } });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
return r.json();
}
function rowFor(entry, depth) {
const node = document.createElement('div');
node.className = 'node';
const row = document.createElement('div');
row.className = 'row' + (state.active === state.root + ':' + entry.path ? ' active' : '');
row.style.paddingLeft = (depth * 14 + 6) + 'px';
row.dataset.path = entry.path;
row.dataset.type = entry.type;
const caret = document.createElement('span');
caret.className = 'caret';
caret.textContent = entry.type === 'dir' ? '\u25B8' : '';
const ico = document.createElement('span');
ico.className = 'ico' + (entry.type === 'dir' ? ' dir' : '');
ico.textContent = entry.type === 'dir' ? '\u25A0' : '\u2022';
const name = document.createElement('span');
name.className = 'name' + (entry.type === 'dir' ? ' dir' : '');
name.textContent = entry.name;
const size = document.createElement('span');
size.className = 'size';
size.textContent = entry.type === 'dir' ? '' : fmtSize(entry.size);
row.append(caret, ico, name, size);
node.append(row);
let childBox = null;
const key = state.root + ':' + entry.path;
async function toggle() {
if (entry.type !== 'dir') { preview(entry); return; }
if (!childBox) {
childBox = document.createElement('div');
childBox.className = 'children';
childBox.style.paddingLeft = '0px';
node.append(childBox);
try {
const data = await api(`/__api/tree?root=${enc(state.root)}&path=${enc(entry.path)}`);
if (!data.entries.length) {
childBox.innerHTML = '<div class="empty">empty</div>';
} else {
for (const child of data.entries) childBox.append(rowFor(child, depth + 1));
}
} catch (e) {
childBox.innerHTML = `<div class="empty">${e.message}</div>`;
}
}
const isOpen = childBox.classList.toggle('open');
caret.classList.toggle('open', isOpen);
if (isOpen) state.open.add(key); else state.open.delete(key);
}
row.addEventListener('click', () => { setActive(key); toggle(); });
return node;
}
function setActive(key) {
state.active = key;
document.querySelectorAll('.row.active').forEach((el) => el.classList.remove('active'));
const [root, ...rest] = key.split(':');
const path = rest.join(':');
const sel = `.row[data-path="${CSS.escape(path)}"]`;
document.querySelectorAll(sel).forEach((el) => el.classList.add('active'));
}
async function loadRoot() {
treeEl.innerHTML = '<div class="hint">loading…</div>';
state.open.clear();
try {
const data = await api(`/__api/tree?root=${enc(state.root)}&path=`);
treeEl.innerHTML = '';
if (!data.entries.length) treeEl.innerHTML = '<div class="empty">no files yet — ask the agent to create some</div>';
for (const e of data.entries) treeEl.append(rowFor(e, 0));
} catch (e) {
treeEl.innerHTML = `<div class="empty">${e.message}</div>`;
}
loadStats();
}
async function loadStats() {
try {
const s = await api(`/__api/stats?root=${enc(state.root)}`);
$('statLeft').textContent = `${s.files} files`;
$('statRight').textContent = fmtSize(s.bytes);
$('rootLabel').textContent = state.root;
} catch (_) { /* ignore */ }
}
let searchTimer = null;
$('searchInput').addEventListener('input', (ev) => {
const q = ev.target.value.trim();
clearTimeout(searchTimer);
if (!q) { loadRoot(); return; }
searchTimer = setTimeout(async () => {
treeEl.innerHTML = '<div class="hint">searching…</div>';
try {
const data = await api(`/__api/search?root=${enc(state.root)}&q=${enc(q)}`);
treeEl.innerHTML = '';
if (!data.results.length) treeEl.innerHTML = '<div class="empty">no matches</div>';
for (const e of data.results) {
const row = rowFor(e, 0);
treeEl.append(row);
}
if (data.truncated) treeEl.insertAdjacentHTML('beforeend', '<div class="hint">results truncated…</div>');
} catch (e) {
treeEl.innerHTML = `<div class="empty">${e.message}</div>`;
}
}, 250);
});
async function preview(entry) {
$('modal').classList.add('open');
$('mName').textContent = entry.path || entry.name;
$('mMeta').textContent = fmtSize(entry.size);
$('mBody').textContent = 'loading…';
$('mFoot').textContent = '';
const qs = `root=${enc(state.root)}&path=${enc(entry.path)}`;
$('mRaw').href = `/__api/raw?${qs}`;
$('mZip').href = `/__api/zip?${qs}`;
try {
const data = await api(`/__api/file?${qs}`);
if (data.binary) {
$('mBody').textContent = '(binary file — use raw/zip to download)';
} else {
$('mBody').textContent = data.content ?? '';
}
$('mMeta').textContent = fmtSize(data.size);
$('mFoot').textContent = data.truncated ? `truncated at ${fmtSize(512 * 1024)} — use raw for full file` : '';
} catch (e) {
$('mBody').textContent = e.message;
}
}
$('mClose').addEventListener('click', () => $('modal').classList.remove('open'));
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') $('modal').classList.remove('open'); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') $('modal').classList.remove('open');
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'b') {
e.preventDefault();
toggleSidebar();
}
});
$('refreshBtn').addEventListener('click', loadRoot);
$('rootSelect').addEventListener('change', (e) => { state.root = e.target.value; state.active = null; $('searchInput').value = ''; loadRoot(); });
function toggleSidebar(force) {
const sb = $('sidebar');
const hidden = force !== undefined ? !force : !sb.classList.contains('collapsed');
sb.classList.toggle('collapsed', hidden);
$('resizer').style.display = hidden ? 'none' : '';
$('showSidebar').style.display = hidden ? '' : 'none';
}
$('collapseBtn').addEventListener('click', () => toggleSidebar(false));
$('showSidebar').addEventListener('click', () => toggleSidebar(true));
// drag to resize
(() => {
const resizer = $('resizer');
let dragging = false;
resizer.addEventListener('mousedown', () => { dragging = true; document.body.style.cursor = 'col-resize'; });
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const w = Math.max(200, Math.min(e.clientX, window.innerWidth * 0.7));
document.documentElement.style.setProperty('--sidebar', w + 'px');
});
window.addEventListener('mouseup', () => { dragging = false; document.body.style.cursor = ''; });
})();
api('/__api/roots').then((d) => {
const sel = $('rootSelect');
sel.innerHTML = d.roots.map((r) => `<option value="${r.id}">${r.id}</option>`).join('');
}).catch(() => {});
setInterval(() => { if (!state.search) loadStats(); }, 15000);
setInterval(() => {
const openDirs = [];
document.querySelectorAll('.children.open').forEach((el) => openDirs.push(el));
if (openDirs.length) refreshOpen();
}, 10000);
async function refreshOpen() {
const nodes = document.querySelectorAll('.children.open');
for (const box of nodes) {
const row = box.previousElementSibling;
if (!row) continue;
const path = row.dataset.path;
if (path === undefined) continue;
try {
const data = await api(`/__api/tree?root=${enc(state.root)}&path=${enc(path)}`);
const depth = parseInt(row.style.paddingLeft || '0', 10) / 14;
box.innerHTML = '';
if (!data.entries.length) box.innerHTML = '<div class="empty">empty</div>';
for (const child of data.entries) box.append(rowFor(child, Math.round(depth)));
} catch (_) { /* ignore */ }
}
}
loadRoot();
})();
</script>
</body>
</html>