Theseus: bundle Pithos 0.2.0 (sidebar panel)
Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. Pithos moves from a toolbar menu that opened a loopback tab to a left-sidebar panel. Existing installs get the same version over the extension update channel.
This commit is contained in:
parent
1e461e9b83
commit
8186407b61
5 changed files with 198 additions and 48 deletions
|
|
@ -1,27 +1,13 @@
|
|||
{
|
||||
"id": "pithos",
|
||||
"name": "Pithos",
|
||||
"version": "0.1.0",
|
||||
"description": "Run s3d, the Sia S3 gateway, from Theseus: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||
"version": "0.2.0",
|
||||
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||
"main": "index.js",
|
||||
"capabilities": [
|
||||
"toolbar-menu"
|
||||
"sidebar-panel"
|
||||
],
|
||||
"toolbar-menu": {
|
||||
"title": "Pithos",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||
"items": [
|
||||
{
|
||||
"id": "open",
|
||||
"label": "Open Pithos"
|
||||
},
|
||||
{
|
||||
"id": "stop",
|
||||
"label": "Stop s3d"
|
||||
}
|
||||
]
|
||||
},
|
||||
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ export async function createPithos(opts = {}) {
|
|||
const login = new LoginSession(daemon, { registration: () => cli.registration() });
|
||||
const secret = crypto.randomBytes(24).toString('base64url');
|
||||
const sessions = new Set();
|
||||
// One-time download links: id -> { path, exp }. Lets a host hand a file
|
||||
// to its own download manager, which carries no session cookie.
|
||||
const tickets = new Map();
|
||||
const subscribers = new Set();
|
||||
let versionCache = null;
|
||||
|
||||
|
|
@ -302,7 +305,16 @@ export async function createPithos(opts = {}) {
|
|||
// DNS-rebinding guard: a page on evil.example resolving to 127.0.0.1 still
|
||||
// sends its own Host header.
|
||||
if (!hostAllowed(req.headers.host)) { res.writeHead(421); return res.end('misdirected request'); }
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let url = new URL(req.url, `http://${req.headers.host}`);
|
||||
let viaTicket = false;
|
||||
const dl = /^\/dl\/([A-Za-z0-9_-]{20,})$/.exec(url.pathname);
|
||||
if (dl && req.method === 'GET') {
|
||||
const t = tickets.get(dl[1]);
|
||||
tickets.delete(dl[1]);
|
||||
if (!t || t.exp < Date.now()) { res.writeHead(410); return res.end('link expired'); }
|
||||
url = new URL(t.path, url);
|
||||
viaTicket = true;
|
||||
}
|
||||
res.setHeader('x-frame-options', 'DENY');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
|
||||
|
|
@ -336,7 +348,7 @@ export async function createPithos(opts = {}) {
|
|||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (!cookieSession(req)) throw new HttpError(401, 'not signed in');
|
||||
if (!viaTicket && !cookieSession(req)) throw new HttpError(401, 'not signed in');
|
||||
if (req.method !== 'GET') requireCsrf(req);
|
||||
|
||||
if (url.pathname === '/api/events') {
|
||||
|
|
@ -387,6 +399,21 @@ export async function createPithos(opts = {}) {
|
|||
url: baseUrl,
|
||||
// Opening this URL signs the view in.
|
||||
authUrl: `${baseUrl}?t=${secret}`,
|
||||
// For hosts that call the API themselves (the Theseus sidebar bridge):
|
||||
// a session cookie that never touches a browser.
|
||||
internalSession() {
|
||||
const id = crypto.randomBytes(24).toString('base64url');
|
||||
sessions.add(id);
|
||||
return `pithos=${id}`;
|
||||
},
|
||||
// A single-use, 60-second URL for one object download.
|
||||
downloadUrl(apiPath) {
|
||||
if (!/^\/api\/s3\/[^/]+\/buckets\/[^/]+\/object\?/.test(apiPath)) throw new Error('only object downloads');
|
||||
const id = crypto.randomBytes(24).toString('base64url');
|
||||
tickets.set(id, { path: apiPath, exp: Date.now() + 60_000 });
|
||||
for (const [k, v] of tickets) if (v.exp < Date.now()) tickets.delete(k);
|
||||
return `${baseUrl}dl/${id}`;
|
||||
},
|
||||
async close() {
|
||||
login.cancel();
|
||||
for (const s of subscribers) s.end();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// Pithos for Theseus. The add-on runs the same control server as the web
|
||||
// console and desktop app, in Theseus's main process, and opens its UI in a
|
||||
// normal tab at a random loopback port. The first URL carries a one-time
|
||||
// secret that the server swaps for an HttpOnly cookie.
|
||||
// console and desktop app, in Theseus's main process, and shows the same UI
|
||||
// as a left-sidebar panel. The panel is the add-on's own ui/index.html, so
|
||||
// no loopback address ever appears in the address bar: the UI's API calls,
|
||||
// uploads and live events travel over the add-on IPC bridge (invoke / emit),
|
||||
// and this file forwards them to the server with an internal session.
|
||||
//
|
||||
// Loaded with require() by the add-on host; core/ is ESM, hence import().
|
||||
|
||||
|
|
@ -10,6 +12,8 @@ const { pathToFileURL } = require("node:url");
|
|||
|
||||
let pithos = null;
|
||||
let starting = null;
|
||||
let cookie = null;
|
||||
let events = null; // AbortController for the /api/events relay
|
||||
|
||||
module.exports = {
|
||||
activate(api) {
|
||||
|
|
@ -22,36 +26,103 @@ module.exports = {
|
|||
if (!starting) {
|
||||
starting = (async () => {
|
||||
const { createPithos } = await import(pathToFileURL(path.join(__dirname, "core", "server.js")).href);
|
||||
pithos = await createPithos({
|
||||
const p = await createPithos({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
hostName: "theseus",
|
||||
binDir: path.join(dataDir, "bin"),
|
||||
openExternal: (url) => api.openTab(url),
|
||||
});
|
||||
api.log(`control server on ${pithos.url}`);
|
||||
return pithos;
|
||||
cookie = p.internalSession();
|
||||
pithos = p;
|
||||
api.log(`control server on ${p.url}`);
|
||||
return p;
|
||||
})().finally(() => { starting = null; });
|
||||
}
|
||||
return starting;
|
||||
}
|
||||
|
||||
api.onMessage("menu-select", async (payload) => {
|
||||
const id = payload && payload.id;
|
||||
if (id === "open") {
|
||||
const p = await ensureServer();
|
||||
api.openTab(p.authUrl);
|
||||
} else if (id === "stop") {
|
||||
if (pithos) await pithos.daemon.stop();
|
||||
async function call(method, apiPath, { body, headers = {} } = {}) {
|
||||
const p = await ensureServer();
|
||||
const res = await fetch(new URL(apiPath, p.url), {
|
||||
method,
|
||||
headers: { cookie, "x-pithos": "1", ...headers },
|
||||
body,
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
// Relay the server's event stream to the panel as api.emit events.
|
||||
async function startEvents() {
|
||||
if (events) return;
|
||||
const p = await ensureServer();
|
||||
const ctl = new AbortController();
|
||||
events = ctl;
|
||||
try {
|
||||
const res = await fetch(new URL("/api/events", p.url), { headers: { cookie }, signal: ctl.signal });
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||||
const block = buf.slice(0, i);
|
||||
buf = buf.slice(i + 2);
|
||||
const ev = /^event: (.+)$/m.exec(block);
|
||||
const data = /^data: (.+)$/m.exec(block);
|
||||
if (ev && data) api.emit("pithos-event", { event: ev[1], data: JSON.parse(data[1]) });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!ctl.signal.aborted) api.log("event relay stopped:", e.message);
|
||||
} finally {
|
||||
if (events === ctl) events = null;
|
||||
}
|
||||
}
|
||||
|
||||
api.onMessage("api", ({ method, path: apiPath, body }) =>
|
||||
call(method || "GET", apiPath, body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }));
|
||||
|
||||
// Uploads arrive as one ArrayBuffer over IPC; forward it as the PUT body.
|
||||
api.onMessage("upload", ({ path: apiPath, bytes, type }) =>
|
||||
call("PUT", apiPath, { body: Buffer.from(bytes), headers: { "content-type": type || "application/octet-stream" } }));
|
||||
|
||||
// Hand the file to Theseus's own download manager through a one-time link.
|
||||
api.onMessage("download", async ({ path: apiPath }) => {
|
||||
const p = await ensureServer();
|
||||
const { session } = require("electron");
|
||||
session.defaultSession.downloadURL(p.downloadUrl(apiPath));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
api.onMessage("events-start", () => { startEvents(); return { ok: true }; });
|
||||
api.onMessage("open-external", ({ url }) => {
|
||||
if (!/^https:\/\//.test(url)) throw new Error("only https links");
|
||||
api.openTab(url);
|
||||
return { ok: true };
|
||||
});
|
||||
// A full browser-tab view, for anyone who prefers it to the sidebar.
|
||||
api.onMessage("open-in-tab", async () => {
|
||||
const p = await ensureServer();
|
||||
api.openTab(p.authUrl);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" });
|
||||
|
||||
// The host has no quit hook for add-ons; without this an s3d we started
|
||||
// would outlive Theseus.
|
||||
process.on("exit", () => { try { pithos && pithos.daemon.child && pithos.daemon.child.kill(); } catch {} });
|
||||
},
|
||||
|
||||
async deactivate() {
|
||||
if (pithos) { const p = pithos; pithos = null; await p.close(); }
|
||||
if (events) { events.abort(); events = null; }
|
||||
if (pithos) { const p = pithos; pithos = null; cookie = null; await p.close(); }
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -74,12 +74,12 @@ nav a.active { background: var(--accent-soft); color: var(--accent); font-weight
|
|||
.dot.starting, .dot.stopping { background: var(--warn); }
|
||||
.dot.crashed { background: var(--danger); }
|
||||
|
||||
main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; }
|
||||
main { padding: 28px 32px 48px; max-width: 1100px; width: 100%; min-width: 0; }
|
||||
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.page-head p { margin: 0; }
|
||||
|
||||
.grid { display: grid; gap: 16px; }
|
||||
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
.grid.two { grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); }
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
|
|
@ -95,7 +95,7 @@ dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; ma
|
|||
dl.kv dt { color: var(--muted); }
|
||||
dl.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 12px; }
|
||||
.stat { background: var(--surface-2); border-radius: 8px; padding: 12px 14px; }
|
||||
.stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||
.stat .l { color: var(--muted); font-size: 12.5px; }
|
||||
|
|
@ -126,7 +126,7 @@ label.field { display: flex; flex-direction: column; gap: 4px; }
|
|||
label.field > span { font-weight: 550; font-size: 13px; }
|
||||
label.field > small { color: var(--muted); }
|
||||
label.check { display: flex; align-items: center; gap: 8px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }
|
||||
|
||||
/* tables */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
|
@ -209,3 +209,12 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
|
|||
.upload { grid-template-columns: 1fr 80px 50px; }
|
||||
.hide-sm { display: none; }
|
||||
}
|
||||
|
||||
/* Theseus sidebar: widen / open-in-tab controls */
|
||||
.host-controls { display: flex; gap: 6px; flex-wrap: wrap; margin-top: auto; }
|
||||
.host-controls + .daemon-pill { margin-top: 0; }
|
||||
@media (max-width: 760px) { .host-controls { margin-top: 0; } }
|
||||
|
||||
/* Narrow hosts (Theseus sidebar): never scroll sideways. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.card { min-width: 0; }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,18 @@ function put(el, ...children) {
|
|||
el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false));
|
||||
}
|
||||
|
||||
// Inside the Theseus sidebar the page is the add-on's own file, with no
|
||||
// server origin to fetch from: every call goes over the add-on IPC bridge
|
||||
// and the add-on forwards it to the control server.
|
||||
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
|
||||
|
||||
async function api(method, path, body, { raw } = {}) {
|
||||
if (bridge) {
|
||||
const r = await bridge.invoke('api', { method, path, body });
|
||||
const data = r.data || {};
|
||||
if (r.status >= 400) throw new Error(data.error || `HTTP ${r.status}`);
|
||||
return data;
|
||||
}
|
||||
const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['content-type'] = 'application/json';
|
||||
|
|
@ -127,24 +138,32 @@ async function refreshStatus() {
|
|||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
function connectEvents() {
|
||||
const es = new EventSource('/api/events');
|
||||
es.addEventListener('status', (e) => {
|
||||
const eventHandlers = {
|
||||
status(d) {
|
||||
if (!state.status) return;
|
||||
state.status.daemon = JSON.parse(e.data);
|
||||
state.status.daemon = d;
|
||||
renderPill();
|
||||
emit();
|
||||
});
|
||||
es.addEventListener('log', (e) => {
|
||||
const l = JSON.parse(e.data);
|
||||
},
|
||||
log(l) {
|
||||
if (l.seq <= state.logSeq) return;
|
||||
state.logSeq = l.seq;
|
||||
state.logs.push(l);
|
||||
if (state.logs.length > 2000) state.logs.shift();
|
||||
logSink?.(l);
|
||||
});
|
||||
es.addEventListener('login', (e) => { state.login = JSON.parse(e.data); emit(); });
|
||||
es.addEventListener('install', (e) => { const p = JSON.parse(e.data); if (p.phase === 'extract') toast('Verified checksum, installing…'); });
|
||||
},
|
||||
login(d) { state.login = d; emit(); },
|
||||
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
|
||||
};
|
||||
|
||||
function connectEvents() {
|
||||
if (bridge) {
|
||||
bridge.on('pithos-event', ({ event, data }) => eventHandlers[event]?.(data));
|
||||
bridge.invoke('events-start').catch(fail);
|
||||
return;
|
||||
}
|
||||
const es = new EventSource('/api/events');
|
||||
for (const [name, fn] of Object.entries(eventHandlers)) es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
|
||||
es.onerror = () => { /* EventSource reconnects on its own */ };
|
||||
}
|
||||
|
||||
|
|
@ -410,6 +429,7 @@ function setup(main) {
|
|||
}
|
||||
|
||||
async function openLink(url) {
|
||||
if (bridge) { try { await bridge.invoke('open-external', { url }); } catch (e) { fail(e); } return; }
|
||||
const host = state.status?.host;
|
||||
if (host === 'desktop' || host === 'theseus') {
|
||||
try { await api('POST', '/api/open-external', { url }); return; } catch { /* fall through */ }
|
||||
|
|
@ -704,7 +724,12 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }),
|
||||
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
|
||||
objects.map((o) => h('tr', {},
|
||||
h('td', {}, h('a', { href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '' }, o.key.slice(prefix.length))),
|
||||
h('td', {}, h('a', {
|
||||
href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '',
|
||||
// The sidebar page has no server origin; Theseus's download
|
||||
// manager fetches the file through a one-time link instead.
|
||||
onclick: bridge ? (e) => { e.preventDefault(); bridge.invoke('download', { path: `${base}/object?key=${encodeURIComponent(o.key)}` }).then(() => toast('Download started')).catch(fail); } : null,
|
||||
}, o.key.slice(prefix.length))),
|
||||
h('td', { class: 'num' }, fmtBytes(o.size)),
|
||||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||||
h('td', { class: 'actions' },
|
||||
|
|
@ -722,6 +747,22 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
|||
const pct = h('span', { class: 'muted' }, '0%');
|
||||
const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct);
|
||||
uploads.append(row);
|
||||
if (bridge) {
|
||||
// The whole file crosses the IPC bridge as one buffer, so keep it to
|
||||
// sizes a renderer can hold comfortably; S3 clients have no limit.
|
||||
if (file.size > 1024 ** 3) { row.remove(); toast(`${file.name}: uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files`, 'error'); continue; }
|
||||
pct.textContent = 'uploading…';
|
||||
bar.style.width = '100%';
|
||||
bar.style.opacity = '.4';
|
||||
file.arrayBuffer()
|
||||
.then((bytes) => bridge.invoke('upload', { path: `${base}/object?key=${encodeURIComponent(key)}`, bytes, type: file.type }))
|
||||
.then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) { pct.textContent = 'done'; bar.style.opacity = '1'; setTimeout(() => row.remove(), 1500); load(); }
|
||||
else { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${r.data?.error || `HTTP ${r.status}`}`, 'error'); }
|
||||
})
|
||||
.catch((e) => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${e.message}`, 'error'); });
|
||||
continue;
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`);
|
||||
xhr.setRequestHeader('x-pithos', '1');
|
||||
|
|
@ -873,6 +914,21 @@ function logs(main) {
|
|||
|
||||
// ---------------------------------------------------------------- boot
|
||||
|
||||
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer
|
||||
// the host's widen/restore, plus a regular tab for anyone who prefers one.
|
||||
function addSidebarControls() {
|
||||
const row = h('div', { class: 'host-controls' });
|
||||
if (bridge.sidebar?.toggleMax) {
|
||||
const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen');
|
||||
const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; };
|
||||
bridge.sidebar.isMax?.().then(label).catch(() => {});
|
||||
bridge.sidebar.onMaxChange?.(label);
|
||||
row.append(widen);
|
||||
}
|
||||
row.append(h('button', { class: 'btn small', title: 'Open Pithos in a browser tab', onclick: () => bridge.invoke('open-in-tab').catch(fail) }, 'Open in tab'));
|
||||
$('.sidebar').insertBefore(row, $('#daemon-pill'));
|
||||
}
|
||||
|
||||
function showSignin() {
|
||||
$('#app').hidden = true;
|
||||
$('#signin').hidden = false;
|
||||
|
|
@ -891,6 +947,7 @@ async function boot() {
|
|||
}
|
||||
$('#signin').hidden = true;
|
||||
$('#app').hidden = false;
|
||||
if (bridge) addSidebarControls();
|
||||
// Backfill logs the server already holds, then stream.
|
||||
try {
|
||||
state.logs = await api('GET', '/api/logs');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue