import { useEffect, useRef } from "preact/hooks";
import { signal } from "@preact/signals";
import { apiGet, apiSend } from "../lib/api.js";
import { ensureMeshClient } from "../lib/mesh.js";
const sessions = signal(null);
const error = signal(null);
function currentPeer() {
try {
return window.MobuxMesh ? window.MobuxMesh.getPeer() : "";
} catch (_) {
return "";
}
}
function handlePeerAuthError(e) {
if (e && e.meshKind === "unauthorized") {
window.dispatchEvent(
new CustomEvent("mobux:peer-auth-required", { detail: { peer: e.peer } }),
);
return true;
}
return false;
}
function ensurePeerCred(peer) {
const m = window.MobuxMesh;
if (!peer || !m) return Promise.resolve(true);
if (m.getPeerCred(peer)) return Promise.resolve(true);
return new Promise((resolve) => {
const done = (ok) => {
window.removeEventListener("mobux:peer-cred-stored", onStored);
window.removeEventListener("mobux:peer-cred-cancelled", onCancel);
resolve(ok);
};
const onStored = (ev) => {
if (!ev.detail || ev.detail.peer === peer) done(true);
};
const onCancel = (ev) => {
if (!ev.detail || ev.detail.peer === peer) done(false);
};
window.addEventListener("mobux:peer-cred-stored", onStored);
window.addEventListener("mobux:peer-cred-cancelled", onCancel);
window.dispatchEvent(
new CustomEvent("mobux:peer-auth-required", { detail: { peer } }),
);
});
}
async function refresh() {
try {
try {
await ensureMeshClient();
} catch (_) {
}
const data = await apiGet("/api/sessions");
sessions.value = Array.isArray(data) ? data : data.sessions || [];
error.value = null;
} catch (e) {
if (handlePeerAuthError(e)) return;
error.value = String(e.message || e);
}
}
export function HomePage() {
const dialogRef = useRef(null);
const nameRef = useRef(null);
useEffect(() => {
refresh();
window.addEventListener("mobux:peer-changed", refresh);
window.refreshSessions = refresh;
return () => {
window.removeEventListener("mobux:peer-changed", refresh);
if (window.refreshSessions === refresh) delete window.refreshSessions;
};
}, []);
const open = async (name) => {
const peer = currentPeer();
if (peer && !(await ensurePeerCred(peer))) return;
const href = peer
? `/s/${encodeURIComponent(peer)}/${encodeURIComponent(name)}`
: `/s/${encodeURIComponent(name)}`;
window.location.href = `/app#${href}`;
window.location.reload();
};
const create = async (e) => {
e.preventDefault();
const name = (nameRef.current?.value || "").trim();
if (!name) return;
const peer = currentPeer();
if (peer && !(await ensurePeerCred(peer))) return;
try {
await apiSend("/api/sessions", {
method: "POST",
body: JSON.stringify({ name }),
});
nameRef.current.value = "";
dialogRef.current?.close();
await refresh();
} catch (err) {
if (handlePeerAuthError(err)) return;
alert(`Create failed: ${err.message}`);
}
};
const kill = async (name) => {
if (!confirm(`Kill session '${name}'?`)) return;
try {
await apiSend(`/api/sessions/${encodeURIComponent(name)}/kill`, {
method: "POST",
});
await refresh();
} catch (err) {
if (handlePeerAuthError(err)) return;
alert(`Kill failed: ${err.message}`);
}
};
const rename = async (oldName) => {
const newName = prompt(`Rename '${oldName}' to:`, oldName);
if (!newName || newName === oldName) return;
try {
await apiSend(`/api/sessions/${encodeURIComponent(oldName)}/rename`, {
method: "POST",
body: JSON.stringify({ name: newName }),
});
await refresh();
} catch (err) {
if (handlePeerAuthError(err)) return;
alert(`Rename failed: ${err.message}`);
}
};
const swipeRow = (row) => {
if (!row) return;
const item = row.querySelector(".session-item");
if (!item || item.dataset.swipeWired) return;
item.dataset.swipeWired = "1";
let startX = 0;
let currentX = 0;
let swiping = false;
item.addEventListener(
"touchstart",
(e) => {
startX = e.touches[0].clientX;
currentX = 0;
swiping = true;
item.style.transition = "none";
},
{ passive: true },
);
item.addEventListener(
"touchmove",
(e) => {
if (!swiping) return;
currentX = Math.max(-100, Math.min(100, e.touches[0].clientX - startX));
item.style.transform = `translateX(${currentX}px)`;
},
{ passive: true },
);
item.addEventListener("touchend", () => {
swiping = false;
item.style.transition = "transform 0.2s ease";
if (currentX < -60) item.style.transform = "translateX(-100px)";
else if (currentX > 60) item.style.transform = "translateX(100px)";
else item.style.transform = "translateX(0)";
});
row.addEventListener("click", (e) => {
if (e.target.closest(".swipe-btn")) return;
if (
item.style.transform !== "translateX(0px)" &&
item.style.transform !== ""
) {
item.style.transition = "transform 0.2s ease";
item.style.transform = "translateX(0)";
}
});
};
const list = sessions.value;
return (
<>
<div id="sessionList" class="session-list">
{error.value && (
<p class="hint">Failed to load sessions: {error.value}</p>
)}
{list == null && !error.value && <p class="hint">Loading…</p>}
{list && list.length === 0 && (
<p class="hint">No tmux sessions. Tap + to create one.</p>
)}
{(list || []).map((s) => {
const name = typeof s === "string" ? s : s.name;
const meta =
typeof s === "object"
? `${s.windows ?? "?"} win · ${s.attached ?? 0} attached`
: "";
return (
<div class="swipe-row" data-name={name} key={name} ref={swipeRow}>
<div class="swipe-action swipe-left">
<button
class="swipe-btn rename-btn"
onClick={() => rename(name)}
>
Rename
</button>
</div>
<a
class="session-item"
href="#"
onClick={(e) => {
e.preventDefault();
open(name);
}}
>
<div class="session-info">
<span class="session-name">{name}</span>
{meta && <span class="session-meta">{meta}</span>}
</div>
<span class="session-arrow">›</span>
</a>
<div class="swipe-action swipe-right">
<button class="swipe-btn kill-btn" onClick={() => kill(name)}>
Kill
</button>
</div>
</div>
);
})}
</div>
<button
id="fabNew"
class="fab"
aria-label="New session"
onClick={() => {
dialogRef.current?.showModal();
nameRef.current?.focus();
}}
>
+
</button>
<dialog ref={dialogRef} id="newSessionDialog" class="session-dialog">
<form id="newSessionForm" method="dialog" onSubmit={create}>
<h3>New session</h3>
<input
ref={nameRef}
id="sessionName"
placeholder="session-name"
autocomplete="off"
required
/>
<div class="dialog-actions">
<button
type="button"
class="btn-cancel"
onClick={() => dialogRef.current?.close()}
>
Cancel
</button>
<button type="submit" class="btn-create">
Create
</button>
</div>
</form>
</dialog>
</>
);
}