370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import {
|
|
Loader2, MonitorPlay, Pencil, Plus, Trash2, Unplug,
|
|
} from 'lucide-react';
|
|
import React, { memo, useCallback, useEffect, useState } from 'react';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
|
import { buildTmuxAttachCommand } from '../../domain/systemManager/tmuxShell';
|
|
import type {
|
|
TmuxClientInfo,
|
|
TmuxManageAction,
|
|
TmuxSessionInfo,
|
|
TmuxWindowInfo,
|
|
} from '../../domain/systemManager/types';
|
|
import type { TerminalSession } from '../../types';
|
|
import {
|
|
SystemPanelCollapsible,
|
|
SystemPanelDetailStrip,
|
|
SystemPanelInlineError,
|
|
SystemPanelRoundButton,
|
|
SystemPanelRow,
|
|
SystemPanelSectionHeader,
|
|
SystemPanelStatusBadge,
|
|
} from './SystemPanelUi';
|
|
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
|
|
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
|
import { showSystemManagerError } from './systemManagerToast';
|
|
|
|
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 TmuxSessionCardProps {
|
|
session: TmuxSessionInfo;
|
|
sessionId: string;
|
|
parentSession: TerminalSession;
|
|
backend: Backend;
|
|
onSessionsChanged: () => Promise<void>;
|
|
}
|
|
|
|
export const TmuxSessionCard = memo(function TmuxSessionCard({
|
|
session,
|
|
sessionId,
|
|
parentSession,
|
|
backend,
|
|
onSessionsChanged,
|
|
}: TmuxSessionCardProps) {
|
|
const { t } = useI18n();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
|
const [windows, setWindows] = useState<TmuxWindowInfo[]>([]);
|
|
const [clients, setClients] = useState<TmuxClientInfo[]>([]);
|
|
const [renamePrompt, setRenamePrompt] = useState<RenamePromptTarget | null>(null);
|
|
const [newWindowOpen, setNewWindowOpen] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const [windowsLoadDetail, setWindowsLoadDetail] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [pending, setPending] = useState<PendingTarget | null>(null);
|
|
|
|
const formatTmuxLoadError = useCallback((
|
|
message: string,
|
|
debug?: { lastOutput?: string; tried?: string[] },
|
|
) => {
|
|
const parts = [message];
|
|
if (debug?.lastOutput) parts.push(debug.lastOutput);
|
|
if (debug?.tried?.length) {
|
|
parts.push(t('systemManager.tmux.lastCommand', { command: debug.tried[debug.tried.length - 1] ?? '' }));
|
|
}
|
|
return parts.filter(Boolean).join(' · ');
|
|
}, [t]);
|
|
|
|
const loadDetails = useCallback(async (): Promise<TmuxWindowInfo[] | null> => {
|
|
setLoadingDetails(true);
|
|
setActionError(null);
|
|
setWindowsLoadDetail(null);
|
|
try {
|
|
const [windowsResult, clientsResult] = await Promise.all([
|
|
backend.listTmuxWindows({ sessionId, sessionName: session.name }),
|
|
backend.listTmuxClients({ sessionId, sessionName: session.name }),
|
|
]);
|
|
if (!windowsResult.success) {
|
|
const detail = formatTmuxLoadError(
|
|
windowsResult.error || t('systemManager.errors.loadTmuxWindows'),
|
|
windowsResult.debug,
|
|
);
|
|
setWindowsLoadDetail(detail);
|
|
throw new Error(detail);
|
|
}
|
|
if (!clientsResult.success) throw new Error(clientsResult.error || t('systemManager.errors.loadTmuxClients'));
|
|
const freshWindows = windowsResult.windows ?? [];
|
|
if (freshWindows.length === 0 && session.windows > 0) {
|
|
const detail = formatTmuxLoadError(
|
|
t('systemManager.tmux.windowsMismatch', { count: String(session.windows) }),
|
|
windowsResult.debug,
|
|
);
|
|
setWindowsLoadDetail(detail);
|
|
throw new Error(detail);
|
|
}
|
|
setWindows(freshWindows);
|
|
setClients(clientsResult.clients ?? []);
|
|
return freshWindows;
|
|
} catch (err) {
|
|
setActionError(err instanceof Error ? err.message : t('systemManager.errors.actionFailed'));
|
|
setWindows([]);
|
|
return null;
|
|
} finally {
|
|
setLoadingDetails(false);
|
|
}
|
|
}, [backend, formatTmuxLoadError, session.name, session.windows, sessionId, t]);
|
|
|
|
useEffect(() => {
|
|
if (expanded) void loadDetails();
|
|
}, [expanded, loadDetails]);
|
|
|
|
const runAction = async (action: TmuxManageAction) => {
|
|
setBusy(true);
|
|
setPending({
|
|
action: action.action,
|
|
windowIndex: 'windowIndex' in action ? action.windowIndex : undefined,
|
|
});
|
|
setActionError(null);
|
|
try {
|
|
const result = await backend.tmuxAction({ sessionId, ...action });
|
|
if (!result.success) throw new Error(result.error || t('systemManager.errors.actionFailed'));
|
|
const cardWillRemount = action.action === 'killSession' || action.action === 'renameSession';
|
|
if (!cardWillRemount && expanded) {
|
|
await loadDetails();
|
|
}
|
|
await onSessionsChanged();
|
|
} 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={() => setExpanded((v) => !v)}
|
|
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={() => {
|
|
if (globalThis.confirm(t('systemManager.tmux.confirmDetachSession', { name: session.name }))) {
|
|
void runAction({ action: 'detachSession', sessionName: session.name });
|
|
}
|
|
}}
|
|
>
|
|
<Unplug size={12} />
|
|
</SystemPanelRoundButton>
|
|
)}
|
|
<SystemPanelRoundButton
|
|
title={t('systemManager.tmux.killSession')}
|
|
destructive
|
|
disabled={busy}
|
|
loading={isPending('killSession')}
|
|
onClick={() => {
|
|
if (globalThis.confirm(t('systemManager.tmux.confirmKillSession', { name: session.name }))) {
|
|
void runAction({ action: 'killSession', sessionName: session.name });
|
|
}
|
|
}}
|
|
>
|
|
<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={() => {
|
|
if (globalThis.confirm(t('systemManager.tmux.confirmKillWindow', {
|
|
name: tmuxWindow.name || String(tmuxWindow.index),
|
|
}))) {
|
|
void runAction({
|
|
action: 'killWindow',
|
|
sessionName: session.name,
|
|
windowIndex: 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>
|
|
|
|
<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,
|
|
});
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
});
|