Files
xo-cli/src/tui/screens/TemplateList.tsx
2026-03-16 06:48:29 +00:00

474 lines
16 KiB
TypeScript

/**
* Template List Screen - Lists available templates and starting actions.
*
* Allows users to:
* - View imported templates
* - Select a template and action to start a new transaction
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { ScrollableList, type ListItemData } from '../components/List.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { colors, logoSmall } from '../theme.js';
// XO Imports
import { generateTemplateIdentifier } from '@xo-cash/engine';
import type { XOTemplate } from '@xo-cash/types';
// Import utility functions
import {
formatTemplateListItem,
formatActionListItem,
getTemplateRoles,
} from '../../utils/template-utils.js';
/**
* Template item with metadata.
*/
interface TemplateItem {
template: XOTemplate;
templateIdentifier: string;
availableActions: TemplateActionItem[];
}
/**
* Template list item with TemplateItem value.
*/
type TemplateListItem = ListItemData<TemplateItem>;
/**
* Action list item with available action value.
*/
type ActionListItem = ListItemData<TemplateActionItem>;
interface TemplateActionItem {
actionIdentifier: string;
name: string;
description?: string;
roles: string[];
source: 'starting' | 'next' | 'starting+next';
}
/**
* Template List Screen Component.
* Displays templates and their starting actions.
*/
export function TemplateListScreen(): React.ReactElement {
const { navigate } = useNavigation();
const { appService, showError } = useAppContext();
const { setStatus } = useStatus();
// State
const [templates, setTemplates] = useState<TemplateItem[]>([]);
const [selectedTemplateIndex, setSelectedTemplateIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
const [isLoading, setIsLoading] = useState(true);
/**
* Loads templates from the engine.
*/
const loadTemplates = useCallback(async () => {
if (!appService) {
showError('AppService not initialized');
return;
}
try {
setIsLoading(true);
setStatus('Loading templates...');
const templateList = await appService.engine.listImportedTemplates();
const allUtxos = await appService.engine.listUnspentOutputsData();
const ownedOutputsByTemplate = new Map<string, Set<string>>();
for (const utxo of allUtxos) {
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
existing.add(utxo.outputIdentifier);
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
}
const loadedTemplates = await Promise.all(
templateList.map(async (template) => {
const templateIdentifier = generateTemplateIdentifier(template);
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
const actionMap = new Map<string, TemplateActionItem>();
for (const startingAction of rawStartingActions) {
const existing = actionMap.get(startingAction.action);
if (existing) {
if (!existing.roles.includes(startingAction.role)) {
existing.roles.push(startingAction.role);
}
continue;
}
const actionDef = template.actions?.[startingAction.action];
actionMap.set(startingAction.action, {
actionIdentifier: startingAction.action,
name: actionDef?.name || startingAction.action,
description: actionDef?.description,
roles: [startingAction.role],
source: 'starting',
});
}
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
for (const outputIdentifier of ownedOutputIdentifiers) {
const outputDef = template.outputs?.[outputIdentifier];
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
| undefined;
if (!lockingScriptDefinition?.roles) continue;
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
const actionIdentifier = typeof actionSpec === 'string'
? actionSpec
: actionSpec.action;
if (!actionIdentifier) continue;
const roleIdentifier = typeof actionSpec === 'string'
? lockscriptRoleId
: (actionSpec.role ?? lockscriptRoleId);
const existing = actionMap.get(actionIdentifier);
if (existing) {
if (!existing.roles.includes(roleIdentifier)) {
existing.roles.push(roleIdentifier);
}
if (existing.source === 'starting') {
existing.source = 'starting+next';
}
continue;
}
const actionDef = template.actions?.[actionIdentifier];
actionMap.set(actionIdentifier, {
actionIdentifier,
name: actionDef?.name || actionIdentifier,
description: actionDef?.description,
roles: [roleIdentifier],
source: 'next',
});
}
}
}
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
return {
template,
templateIdentifier,
availableActions,
};
})
);
setTemplates(loadedTemplates);
setSelectedTemplateIndex(0);
setSelectedActionIndex(0);
setStatus('Ready');
setIsLoading(false);
} catch (error) {
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
setIsLoading(false);
}
}, [appService, setStatus, showError]);
// Load templates on mount
useEffect(() => {
loadTemplates();
}, [loadTemplates]);
// Get current template and its actions
const currentTemplate = templates[selectedTemplateIndex];
const currentActions = currentTemplate?.availableActions ?? [];
/**
* Build template list items for ScrollableList.
*/
const templateListItems = useMemo((): TemplateListItem[] => {
return templates.map((item, index) => {
const formatted = formatTemplateListItem(item.template, index);
return {
key: item.templateIdentifier,
label: formatted.label,
description: formatted.description,
value: item,
hidden: !formatted.isValid,
};
});
}, [templates]);
/**
* Build action list items for ScrollableList.
*/
const actionListItems = useMemo((): ActionListItem[] => {
return currentActions.map((action, index) => {
const formatted = formatActionListItem(
action.actionIdentifier,
currentTemplate?.template?.actions?.[action.actionIdentifier],
action.roles.length,
index
);
const sourceSuffix = action.source === 'next'
? ' [next]'
: action.source === 'starting+next'
? ' [start+next]'
: '';
return {
key: action.actionIdentifier,
label: `${formatted.label}${sourceSuffix}`,
description: formatted.description,
value: action,
hidden: !formatted.isValid,
};
});
}, [currentActions, currentTemplate]);
/**
* Handle template selection change.
*/
const handleTemplateSelect = useCallback((index: number) => {
setSelectedTemplateIndex(index);
setSelectedActionIndex(0); // Reset action selection when template changes
}, []);
/**
* Handles action selection.
* Navigates to the Action Wizard where the user will choose their role.
*/
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
if (!currentTemplate || !item.value) return;
const action = item.value;
// Navigate to the Action Wizard — role selection happens there
navigate('wizard', {
templateIdentifier: currentTemplate.templateIdentifier,
actionIdentifier: action.actionIdentifier,
actionRoles: action.roles,
template: currentTemplate.template,
});
}, [currentTemplate, navigate]);
// Handle keyboard navigation
useInput((input, key) => {
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
return;
}
});
/**
* Render custom template list item.
*/
const renderTemplateItem = useCallback((
item: TemplateListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
/**
* Render custom action list item.
*/
const renderActionItem = useCallback((
item: ActionListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}, []);
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
</Box>
{/* Main content - two columns */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Template list */}
<Box flexDirection="column" width="40%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Templates </Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
</Box>
) : (
<ScrollableList
items={templateListItems}
selectedIndex={selectedTemplateIndex}
onSelect={handleTemplateSelect}
focus={focusedPanel === 'templates'}
emptyMessage="No templates imported"
renderItem={renderTemplateItem}
/>
)}
</Box>
</Box>
{/* Right column: Actions list */}
<Box flexDirection="column" width="60%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Available Actions </Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
</Box>
) : !currentTemplate ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Select a template...</Text>
</Box>
) : (
<ScrollableList
items={actionListItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionActivate}
focus={focusedPanel === 'actions'}
emptyMessage="No actions available"
renderItem={renderActionItem}
/>
)}
</Box>
</Box>
</Box>
{/* Description box */}
<Box marginTop={1}>
<Box
borderStyle="single"
borderColor={colors.border}
flexDirection="column"
paddingX={1}
paddingY={1}
width="100%"
>
<Text color={colors.primary} bold> Description </Text>
{/* Show template description when templates panel is focused */}
{focusedPanel === 'templates' && currentTemplate ? (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text} bold>
{currentTemplate.template.name || 'Unnamed Template'}
</Text>
<Text color={colors.textMuted}>
{currentTemplate.template.description || 'No description available'}
</Text>
{currentTemplate.template.version !== undefined && (
<Text color={colors.text}>
Version: {currentTemplate.template.version}
</Text>
)}
{currentTemplate.template.roles && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Roles:</Text>
{getTemplateRoles(currentTemplate.template).map((role) => (
<Text key={role.roleId} color={colors.textMuted}>
{' '}- {role.name}: {role.description || 'No description'}
</Text>
))}
</Box>
)}
</Box>
) : focusedPanel === 'templates' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template to see details</Text>
) : null}
{/* Show action description when actions panel is focused */}
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
<Box marginTop={1} flexDirection="column">
{(() => {
const action = currentActions[selectedActionIndex];
if (!action) return null;
return (
<>
<Text color={colors.text} bold>
{action.name}
</Text>
<Text color={colors.textMuted}>
{action.description || 'No description available'}
</Text>
{/* List roles available for this action in current context */}
{action.roles.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Available Roles:</Text>
{action.roles.map((roleId) => {
const roleDef = currentTemplate.template.roles?.[roleId];
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
return (
<Text key={roleId} color={colors.textMuted}>
{' '}- {roleName}
{roleDescription ? `: ${roleDescription}` : ''}
</Text>
);
})}
<Text color={colors.textMuted}>
{' '}Source: {action.source}
</Text>
</Box>
)}
</>
);
})()}
</Box>
) : focusedPanel === 'actions' && !currentTemplate ? (
<Text color={colors.textMuted}>Select a template first</Text>
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
<Text color={colors.textMuted}>No actions available</Text>
) : null}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch list Enter: Select action : Navigate Esc: Back
</Text>
</Box>
</Box>
);
}