Add import template into tui. Fix tests that fail on macos. Fix some updates.
This commit is contained in:
@@ -8,10 +8,12 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import path from 'node:path';
|
||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||
import { FilePicker } from '../components/FilePicker.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||
import { useBlockableInput, useInputLayer, useIsInputCaptured, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||
import { colors, logoSmall } from '../theme.js';
|
||||
|
||||
// XO Imports
|
||||
@@ -25,6 +27,8 @@ import {
|
||||
getTemplateRoles,
|
||||
} from '../../utils/template-utils.js';
|
||||
import { buildScriptHashDataMap } from '../../utils/utxo-metadata.js';
|
||||
import { loadTemplateFromFile } from '../../utils/load-template-from-file.js';
|
||||
import { ConfirmDialog, DialogWrapper } from '../components/Dialog.js';
|
||||
|
||||
/**
|
||||
* Template item with metadata.
|
||||
@@ -53,6 +57,55 @@ interface TemplateActionItem {
|
||||
source: 'starting' | 'next' | 'starting+next';
|
||||
}
|
||||
|
||||
/** List item key for the synthetic import row. */
|
||||
const IMPORT_TEMPLATE_KEY = 'import-template';
|
||||
|
||||
/** Input layer id shared by the import dialog and its file picker. */
|
||||
const IMPORT_TEMPLATE_DIALOG_LAYER_ID = 'import-template-dialog';
|
||||
|
||||
/**
|
||||
* Import template dialog overlay.
|
||||
* Captures keyboard input and wraps the generic {@link FilePicker}.
|
||||
*/
|
||||
function ImportTemplateDialogOverlay({
|
||||
onClose,
|
||||
onSelectFile,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSelectFile: (filePath: string) => void;
|
||||
}): React.ReactElement {
|
||||
useInputLayer(IMPORT_TEMPLATE_DIALOG_LAYER_ID);
|
||||
|
||||
useLayeredInput(IMPORT_TEMPLATE_DIALOG_LAYER_ID, (_input, key) => {
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogWrapper title="Import Template" borderColor={colors.primary} width={72}>
|
||||
<Text color={colors.textMuted}>
|
||||
Select a JSON, JavaScript, or TypeScript template file from disk.
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<FilePicker
|
||||
layerId={IMPORT_TEMPLATE_DIALOG_LAYER_ID}
|
||||
extensions={['json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts']}
|
||||
onSelectFile={onSelectFile}
|
||||
maxVisible={8}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
↑↓ navigate • Enter open/select • Esc cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Template List Screen Component.
|
||||
* Displays templates and their starting actions.
|
||||
@@ -68,6 +121,10 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = useState<TemplateItem | null>(null);
|
||||
const isCaptured = useIsInputCaptured();
|
||||
|
||||
/**
|
||||
* Loads templates from the engine.
|
||||
@@ -196,15 +253,23 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
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 importTemplateItem: TemplateListItem = {
|
||||
key: IMPORT_TEMPLATE_KEY,
|
||||
label: 'Import Template',
|
||||
description: 'Import a template from a file',
|
||||
value: {
|
||||
templateIdentifier: IMPORT_TEMPLATE_KEY,
|
||||
template: {} as XOTemplate,
|
||||
availableActions: [],
|
||||
},
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
return [...templates.map((item, index) => {
|
||||
const formatted = formatTemplateListItem(item.template, index);
|
||||
return {
|
||||
key: item.templateIdentifier,
|
||||
@@ -213,9 +278,16 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
value: item,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}), importTemplateItem];
|
||||
}, [templates]);
|
||||
|
||||
const selectedTemplateListItem = templateListItems[selectedTemplateIndex];
|
||||
const isImportRowSelected = selectedTemplateListItem?.key === IMPORT_TEMPLATE_KEY;
|
||||
const currentTemplate = isImportRowSelected
|
||||
? undefined
|
||||
: selectedTemplateListItem?.value;
|
||||
const currentActions = currentTemplate?.availableActions ?? [];
|
||||
|
||||
/**
|
||||
* Build action list items for ScrollableList.
|
||||
*/
|
||||
@@ -246,6 +318,86 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Opens the import file picker.
|
||||
*/
|
||||
const openImportDialog = useCallback(() => {
|
||||
setIsImportDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Opens the import file picker when the synthetic import row is activated.
|
||||
*/
|
||||
const handleTemplateActivate = useCallback((item: TemplateListItem) => {
|
||||
if (item.key === IMPORT_TEMPLATE_KEY) {
|
||||
openImportDialog();
|
||||
}
|
||||
}, [openImportDialog]);
|
||||
|
||||
/**
|
||||
* Opens delete confirmation for the currently selected template.
|
||||
*/
|
||||
const openDeleteDialog = useCallback(() => {
|
||||
if (!currentTemplate) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTemplateToDelete(currentTemplate);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}, [currentTemplate]);
|
||||
|
||||
/**
|
||||
* Deletes the confirmed template from local storage.
|
||||
*/
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!appService || !templateToDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedName =
|
||||
templateToDelete.template.name || templateToDelete.templateIdentifier;
|
||||
|
||||
try {
|
||||
setStatus('Deleting template...');
|
||||
await appService.engine.DANGEROUS_deleteImportedTemplate(
|
||||
templateToDelete.templateIdentifier,
|
||||
);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
await loadTemplates();
|
||||
setStatus(`Deleted ${deletedName}`);
|
||||
} catch (error) {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
showError(
|
||||
`Failed to delete template: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}, [appService, loadTemplates, setStatus, showError, templateToDelete]);
|
||||
|
||||
/**
|
||||
* Loads the selected template file and imports it through the engine.
|
||||
*/
|
||||
const handleImportFile = useCallback(async (filePath: string) => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('Importing template...');
|
||||
const content = await loadTemplateFromFile(filePath);
|
||||
await appService.engine.importTemplate(content);
|
||||
await loadTemplates();
|
||||
setIsImportDialogOpen(false);
|
||||
setStatus(`Imported ${path.basename(filePath)}`);
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to import template: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}, [appService, loadTemplates, setStatus, showError]);
|
||||
|
||||
/**
|
||||
* Handles action selection.
|
||||
* Navigates to the Action Wizard where the user will choose their role.
|
||||
@@ -264,12 +416,25 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
});
|
||||
}, [currentTemplate, navigate]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useBlockableInput((_input, key) => {
|
||||
// Handle keyboard navigation and template shortcuts
|
||||
useBlockableInput((input, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoading || focusedPanel !== 'templates') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'a' || input === 'A') {
|
||||
openImportDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((input === 'd' || input === 'D') && currentTemplate) {
|
||||
openDeleteDialog();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -338,7 +503,8 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
items={templateListItems}
|
||||
selectedIndex={selectedTemplateIndex}
|
||||
onSelect={handleTemplateSelect}
|
||||
focus={focusedPanel === 'templates'}
|
||||
onActivate={handleTemplateActivate}
|
||||
focus={focusedPanel === 'templates' && !isCaptured}
|
||||
emptyMessage="No templates imported"
|
||||
renderItem={renderTemplateItem}
|
||||
/>
|
||||
@@ -360,6 +526,12 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : isImportRowSelected ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Import a template to see available actions
|
||||
</Text>
|
||||
</Box>
|
||||
) : !currentTemplate ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Select a template...</Text>
|
||||
@@ -370,7 +542,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
focus={focusedPanel === 'actions' && !isCaptured}
|
||||
emptyMessage="No actions available"
|
||||
renderItem={renderActionItem}
|
||||
/>
|
||||
@@ -392,7 +564,15 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
<Text color={colors.primary} bold> Description </Text>
|
||||
|
||||
{/* Show template description when templates panel is focused */}
|
||||
{focusedPanel === 'templates' && currentTemplate ? (
|
||||
{focusedPanel === 'templates' && isImportRowSelected ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>Import Template</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Import a template file (JSON, JavaScript, or TypeScript) from the directory where the TUI was launched.
|
||||
Press Enter or a to open the file picker.
|
||||
</Text>
|
||||
</Box>
|
||||
) : focusedPanel === 'templates' && currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>
|
||||
{currentTemplate.template.name || 'Unnamed Template'}
|
||||
@@ -471,9 +651,57 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back
|
||||
{focusedPanel === 'templates' && isImportRowSelected
|
||||
? 'Tab: Switch list • a/Enter: Import • ↑↓: Navigate • Esc: Back'
|
||||
: focusedPanel === 'templates' && currentTemplate
|
||||
? 'Tab: Switch list • a: Import • d: Delete • ↑↓: Navigate • Esc: Back'
|
||||
: 'Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import template dialog overlay */}
|
||||
{isImportDialogOpen && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<ImportTemplateDialogOverlay
|
||||
onClose={() => setIsImportDialogOpen(false)}
|
||||
onSelectFile={handleImportFile}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Delete template confirmation dialog */}
|
||||
{isDeleteDialogOpen && templateToDelete && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<ConfirmDialog
|
||||
title="Delete Template"
|
||||
message={
|
||||
`Delete "${templateToDelete.template.name || templateToDelete.templateIdentifier}"?\n\n` +
|
||||
'This removes the template from local storage. Invitations that use it may become unusable.'
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setTemplateToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user