mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
add hook detail page
This commit is contained in:
parent
247e8b8742
commit
a0b3cc3268
12 changed files with 689 additions and 17 deletions
|
|
@ -0,0 +1,343 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
HookEventName,
|
||||
HooksConfigSource,
|
||||
HookType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import type { HookEventDisplayInfo, HookConfigDisplayInfo } from './types.js';
|
||||
|
||||
// Mock i18n module
|
||||
vi.mock('../../../i18n/index.js', () => ({
|
||||
t: vi.fn((key: string) => key),
|
||||
}));
|
||||
|
||||
// Mock useKeypress
|
||||
vi.mock('../../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'white',
|
||||
secondary: 'gray',
|
||||
accent: 'cyan',
|
||||
},
|
||||
border: {
|
||||
default: 'gray',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('HookConfigDetailStep', () => {
|
||||
const mockOnBack = vi.fn();
|
||||
|
||||
const createMockHookEvent = (): HookEventDisplayInfo => ({
|
||||
event: HookEventName.Stop,
|
||||
shortDescription: 'Right before Qwen Code concludes its response',
|
||||
description: '',
|
||||
exitCodes: [
|
||||
{ code: 0, description: 'stdout/stderr not shown' },
|
||||
{
|
||||
code: 2,
|
||||
description: 'show stderr to model and continue conversation',
|
||||
},
|
||||
{ code: 'Other', description: 'show stderr to user only' },
|
||||
],
|
||||
configs: [],
|
||||
});
|
||||
|
||||
const createMockHookConfig = (
|
||||
source: HooksConfigSource = HooksConfigSource.User,
|
||||
sourceDisplay = 'User Settings',
|
||||
sourcePath?: string,
|
||||
): HookConfigDisplayInfo => ({
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
},
|
||||
source,
|
||||
sourceDisplay,
|
||||
sourcePath,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render hook details title', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Hook details');
|
||||
});
|
||||
|
||||
it('should render event name', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Event:');
|
||||
expect(lastFrame()).toContain(HookEventName.Stop);
|
||||
});
|
||||
|
||||
it('should render hook type', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Type:');
|
||||
expect(lastFrame()).toContain('command');
|
||||
});
|
||||
|
||||
it('should render source for User Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Source:');
|
||||
expect(lastFrame()).toContain('User Settings');
|
||||
});
|
||||
|
||||
it('should render source for Local Settings', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Local Settings');
|
||||
});
|
||||
|
||||
it('should render source for Extensions with path', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
'/Users/test/.qwen/extensions/ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extensions');
|
||||
expect(lastFrame()).toContain('/Users/test/.qwen/extensions/ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should render Extension field for extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(
|
||||
HooksConfigSource.Extensions,
|
||||
'ralph-wiggum',
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Extension:');
|
||||
expect(lastFrame()).toContain('ralph-wiggum');
|
||||
});
|
||||
|
||||
it('should not render Extension field for non-extensions', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig(HooksConfigSource.User);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should not have Extension label for User Settings
|
||||
const output = lastFrame();
|
||||
const extensionMatch = output?.match(/Extension:/g);
|
||||
expect(extensionMatch).toBeNull();
|
||||
});
|
||||
|
||||
it('should render command', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Command:');
|
||||
expect(lastFrame()).toContain('/path/to/hook.sh');
|
||||
});
|
||||
|
||||
it('should render hook name if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
name: 'My Hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Name:');
|
||||
expect(lastFrame()).toContain('My Hook');
|
||||
});
|
||||
|
||||
it('should render hook description if present', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig: HookConfigDisplayInfo = {
|
||||
config: {
|
||||
type: HookType.Command,
|
||||
command: '/path/to/hook.sh',
|
||||
description: 'A test hook',
|
||||
},
|
||||
source: HooksConfigSource.User,
|
||||
sourceDisplay: 'User Settings',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Desc:');
|
||||
expect(lastFrame()).toContain('A test hook');
|
||||
});
|
||||
|
||||
it('should render help text', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('To modify or remove this hook');
|
||||
});
|
||||
|
||||
it('should render Esc hint', () => {
|
||||
const hookEvent = createMockHookEvent();
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Esc to go back');
|
||||
});
|
||||
|
||||
it('should handle different event types', () => {
|
||||
const events = [
|
||||
HookEventName.PreToolUse,
|
||||
HookEventName.PostToolUse,
|
||||
HookEventName.UserPromptSubmit,
|
||||
HookEventName.SessionStart,
|
||||
];
|
||||
|
||||
for (const event of events) {
|
||||
const hookEvent: HookEventDisplayInfo = {
|
||||
event,
|
||||
shortDescription: 'Test',
|
||||
description: '',
|
||||
exitCodes: [],
|
||||
configs: [],
|
||||
};
|
||||
const hookConfig = createMockHookConfig();
|
||||
|
||||
const { lastFrame } = render(
|
||||
<HookConfigDetailStep
|
||||
hookEvent={hookEvent}
|
||||
hookConfig={hookConfig}
|
||||
onBack={mockOnBack}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain(event);
|
||||
}
|
||||
});
|
||||
});
|
||||
179
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
179
packages/cli/src/ui/components/hooks/HookConfigDetailStep.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookConfigDisplayInfo, HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookConfigDetailStepProps {
|
||||
hookEvent: HookEventDisplayInfo;
|
||||
hookConfig: HookConfigDisplayInfo;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function HookConfigDetailStep({
|
||||
hookEvent,
|
||||
hookConfig,
|
||||
onBack,
|
||||
}: HookConfigDetailStepProps): React.JSX.Element {
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Get source display
|
||||
const getSourceDisplay = (): string => {
|
||||
switch (hookConfig.source) {
|
||||
case HooksConfigSource.Project:
|
||||
return t('Local Settings');
|
||||
case HooksConfigSource.User:
|
||||
return t('User Settings');
|
||||
case HooksConfigSource.System:
|
||||
return t('System Settings');
|
||||
case HooksConfigSource.Extensions:
|
||||
return t('Extensions');
|
||||
default:
|
||||
return hookConfig.source;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if this is from an extension
|
||||
const isFromExtension = hookConfig.source === HooksConfigSource.Extensions;
|
||||
|
||||
// Get hook type display
|
||||
const getHookTypeDisplay = (): string => {
|
||||
switch (hookConfig.config.type) {
|
||||
case 'command':
|
||||
return 'command';
|
||||
default:
|
||||
return hookConfig.config.type;
|
||||
}
|
||||
};
|
||||
|
||||
// Get command to display
|
||||
const getCommand = (): string => {
|
||||
if (hookConfig.config.type === 'command') {
|
||||
return hookConfig.config.command;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calculate box width for command display
|
||||
const commandBoxWidth = Math.min(terminalWidth - 6, 80);
|
||||
|
||||
// Label width for alignment (Extension: is the longest label)
|
||||
const labelWidth = 12;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{t('Hook details')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Event */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Event:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookEvent.event}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Type */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Type:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getHookTypeDisplay()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Source */}
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{getSourceDisplay()}</Text>
|
||||
{hookConfig.sourcePath && (
|
||||
<Text color={theme.text.secondary}> ({hookConfig.sourcePath})</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Extension name (only for extensions) */}
|
||||
{isFromExtension && hookConfig.sourceDisplay && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Extension:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.sourceDisplay}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Name (if exists) */}
|
||||
{hookConfig.config.name && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Name:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>{hookConfig.config.name}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{hookConfig.config.description && (
|
||||
<Box>
|
||||
<Box width={labelWidth}>
|
||||
<Text color={theme.text.secondary}>{t('Desc:')}</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.primary}>
|
||||
{hookConfig.config.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Command */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Command:')}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Command box */}
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
width={commandBoxWidth}
|
||||
>
|
||||
<Text color={theme.text.primary}>{getCommand()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Footer hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,6 +24,11 @@ vi.mock('../../hooks/useKeypress.js', () => ({
|
|||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useTerminalSize
|
||||
vi.mock('../../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
|
||||
}));
|
||||
|
||||
// Mock semantic-colors
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
|
|
@ -137,6 +142,7 @@ describe('HookDetailStep', () => {
|
|||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Configured hooks');
|
||||
expect(output).toContain('[command]');
|
||||
expect(output).toContain('hook-command-0');
|
||||
expect(output).toContain('hook-command-1');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,25 +8,34 @@ import { useState } from 'react';
|
|||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
||||
import type { HookEventDisplayInfo } from './types.js';
|
||||
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
|
||||
import { getTranslatedSourceDisplayMap } from './constants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface HookDetailStepProps {
|
||||
hook: HookEventDisplayInfo;
|
||||
onBack: () => void;
|
||||
onSelectConfig?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function HookDetailStep({
|
||||
hook,
|
||||
onBack,
|
||||
onSelectConfig,
|
||||
}: HookDetailStepProps): React.JSX.Element {
|
||||
const hasConfigs = hook.configs.length > 0;
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
|
||||
// Get translated source display map
|
||||
const sourceDisplayMap = getTranslatedSourceDisplayMap();
|
||||
|
||||
// Calculate column widths (command: 70%, source: 30%)
|
||||
const commandWidth = Math.floor(terminalWidth * 0.65);
|
||||
const sourceWidth = Math.floor(terminalWidth * 0.3);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -39,12 +48,26 @@ export function HookDetailStep({
|
|||
setSelectedIndex((prev) =>
|
||||
Math.min(hook.configs.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.name === 'return' && onSelectConfig) {
|
||||
onSelectConfig(selectedIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Get source display for config list
|
||||
const getConfigSourceDisplay = (config: {
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
}): string => {
|
||||
if (config.source === HooksConfigSource.Extensions) {
|
||||
// For extensions, sourceDisplay is the extension name
|
||||
return `${sourceDisplayMap[HooksConfigSource.Extensions]} (${config.sourceDisplay})`;
|
||||
}
|
||||
return sourceDisplayMap[config.source] || config.source;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{/* Title */}
|
||||
|
|
@ -87,31 +110,49 @@ export function HookDetailStep({
|
|||
</Text>
|
||||
{hook.configs.map((config, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const sourceDisplay =
|
||||
sourceDisplayMap[config.source] || config.source;
|
||||
const sourceDisplay = getConfigSourceDisplay(config);
|
||||
const command =
|
||||
config.config.type === 'command' ? config.config.command : '';
|
||||
const hookType = config.config.type;
|
||||
|
||||
return (
|
||||
<Box key={index}>
|
||||
<Box minWidth={2}>
|
||||
{/* Left column: selector + command */}
|
||||
<Box width={commandWidth}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
wrap="wrap"
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
{`${index + 1}. [${hookType}] ${command}`}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Right column: source */}
|
||||
<Box width={sourceWidth}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{sourceDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
bold={isSelected}
|
||||
>
|
||||
{`${index + 1}. ${config.config.command}`}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
<Text color={theme.text.secondary}>{sourceDisplay}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
{onSelectConfig ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to select · Esc to go back')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
import { HOOKS_MANAGEMENT_STEPS } from './types.js';
|
||||
import { HooksListStep } from './HooksListStep.js';
|
||||
import { HookDetailStep } from './HookDetailStep.js';
|
||||
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
|
||||
import {
|
||||
DISPLAY_HOOK_EVENTS,
|
||||
getTranslatedSourceDisplayMap,
|
||||
|
|
@ -42,6 +43,7 @@ export function HooksManagementDialog({
|
|||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
|
||||
]);
|
||||
const [selectedHookIndex, setSelectedHookIndex] = useState<number>(-1);
|
||||
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
|
||||
const [hooks, setHooks] = useState<HookEventDisplayInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
|
@ -107,7 +109,8 @@ export function HooksManagementDialog({
|
|||
hookInfo.configs.push({
|
||||
config: hookConfig,
|
||||
source: HooksConfigSource.Extensions,
|
||||
sourceDisplay: sourceDisplayMap[HooksConfigSource.Extensions],
|
||||
sourceDisplay: extension.name,
|
||||
sourcePath: extension.path,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -167,13 +170,23 @@ export function HooksManagementDialog({
|
|||
});
|
||||
}, [onClose]);
|
||||
|
||||
// Select hook
|
||||
// Select hook event
|
||||
const handleSelectHook = useCallback((index: number) => {
|
||||
setSelectedHookIndex(index);
|
||||
setSelectedConfigIndex(-1);
|
||||
setNavigationStack((prev) => [...prev, HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL]);
|
||||
}, []);
|
||||
|
||||
// Selected hook
|
||||
// Select hook config
|
||||
const handleSelectConfig = useCallback((index: number) => {
|
||||
setSelectedConfigIndex(index);
|
||||
setNavigationStack((prev) => [
|
||||
...prev,
|
||||
HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL,
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Selected hook event
|
||||
const selectedHook = useMemo(() => {
|
||||
if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
|
||||
return hooks[selectedHookIndex];
|
||||
|
|
@ -181,6 +194,18 @@ export function HooksManagementDialog({
|
|||
return null;
|
||||
}, [hooks, selectedHookIndex]);
|
||||
|
||||
// Selected hook config
|
||||
const selectedConfig = useMemo(() => {
|
||||
if (
|
||||
selectedHook &&
|
||||
selectedConfigIndex >= 0 &&
|
||||
selectedConfigIndex < selectedHook.configs.length
|
||||
) {
|
||||
return selectedHook.configs[selectedConfigIndex];
|
||||
}
|
||||
return null;
|
||||
}, [selectedHook, selectedConfigIndex]);
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = () => {
|
||||
const currentStep = getCurrentStep();
|
||||
|
|
@ -220,7 +245,11 @@ export function HooksManagementDialog({
|
|||
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
|
||||
if (selectedHook) {
|
||||
return (
|
||||
<HookDetailStep hook={selectedHook} onBack={handleNavigateBack} />
|
||||
<HookDetailStep
|
||||
hook={selectedHook}
|
||||
onBack={handleNavigateBack}
|
||||
onSelectConfig={handleSelectConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
|
@ -229,6 +258,24 @@ export function HooksManagementDialog({
|
|||
</Box>
|
||||
);
|
||||
|
||||
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
|
||||
if (selectedHook && selectedConfig) {
|
||||
return (
|
||||
<HookConfigDetailStep
|
||||
hookEvent={selectedHook}
|
||||
hookConfig={selectedConfig}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No hook config selected')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface HookConfigDisplayInfo {
|
|||
config: HookConfig;
|
||||
source: HooksConfigSource;
|
||||
sourceDisplay: string;
|
||||
sourcePath?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export interface HookConfigDisplayInfo {
|
|||
export const HOOKS_MANAGEMENT_STEPS = {
|
||||
HOOKS_LIST: 'hooks_list',
|
||||
HOOK_DETAIL: 'hook_detail',
|
||||
HOOK_CONFIG_DETAIL: 'hook_config_detail',
|
||||
} as const;
|
||||
|
||||
export type HooksManagementStep =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue