[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
412
components/systemManager/TmuxSessionCard.tsx
Normal file
412
components/systemManager/TmuxSessionCard.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import {
|
||||
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
|
||||
import type {
|
||||
TmuxManageAction,
|
||||
TmuxSessionInfo,
|
||||
} from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { AsyncRecordState } from '../../application/state/systemManager/useAsyncRecordCache';
|
||||
import type { TmuxSessionDetails } from './TmuxManagerTab';
|
||||
import {
|
||||
SystemPanelCollapsible,
|
||||
SystemPanelDetailStrip,
|
||||
SystemPanelInlineError,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSectionHeader,
|
||||
SystemPanelStatusBadge,
|
||||
} from './SystemPanelUi';
|
||||
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
import { runTmuxSessionAction } from './tmuxActionFocus';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
const TMUX_POPUP_ICON = {
|
||||
kind: 'image',
|
||||
src: '/system-icons/tmux.svg',
|
||||
alt: 'tmux',
|
||||
} as const;
|
||||
|
||||
type RenamePromptTarget =
|
||||
| { kind: 'session' }
|
||||
| { kind: 'window'; windowIndex: number; currentName: string };
|
||||
|
||||
interface PendingTarget {
|
||||
action: TmuxManageAction['action'];
|
||||
windowIndex?: number;
|
||||
}
|
||||
|
||||
interface ConfirmTmuxDetachOptions {
|
||||
sessionName: string;
|
||||
confirmMessage: string;
|
||||
confirm: (message: string) => boolean;
|
||||
runAction: (action: TmuxManageAction) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runConfirmedTmuxDetachAction({
|
||||
sessionName,
|
||||
confirmMessage,
|
||||
confirm,
|
||||
runAction,
|
||||
}: ConfirmTmuxDetachOptions): Promise<boolean> {
|
||||
if (!confirm(confirmMessage)) return false;
|
||||
await runAction({ action: 'detachSession', sessionName });
|
||||
return true;
|
||||
}
|
||||
|
||||
interface TmuxSessionCardProps {
|
||||
session: TmuxSessionInfo;
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
backend: Backend;
|
||||
detailsRecord?: AsyncRecordState<TmuxSessionDetails>;
|
||||
onLoadDetails: (session: TmuxSessionInfo, options?: { force?: boolean; urgent?: boolean }) => Promise<void>;
|
||||
onRefreshDetails: (session: TmuxSessionInfo) => Promise<void>;
|
||||
onSessionsChanged: () => Promise<void>;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
}
|
||||
|
||||
export const TmuxSessionCard = memo(function TmuxSessionCard({
|
||||
session,
|
||||
sessionId,
|
||||
parentSession,
|
||||
backend,
|
||||
detailsRecord,
|
||||
onLoadDetails,
|
||||
onRefreshDetails,
|
||||
onSessionsChanged,
|
||||
onRequestTerminalFocus,
|
||||
}: TmuxSessionCardProps) {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
|
||||
const [detachConfirmOpen, setDetachConfirmOpen] = useState(false);
|
||||
const [killSessionConfirmOpen, setKillSessionConfirmOpen] = useState(false);
|
||||
const [killWindowConfirm, setKillWindowConfirm] = useState<{
|
||||
windowIndex: number;
|
||||
windowName: string;
|
||||
} | null>(null);
|
||||
const [newWindowOpen, setNewWindowOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [pending, setPending] = useState<PendingTarget | null>(null);
|
||||
|
||||
const windows = detailsRecord?.data?.windows ?? [];
|
||||
const clients = detailsRecord?.data?.clients ?? [];
|
||||
const loadingDetails = detailsRecord?.loading ?? false;
|
||||
const windowsLoadDetail = detailsRecord?.error ?? null;
|
||||
const summaryKey = useMemo(
|
||||
() => `${session.name}|${session.created}|${session.windows}|${session.attached}|${session.activity ?? ''}`,
|
||||
[session.activity, session.attached, session.created, session.name, session.windows],
|
||||
);
|
||||
const lastExpandedSummaryKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
lastExpandedSummaryKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (lastExpandedSummaryKeyRef.current === null) {
|
||||
lastExpandedSummaryKeyRef.current = summaryKey;
|
||||
return;
|
||||
}
|
||||
if (lastExpandedSummaryKeyRef.current === summaryKey) return;
|
||||
lastExpandedSummaryKeyRef.current = summaryKey;
|
||||
void onRefreshDetails(session);
|
||||
}, [expanded, onRefreshDetails, session, summaryKey]);
|
||||
|
||||
const runAction = async (action: TmuxManageAction) => {
|
||||
setBusy(true);
|
||||
setPending({
|
||||
action: action.action,
|
||||
windowIndex: 'windowIndex' in action ? action.windowIndex : undefined,
|
||||
});
|
||||
setActionError(null);
|
||||
try {
|
||||
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
|
||||
const result = await runTmuxSessionAction({
|
||||
sessionId,
|
||||
action,
|
||||
tmuxAction: backend.tmuxAction,
|
||||
onRefreshDetails: !cardWillRemount && expanded ? () => onRefreshDetails(session) : undefined,
|
||||
onSessionsChanged,
|
||||
onRequestTerminalFocus,
|
||||
});
|
||||
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = (action: TmuxManageAction['action'], windowIndex?: number) =>
|
||||
pending !== null
|
||||
&& pending.action === action
|
||||
&& pending.windowIndex === windowIndex;
|
||||
|
||||
const handleAttach = async (windowIndex?: number) => {
|
||||
const result = await openInteractiveTerminal(
|
||||
backend,
|
||||
parentSession,
|
||||
windowIndex !== undefined ? `tmux: ${session.name}:${windowIndex}` : `tmux: ${session.name}`,
|
||||
buildTmuxAttachCommand(session.name, windowIndex),
|
||||
{ icon: TMUX_POPUP_ICON },
|
||||
);
|
||||
if (!result.success) {
|
||||
const message = result.error || t('systemManager.errors.actionFailed');
|
||||
setActionError(message);
|
||||
showSystemManagerError(message, t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemPanelRow
|
||||
selected={expanded}
|
||||
onClick={() => {
|
||||
const nextExpanded = !expanded;
|
||||
setExpanded(nextExpanded);
|
||||
if (nextExpanded) {
|
||||
void onLoadDetails(session, { force: true, urgent: true });
|
||||
}
|
||||
}}
|
||||
title={session.name}
|
||||
subtitle={t('systemManager.tmux.windows', { count: String(session.windows) })}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={session.attached ? 'success' : 'muted'}>
|
||||
{session.attached ? t('systemManager.tmux.attached') : t('systemManager.tmux.detached')}
|
||||
</SystemPanelStatusBadge>
|
||||
<SystemPanelRoundButton title={t('systemManager.tmux.attach')} onClick={() => handleAttach()}>
|
||||
<MonitorPlay size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.rename')}
|
||||
disabled={busy}
|
||||
onClick={() => setRenamePrompt({ kind: 'session' })}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
{session.attached && (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.detach')}
|
||||
disabled={busy}
|
||||
loading={isPending('detachSession')}
|
||||
onClick={() => setDetachConfirmOpen(true)}
|
||||
>
|
||||
<Unplug size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.killSession')}
|
||||
destructive
|
||||
disabled={busy}
|
||||
loading={isPending('killSession')}
|
||||
onClick={() => setKillSessionConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{actionError && <SystemPanelInlineError message={actionError} />}
|
||||
|
||||
<SystemPanelCollapsible open={expanded}>
|
||||
{loadingDetails && windows.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] text-muted-foreground border-b border-border/30">
|
||||
{t('systemManager.tmux.loadingDetails')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clients.length > 0 && (
|
||||
<SystemPanelDetailStrip>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('systemManager.tmux.clients')}: {clients.map((c) => c.tty || c.name).join(', ')}
|
||||
</div>
|
||||
</SystemPanelDetailStrip>
|
||||
)}
|
||||
|
||||
<SystemPanelSectionHeader
|
||||
trailing={(
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setNewWindowOpen(true)}
|
||||
className="shrink-0 h-5 px-1.5 rounded text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/60 inline-flex items-center gap-1 disabled:opacity-40"
|
||||
>
|
||||
{isPending('createWindow')
|
||||
? <Loader2 size={10} className="animate-spin" />
|
||||
: <Plus size={10} />}
|
||||
{t('systemManager.tmux.newWindow')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{t('systemManager.tmux.windowList')}{windows.length > 0 ? ` · ${windows.length}` : ''}
|
||||
</SystemPanelSectionHeader>
|
||||
|
||||
{windows.map((tmuxWindow) => (
|
||||
<SystemPanelRow
|
||||
key={tmuxWindow.index}
|
||||
depth={1}
|
||||
title={`#${tmuxWindow.index} ${tmuxWindow.name || t('systemManager.tmux.unnamedWindow')}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.attachWindow')}
|
||||
onClick={() => handleAttach(tmuxWindow.index)}
|
||||
>
|
||||
<MonitorPlay size={11} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.rename')}
|
||||
disabled={busy}
|
||||
onClick={() => setRenamePrompt({
|
||||
kind: 'window',
|
||||
windowIndex: tmuxWindow.index,
|
||||
currentName: tmuxWindow.name,
|
||||
})}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.tmux.killWindow')}
|
||||
destructive
|
||||
disabled={busy}
|
||||
loading={isPending('killWindow', tmuxWindow.index)}
|
||||
onClick={() => setKillWindowConfirm({
|
||||
windowIndex: tmuxWindow.index,
|
||||
windowName: tmuxWindow.name || String(tmuxWindow.index),
|
||||
})}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loadingDetails && windows.length === 0 && (
|
||||
<div className="px-3 py-2 text-[10px] text-muted-foreground border-b border-border/30 break-all">
|
||||
{windowsLoadDetail || actionError || t('systemManager.tmux.noWindows')}
|
||||
</div>
|
||||
)}
|
||||
</SystemPanelCollapsible>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={detachConfirmOpen}
|
||||
title={t('systemManager.tmux.detach')}
|
||||
message={t('systemManager.tmux.confirmDetachSession', { name: session.name })}
|
||||
confirmLabel={t('systemManager.tmux.detach')}
|
||||
destructive
|
||||
busy={busy}
|
||||
onOpenChange={setDetachConfirmOpen}
|
||||
onConfirm={() => {
|
||||
setDetachConfirmOpen(false);
|
||||
void runAction({ action: 'detachSession', sessionName: session.name });
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={killSessionConfirmOpen}
|
||||
title={t('systemManager.tmux.killSession')}
|
||||
message={t('systemManager.tmux.confirmKillSession', { name: session.name })}
|
||||
confirmLabel={t('systemManager.tmux.killSession')}
|
||||
destructive
|
||||
busy={busy}
|
||||
onOpenChange={setKillSessionConfirmOpen}
|
||||
onConfirm={() => {
|
||||
setKillSessionConfirmOpen(false);
|
||||
void runAction({ action: 'killSession', sessionName: session.name });
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={killWindowConfirm !== null}
|
||||
title={t('systemManager.tmux.killWindow')}
|
||||
message={t('systemManager.tmux.confirmKillWindow', {
|
||||
name: killWindowConfirm?.windowName ?? '',
|
||||
})}
|
||||
confirmLabel={t('systemManager.tmux.killWindow')}
|
||||
destructive
|
||||
busy={busy}
|
||||
onOpenChange={(open) => { if (!open) setKillWindowConfirm(null); }}
|
||||
onConfirm={() => {
|
||||
const target = killWindowConfirm;
|
||||
setKillWindowConfirm(null);
|
||||
if (!target) return;
|
||||
void runAction({
|
||||
action: 'killWindow',
|
||||
sessionName: session.name,
|
||||
windowIndex: target.windowIndex,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelPromptDialog
|
||||
open={renamePrompt !== null}
|
||||
title={renamePrompt?.kind === 'window'
|
||||
? t('systemManager.tmux.renameWindowPrompt')
|
||||
: t('systemManager.tmux.renameSessionPrompt')}
|
||||
fields={[{
|
||||
id: 'name',
|
||||
label: renamePrompt?.kind === 'window'
|
||||
? t('systemManager.tmux.windowName')
|
||||
: t('systemManager.tmux.newSessionName'),
|
||||
initialValue: renamePrompt?.kind === 'window' ? renamePrompt.currentName : session.name,
|
||||
}]}
|
||||
confirmLabel={t('common.rename')}
|
||||
busy={busy}
|
||||
onOpenChange={(open) => { if (!open) setRenamePrompt(null); }}
|
||||
onSubmit={(values) => {
|
||||
const target = renamePrompt;
|
||||
setRenamePrompt(null);
|
||||
if (!target) return;
|
||||
if (target.kind === 'session') {
|
||||
if (values.name !== session.name) {
|
||||
void runAction({ action: 'renameSession', sessionName: session.name, newName: values.name });
|
||||
}
|
||||
} else if (values.name !== target.currentName) {
|
||||
void runAction({
|
||||
action: 'renameWindow',
|
||||
sessionName: session.name,
|
||||
windowIndex: target.windowIndex,
|
||||
newName: values.name,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SystemPanelPromptDialog
|
||||
open={newWindowOpen}
|
||||
title={t('systemManager.tmux.newWindow')}
|
||||
fields={[{
|
||||
id: 'name',
|
||||
label: t('systemManager.tmux.windowName'),
|
||||
placeholder: t('systemManager.tmux.newWindowPlaceholder'),
|
||||
required: false,
|
||||
}]}
|
||||
confirmLabel={t('common.create')}
|
||||
busy={busy}
|
||||
onOpenChange={setNewWindowOpen}
|
||||
onSubmit={(values) => {
|
||||
setNewWindowOpen(false);
|
||||
void runAction({
|
||||
action: 'createWindow',
|
||||
sessionName: session.name,
|
||||
windowName: values.name || undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user