import { useEffect, useRef } from "preact/hooks";
import { signal } from "@preact/signals";
import { apiGet, apiSend } from "../lib/api.js";
import { getSelectedNode, setSelectedNode, withNode } from "../lib/nodes.js";
const sessions = signal(null);
const error = signal(null);
const nodes = signal(null); const nodeError = signal(null);
const selectedNode = signal(getSelectedNode());
async function refresh() {
try {
const data = await apiGet(withNode("/api/sessions", selectedNode.value));
sessions.value = Array.isArray(data) ? data : data.sessions || [];
error.value = null;
} catch (e) {
error.value = String(e.message || e);
}
}
async function loadNodes() {
let res;
try {
res = await fetch("/api/nodes", {
headers: { Accept: "application/json" },
redirect: "manual",
});
} catch (e) {
nodeError.value = String(e.message || e);
return;
}
if (res.status === 404 || res.type === "opaqueredirect") {
nodes.value = [];
return;
}
if (!res.ok) {
res.text().catch(() => {});
nodeError.value = `GET /api/nodes -> ${res.status}`;
return;
}
let data;
try {
data = await res.json();
} catch (e) {
nodeError.value = `GET /api/nodes: ${String(e.message || e)}`;
return;
}
nodes.value = data.nodes || [];
nodeError.value = null;
if (
selectedNode.value &&
!nodes.value.some((n) => n.name === selectedNode.value)
) {
selectedNode.value = "";
setSelectedNode("");
}
}
export function HomePage() {
const dialogRef = useRef(null);
const nameRef = useRef(null);
useEffect(() => {
loadNodes().then(refresh);
}, []);
const pickNode = (name) => {
if (name === selectedNode.value) return;
selectedNode.value = name;
setSelectedNode(name);
sessions.value = null;
error.value = null;
refresh();
};
const open = async (name) => {
window.location.href = `/app#/s/${encodeURIComponent(name)}`;
window.location.reload();
};
const create = async (e) => {
e.preventDefault();
const name = (nameRef.current?.value || "").trim();
if (!name) return;
try {
await apiSend(withNode("/api/sessions", selectedNode.value), {
method: "POST",
body: JSON.stringify({ name }),
});
nameRef.current.value = "";
dialogRef.current?.close();
await refresh();
} catch (err) {
alert(`Create failed: ${err.message}`);
}
};
const kill = async (name) => {
if (!confirm(`Kill session '${name}'?`)) return;
try {
await apiSend(
withNode(
`/api/sessions/${encodeURIComponent(name)}/kill`,
selectedNode.value,
),
{ method: "POST" },
);
await refresh();
} catch (err) {
alert(`Kill failed: ${err.message}`);
}
};
const rename = async (oldName) => {
const newName = prompt(`Rename '${oldName}' to:`, oldName);
if (!newName || newName === oldName) return;
try {
await apiSend(
withNode(
`/api/sessions/${encodeURIComponent(oldName)}/rename`,
selectedNode.value,
),
{
method: "POST",
body: JSON.stringify({ name: newName }),
},
);
await refresh();
} catch (err) {
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;
const nodeList = nodes.value;
return (
<>
{nodeError.value && (
<p class="hint">Failed to load nodes: {nodeError.value}</p>
)}
{nodeList && nodeList.length > 0 && (
<div id="nodePicker" class="node-picker" aria-label="Node">
<button
type="button"
class={`node-chip${selectedNode.value === "" ? " active" : ""}`}
data-node=""
onClick={() => pickNode("")}
>
local
</button>
{nodeList.map((n) => (
<button
type="button"
key={n.name}
class={`node-chip${selectedNode.value === n.name ? " active" : ""}${n.reachable === false ? " unreachable" : ""}`}
data-node={n.name}
title={n.target}
onClick={() => pickNode(n.name)}
>
{n.name}
{n.reachable === false && (
<span class="node-dead">unreachable</span>
)}
</button>
))}
</div>
)}
<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>
</>
);
}