feat: add session name input to tool starter
ToolStarter (used by FAB and workspace detail): - Add Session Name text input, defaulting to workspace.name - Pass user-provided name to createInstance display_name parameter - If left empty or only whitespace, falls back to auto-generated name Quality gates: tsc --noEmit pass, npm run build pass, 82/82 tests pass
This commit is contained in:
@@ -41,11 +41,12 @@ const SessionItem = ({ session }: { session: Session }) => {
|
|||||||
// - Everything else falls back to the project page
|
// - Everything else falls back to the project page
|
||||||
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
const hasTerminal = session.tool_type_interfaces.includes("terminal");
|
||||||
const hasWeb = session.tool_type_interfaces.includes("web");
|
const hasWeb = session.tool_type_interfaces.includes("web");
|
||||||
const href = session.url && hasWeb
|
const href =
|
||||||
? session.url
|
session.url && hasWeb
|
||||||
: hasTerminal
|
? session.url
|
||||||
? `/instances/${session.id}/terminal`
|
: hasTerminal
|
||||||
: `/projects/${session.project_id}`;
|
? `/instances/${session.id}/terminal`
|
||||||
|
: `/projects/${session.project_id}`;
|
||||||
|
|
||||||
const tooltipParts = [session.display_name, session.project_name];
|
const tooltipParts = [session.display_name, session.project_name];
|
||||||
if (session.workspace_name) tooltipParts.push(session.workspace_name);
|
if (session.workspace_name) tooltipParts.push(session.workspace_name);
|
||||||
|
|||||||
@@ -171,8 +171,7 @@ export function SessionCard({
|
|||||||
{" "}
|
{" "}
|
||||||
/ <span>{session.repository_name}</span>
|
/ <span>{session.repository_name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}{" "}
|
||||||
{" "}
|
|
||||||
· {session.tool_type_name}
|
· {session.tool_type_name}
|
||||||
</p>
|
</p>
|
||||||
{session.clone_mode && (
|
{session.clone_mode && (
|
||||||
|
|||||||
@@ -3,128 +3,137 @@ import { SessionCard } from "./session-card";
|
|||||||
import type { InstanceHealth } from "../../../api/sessions";
|
import type { InstanceHealth } from "../../../api/sessions";
|
||||||
|
|
||||||
export interface SessionListProps {
|
export interface SessionListProps {
|
||||||
sessions: Session[];
|
sessions: Session[];
|
||||||
onOpen?: (session: Session) => void;
|
onOpen?: (session: Session) => void;
|
||||||
onStart?: (session: Session) => void;
|
onStart?: (session: Session) => void;
|
||||||
onStop?: (session: Session) => void;
|
onStop?: (session: Session) => void;
|
||||||
onDelete?: (session: Session) => void;
|
onDelete?: (session: Session) => void;
|
||||||
onRecreateTunnel?: (session: Session) => void;
|
onRecreateTunnel?: (session: Session) => void;
|
||||||
onRename?: (session: Session, newName: string) => void;
|
onRename?: (session: Session, newName: string) => void;
|
||||||
actionBusyId?: string | null;
|
actionBusyId?: string | null;
|
||||||
tunnelHealth?: Record<string, InstanceHealth>;
|
tunnelHealth?: Record<string, InstanceHealth>;
|
||||||
showGrouping?: boolean;
|
showGrouping?: boolean;
|
||||||
activeTitle?: string;
|
activeTitle?: string;
|
||||||
recentTitle?: string;
|
recentTitle?: string;
|
||||||
maxRecent?: number;
|
maxRecent?: number;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
const activeStatuses = [
|
||||||
|
"running",
|
||||||
|
"building",
|
||||||
|
"starting",
|
||||||
|
"probing",
|
||||||
|
"pending",
|
||||||
|
"unhealthy",
|
||||||
|
];
|
||||||
const recentStatuses = ["stopped", "error"];
|
const recentStatuses = ["stopped", "error"];
|
||||||
|
|
||||||
export function SessionList({
|
export function SessionList({
|
||||||
sessions,
|
sessions,
|
||||||
onOpen,
|
onOpen,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRecreateTunnel,
|
onRecreateTunnel,
|
||||||
onRename,
|
onRename,
|
||||||
actionBusyId = null,
|
actionBusyId = null,
|
||||||
tunnelHealth = {},
|
tunnelHealth = {},
|
||||||
showGrouping = true,
|
showGrouping = true,
|
||||||
activeTitle = "Active Sessions",
|
activeTitle = "Active Sessions",
|
||||||
recentTitle = "Recent Sessions",
|
recentTitle = "Recent Sessions",
|
||||||
maxRecent = 5,
|
maxRecent = 5,
|
||||||
emptyMessage = "No sessions",
|
emptyMessage = "No sessions",
|
||||||
}: SessionListProps) {
|
}: SessionListProps) {
|
||||||
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
const activeSessions = sessions.filter((s) =>
|
||||||
const recentSessions = sessions
|
activeStatuses.includes(s.status),
|
||||||
.filter((s) => recentStatuses.includes(s.status))
|
);
|
||||||
.slice(0, maxRecent);
|
const recentSessions = sessions
|
||||||
|
.filter((s) => recentStatuses.includes(s.status))
|
||||||
|
.slice(0, maxRecent);
|
||||||
|
|
||||||
if (!showGrouping) {
|
if (!showGrouping) {
|
||||||
return (
|
return (
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{sessions.length === 0 ? (
|
{sessions.length === 0 ? (
|
||||||
<p className="muted">{emptyMessage}</p>
|
<p className="muted">{emptyMessage}</p>
|
||||||
) : (
|
) : (
|
||||||
sessions.map((session) => (
|
sessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
isBusy={actionBusyId === session.id}
|
isBusy={actionBusyId === session.id}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="session-list">
|
<div className="session-list">
|
||||||
{/* Active Sessions */}
|
{/* Active Sessions */}
|
||||||
<div className="session-group">
|
<div className="session-group">
|
||||||
<div className="session-group-header">
|
<div className="session-group-header">
|
||||||
<h3>{activeTitle}</h3>
|
<h3>{activeTitle}</h3>
|
||||||
{activeSessions.length > 0 && (
|
{activeSessions.length > 0 && (
|
||||||
<span className="badge">{activeSessions.length}</span>
|
<span className="badge">{activeSessions.length}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{activeSessions.length === 0 ? (
|
{activeSessions.length === 0 ? (
|
||||||
<p className="muted">No active sessions</p>
|
<p className="muted">No active sessions</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{activeSessions.map((session) => (
|
{activeSessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
isBusy={actionBusyId === session.id}
|
isBusy={actionBusyId === session.id}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Sessions */}
|
{/* Recent Sessions */}
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
<div className="session-group">
|
<div className="session-group">
|
||||||
<div className="session-group-header">
|
<div className="session-group-header">
|
||||||
<h3>{recentTitle}</h3>
|
<h3>{recentTitle}</h3>
|
||||||
<span className="badge">{recentSessions.length}</span>
|
<span className="badge">{recentSessions.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="sessions-grid">
|
<div className="sessions-grid">
|
||||||
{recentSessions.map((session) => (
|
{recentSessions.map((session) => (
|
||||||
<SessionCard
|
<SessionCard
|
||||||
key={session.id}
|
key={session.id}
|
||||||
session={session}
|
session={session}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onStart={onStart}
|
onStart={onStart}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onRecreateTunnel={onRecreateTunnel}
|
onRecreateTunnel={onRecreateTunnel}
|
||||||
onRename={onRename}
|
onRename={onRename}
|
||||||
isBusy={actionBusyId === session.id}
|
isBusy={actionBusyId === session.id}
|
||||||
tunnelHealth={tunnelHealth[session.id] || null}
|
tunnelHealth={tunnelHealth[session.id] || null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function ToolStarter({
|
|||||||
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
||||||
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState(workspace.name);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ export function ToolStarter({
|
|||||||
workspace.project_id,
|
workspace.project_id,
|
||||||
workspace.repo_id,
|
workspace.repo_id,
|
||||||
selectedToolTypeId,
|
selectedToolTypeId,
|
||||||
workspace.name,
|
displayName.trim() || undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -140,7 +141,7 @@ export function ToolStarter({
|
|||||||
} finally {
|
} finally {
|
||||||
setStarting(false);
|
setStarting(false);
|
||||||
}
|
}
|
||||||
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
|
}, [selectedToolTypeId, selectedProfileId, displayName, workspace, onStarted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tool-starter">
|
<div className="tool-starter">
|
||||||
@@ -187,6 +188,19 @@ export function ToolStarter({
|
|||||||
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Session Name */}
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="session-name">Session Name</label>
|
||||||
|
<input
|
||||||
|
id="session-name"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
placeholder="My dev environment"
|
||||||
|
disabled={starting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Config Profile */}
|
{/* Config Profile */}
|
||||||
{selectedToolTypeId && (
|
{selectedToolTypeId && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -10,294 +10,301 @@ import type { TerminalSession } from "../api/terminal";
|
|||||||
import type { ModifierKey } from "./use-special-keys";
|
import type { ModifierKey } from "./use-special-keys";
|
||||||
|
|
||||||
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
|
||||||
sessions.map((s) => ({
|
sessions.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
name: s.name,
|
name: s.name,
|
||||||
status: s.status as TerminalSessionInfo["status"],
|
status: s.status as TerminalSessionInfo["status"],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type TerminalStatus =
|
type TerminalStatus =
|
||||||
| "connecting"
|
| "connecting"
|
||||||
| "connected"
|
| "connected"
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
| "error"
|
| "error"
|
||||||
| "resetting";
|
| "resetting";
|
||||||
|
|
||||||
export const useTerminalPage = () => {
|
export const useTerminalPage = () => {
|
||||||
const { instanceId } = useParams<{ instanceId: string }>();
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useMobileViewport();
|
const isMobile = useMobileViewport();
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
|
||||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||||
|
|
||||||
const [terminalStatuses, setTerminalStatuses] = useState<
|
const [terminalStatuses, setTerminalStatuses] = useState<
|
||||||
Record<string, TerminalStatus>
|
Record<string, TerminalStatus>
|
||||||
>({});
|
>({});
|
||||||
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
const changeFontSizeRef = useRef<((delta: number) => void) | null>(null);
|
||||||
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
const sendDataRef = useRef<((data: string) => void) | null>(null);
|
||||||
const focusInputRef = useRef<(() => void) | null>(null);
|
const focusInputRef = useRef<(() => void) | null>(null);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
const [showSpecialKeysPanel, setShowSpecialKeysPanel] = useState(false);
|
||||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(
|
||||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard();
|
null,
|
||||||
|
);
|
||||||
|
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||||
|
useVirtualKeyboard();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
createSession,
|
createSession,
|
||||||
closeSession,
|
closeSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
resetSession,
|
resetSession,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
} = useTerminalSessions(instanceId ?? "");
|
} = useTerminalSessions(instanceId ?? "");
|
||||||
|
|
||||||
// Auto-create default session
|
// Auto-create default session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && sessions.length === 0 && !error && instanceId) {
|
if (!loading && sessions.length === 0 && !error && instanceId) {
|
||||||
void createSession("Session 1");
|
void createSession("Session 1");
|
||||||
}
|
}
|
||||||
}, [loading, sessions.length, error, instanceId, createSession]);
|
}, [loading, sessions.length, error, instanceId, createSession]);
|
||||||
|
|
||||||
// Update document title based on active terminal session
|
// Update document title based on active terminal session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
document.title = "Terminal — Headquarter";
|
document.title = "Terminal — Headquarter";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const active = sessions.find((s) => s.id === activeSessionId);
|
const active = sessions.find((s) => s.id === activeSessionId);
|
||||||
const name = active?.name ?? `Instance ${instanceId.slice(0, 8)}`;
|
const name = active?.name ?? `Instance ${instanceId.slice(0, 8)}`;
|
||||||
document.title = `${name} — Terminal — Headquarter`;
|
document.title = `${name} — Terminal — Headquarter`;
|
||||||
return () => {
|
return () => {
|
||||||
document.title = "Headquarter";
|
document.title = "Headquarter";
|
||||||
};
|
};
|
||||||
}, [instanceId, activeSessionId, sessions]);
|
}, [instanceId, activeSessionId, sessions]);
|
||||||
|
|
||||||
// Sync refs with sessions
|
// Sync refs with sessions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
if (!terminalRefs.current[session.id]) {
|
if (!terminalRefs.current[session.id]) {
|
||||||
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const currentIds = new Set(sessions.map((s) => s.id));
|
const currentIds = new Set(sessions.map((s) => s.id));
|
||||||
for (const id of Object.keys(terminalRefs.current)) {
|
for (const id of Object.keys(terminalRefs.current)) {
|
||||||
if (!currentIds.has(id)) {
|
if (!currentIds.has(id)) {
|
||||||
delete terminalRefs.current[id];
|
delete terminalRefs.current[id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
|
|
||||||
// Fit and focus active terminal
|
// Fit and focus active terminal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
const ref = terminalRefs.current[activeSessionId];
|
const ref = terminalRefs.current[activeSessionId];
|
||||||
let raf1 = 0;
|
let raf1 = 0;
|
||||||
let raf2 = 0;
|
let raf2 = 0;
|
||||||
raf1 = requestAnimationFrame(() => {
|
raf1 = requestAnimationFrame(() => {
|
||||||
raf2 = requestAnimationFrame(() => {
|
raf2 = requestAnimationFrame(() => {
|
||||||
ref.current?.fit();
|
ref.current?.fit();
|
||||||
ref.current?.focus();
|
ref.current?.focus();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf1);
|
cancelAnimationFrame(raf1);
|
||||||
cancelAnimationFrame(raf2);
|
cancelAnimationFrame(raf2);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [activeSessionId]);
|
}, [activeSessionId]);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||||
if (!isAltShift) return;
|
if (!isAltShift) return;
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case "n":
|
case "n":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (sessions.length < 5) {
|
if (sessions.length < 5) {
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
void createSession(`Session ${sessions.length + 1}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "w":
|
case "w":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSessionId && window.confirm("Close this terminal session?")) {
|
if (
|
||||||
void closeSession(activeSessionId);
|
activeSessionId &&
|
||||||
}
|
window.confirm("Close this terminal session?")
|
||||||
break;
|
) {
|
||||||
case "arrowleft":
|
void closeSession(activeSessionId);
|
||||||
e.preventDefault();
|
}
|
||||||
if (activeSessionId) {
|
break;
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
case "arrowleft":
|
||||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
e.preventDefault();
|
||||||
}
|
if (activeSessionId) {
|
||||||
break;
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||||
case "arrowright":
|
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||||
e.preventDefault();
|
}
|
||||||
if (activeSessionId) {
|
break;
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
case "arrowright":
|
||||||
if (idx < sessions.length - 1) setActiveSessionId(sessions[idx + 1].id);
|
e.preventDefault();
|
||||||
}
|
if (activeSessionId) {
|
||||||
break;
|
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||||
case "r":
|
if (idx < sessions.length - 1)
|
||||||
e.preventDefault();
|
setActiveSessionId(sessions[idx + 1].id);
|
||||||
if (activeSessionId) void resetSession(activeSessionId);
|
}
|
||||||
break;
|
break;
|
||||||
case "f":
|
case "r":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsFullscreen((prev) => !prev);
|
if (activeSessionId) void resetSession(activeSessionId);
|
||||||
break;
|
break;
|
||||||
}
|
case "f":
|
||||||
};
|
e.preventDefault();
|
||||||
|
setIsFullscreen((prev) => !prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [
|
}, [
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
createSession,
|
createSession,
|
||||||
closeSession,
|
closeSession,
|
||||||
resetSession,
|
resetSession,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keep screen awake
|
// Keep screen awake
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let wakeLock: WakeLockSentinel | null = null;
|
let wakeLock: WakeLockSentinel | null = null;
|
||||||
const requestWakeLock = async () => {
|
const requestWakeLock = async () => {
|
||||||
try {
|
try {
|
||||||
if ("wakeLock" in navigator) {
|
if ("wakeLock" in navigator) {
|
||||||
wakeLock = await navigator.wakeLock.request("screen");
|
wakeLock = await navigator.wakeLock.request("screen");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void requestWakeLock();
|
void requestWakeLock();
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === "visible") void requestWakeLock();
|
if (document.visibilityState === "visible") void requestWakeLock();
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
wakeLock?.release().catch(() => {});
|
wakeLock?.release().catch(() => {});
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Lock page scroll on mobile
|
// Lock page scroll on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
document.documentElement.classList.add("terminal-page-open");
|
document.documentElement.classList.add("terminal-page-open");
|
||||||
document.body.classList.add("terminal-page-open");
|
document.body.classList.add("terminal-page-open");
|
||||||
return () => {
|
return () => {
|
||||||
document.documentElement.classList.remove("terminal-page-open");
|
document.documentElement.classList.remove("terminal-page-open");
|
||||||
document.body.classList.remove("terminal-page-open");
|
document.body.classList.remove("terminal-page-open");
|
||||||
};
|
};
|
||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handleFullscreenClick = useCallback(
|
const handleFullscreenClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLElement>) => {
|
(e: React.MouseEvent<HTMLElement>) => {
|
||||||
if (!isFullscreen) return;
|
if (!isFullscreen) return;
|
||||||
const target = e.target as Node;
|
const target = e.target as Node;
|
||||||
const current = e.currentTarget as HTMLElement;
|
const current = e.currentTarget as HTMLElement;
|
||||||
const content = current.querySelector(".terminal-page-content");
|
const content = current.querySelector(".terminal-page-content");
|
||||||
const header = current.querySelector(".terminal-fullscreen-header");
|
const header = current.querySelector(".terminal-fullscreen-header");
|
||||||
if (content?.contains(target) || header?.contains(target)) return;
|
if (content?.contains(target) || header?.contains(target)) return;
|
||||||
setIsFullscreen(false);
|
setIsFullscreen(false);
|
||||||
},
|
},
|
||||||
[isFullscreen],
|
[isFullscreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(sessionId: string) => setActiveSessionId(sessionId),
|
(sessionId: string) => setActiveSessionId(sessionId),
|
||||||
[setActiveSessionId],
|
[setActiveSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = useCallback(
|
||||||
async (sessionId: string) => closeSession(sessionId),
|
async (sessionId: string) => closeSession(sessionId),
|
||||||
[closeSession],
|
[closeSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreate = useCallback(() => {
|
const handleCreate = useCallback(() => {
|
||||||
void createSession(`Session ${sessions.length + 1}`);
|
void createSession(`Session ${sessions.length + 1}`);
|
||||||
}, [createSession, sessions.length]);
|
}, [createSession, sessions.length]);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
(sessionId: string, newName: string) => {
|
(sessionId: string, newName: string) => {
|
||||||
void renameSession(sessionId, newName);
|
void renameSession(sessionId, newName);
|
||||||
},
|
},
|
||||||
[renameSession],
|
[renameSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleTerminalReady = useCallback(
|
const handleTerminalReady = useCallback(
|
||||||
(
|
(
|
||||||
sendData: (data: string) => void,
|
sendData: (data: string) => void,
|
||||||
status: TerminalStatus,
|
status: TerminalStatus,
|
||||||
focusInput: () => void,
|
focusInput: () => void,
|
||||||
changeFontSize: (delta: number) => void,
|
changeFontSize: (delta: number) => void,
|
||||||
) => {
|
) => {
|
||||||
setTerminalStatuses((prev) => ({
|
setTerminalStatuses((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeSessionId ?? "default"]: status,
|
[activeSessionId ?? "default"]: status,
|
||||||
}));
|
}));
|
||||||
sendDataRef.current = sendData;
|
sendDataRef.current = sendData;
|
||||||
focusInputRef.current = focusInput;
|
focusInputRef.current = focusInput;
|
||||||
changeFontSizeRef.current = changeFontSize;
|
changeFontSizeRef.current = changeFontSize;
|
||||||
},
|
},
|
||||||
[activeSessionId],
|
[activeSessionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFontSizeChange = useCallback((delta: number) => {
|
const handleFontSizeChange = useCallback((delta: number) => {
|
||||||
changeFontSizeRef.current?.(delta);
|
changeFontSizeRef.current?.(delta);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSendKey = useCallback((data: string) => {
|
const handleSendKey = useCallback((data: string) => {
|
||||||
sendDataRef.current?.(data);
|
sendDataRef.current?.(data);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
if (activeSessionId && terminalRefs.current[activeSessionId]) {
|
||||||
terminalRefs.current[activeSessionId].current?.reset();
|
terminalRefs.current[activeSessionId].current?.reset();
|
||||||
}
|
}
|
||||||
}, [activeSessionId]);
|
}, [activeSessionId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instanceId,
|
instanceId,
|
||||||
navigate,
|
navigate,
|
||||||
isMobile,
|
isMobile,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
setIsFullscreen,
|
setIsFullscreen,
|
||||||
terminalRefs,
|
terminalRefs,
|
||||||
headerAutoHide,
|
headerAutoHide,
|
||||||
terminalStatuses,
|
terminalStatuses,
|
||||||
sendDataRef,
|
sendDataRef,
|
||||||
focusInputRef,
|
focusInputRef,
|
||||||
changeFontSizeRef,
|
changeFontSizeRef,
|
||||||
showResetConfirm,
|
showResetConfirm,
|
||||||
setShowResetConfirm,
|
setShowResetConfirm,
|
||||||
showSpecialKeysPanel,
|
showSpecialKeysPanel,
|
||||||
setShowSpecialKeysPanel,
|
setShowSpecialKeysPanel,
|
||||||
activeModifier,
|
activeModifier,
|
||||||
setActiveModifier,
|
setActiveModifier,
|
||||||
isKeyboardOpen,
|
isKeyboardOpen,
|
||||||
keyboardHeight,
|
keyboardHeight,
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
handleFullscreenClick,
|
handleFullscreenClick,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
handleClose,
|
handleClose,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
handleRename,
|
handleRename,
|
||||||
handleTerminalReady,
|
handleTerminalReady,
|
||||||
handleFontSizeChange,
|
handleFontSizeChange,
|
||||||
handleSendKey,
|
handleSendKey,
|
||||||
handleReset,
|
handleReset,
|
||||||
sessionInfos: SESSIONS_TO_INFO(sessions),
|
sessionInfos: SESSIONS_TO_INFO(sessions),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -89,4 +89,3 @@
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user