@akiojin/gwt 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +323 -0
- package/README.md +347 -0
- package/bin/gwt.js +5 -0
- package/package.json +125 -0
- package/src/claude-history.ts +717 -0
- package/src/claude.ts +292 -0
- package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
- package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
- package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
- package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
- package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
- package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
- package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
- package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
- package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
- package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
- package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
- package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
- package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
- package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
- package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
- package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
- package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
- package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
- package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
- package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
- package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
- package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
- package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
- package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
- package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
- package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
- package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
- package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
- package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
- package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
- package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
- package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
- package/src/cli/ui/components/App.tsx +793 -0
- package/src/cli/ui/components/common/Confirm.tsx +40 -0
- package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
- package/src/cli/ui/components/common/Input.tsx +36 -0
- package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
- package/src/cli/ui/components/common/Select.tsx +216 -0
- package/src/cli/ui/components/parts/Footer.tsx +41 -0
- package/src/cli/ui/components/parts/Header.test.tsx +85 -0
- package/src/cli/ui/components/parts/Header.tsx +63 -0
- package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
- package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
- package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
- package/src/cli/ui/components/parts/Stats.tsx +67 -0
- package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
- package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
- package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
- package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
- package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
- package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
- package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
- package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
- package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
- package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
- package/src/cli/ui/hooks/useGitData.ts +157 -0
- package/src/cli/ui/hooks/useScreenState.ts +44 -0
- package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
- package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
- package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
- package/src/cli/ui/types.ts +295 -0
- package/src/cli/ui/utils/baseBranch.ts +34 -0
- package/src/cli/ui/utils/branchFormatter.ts +222 -0
- package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
- package/src/codex.ts +139 -0
- package/src/config/builtin-tools.ts +44 -0
- package/src/config/constants.ts +100 -0
- package/src/config/env-history.ts +45 -0
- package/src/config/index.ts +204 -0
- package/src/config/tools.ts +293 -0
- package/src/git.ts +1102 -0
- package/src/github.ts +158 -0
- package/src/index.test.ts +87 -0
- package/src/index.ts +684 -0
- package/src/index.ts.backup +1543 -0
- package/src/launcher.ts +142 -0
- package/src/repositories/git.repository.ts +129 -0
- package/src/repositories/github.repository.ts +83 -0
- package/src/repositories/worktree.repository.ts +69 -0
- package/src/services/BatchMergeService.ts +251 -0
- package/src/services/WorktreeOrchestrator.ts +115 -0
- package/src/services/__tests__/BatchMergeService.test.ts +518 -0
- package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
- package/src/services/dependency-installer.ts +199 -0
- package/src/services/git.service.ts +113 -0
- package/src/services/github.service.ts +61 -0
- package/src/services/worktree.service.ts +66 -0
- package/src/types/api.ts +241 -0
- package/src/types/tools.ts +235 -0
- package/src/utils/spinner.ts +54 -0
- package/src/utils/terminal.ts +272 -0
- package/src/utils.test.ts +43 -0
- package/src/utils.ts +60 -0
- package/src/web/client/index.html +12 -0
- package/src/web/client/src/components/BranchGraph.tsx +231 -0
- package/src/web/client/src/components/EnvEditor.tsx +145 -0
- package/src/web/client/src/components/Terminal.tsx +137 -0
- package/src/web/client/src/hooks/useBranches.ts +41 -0
- package/src/web/client/src/hooks/useConfig.ts +31 -0
- package/src/web/client/src/hooks/useSessions.ts +59 -0
- package/src/web/client/src/hooks/useWorktrees.ts +47 -0
- package/src/web/client/src/index.css +834 -0
- package/src/web/client/src/lib/api.ts +184 -0
- package/src/web/client/src/lib/websocket.ts +174 -0
- package/src/web/client/src/main.tsx +29 -0
- package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
- package/src/web/client/src/pages/BranchListPage.tsx +264 -0
- package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
- package/src/web/client/src/router.tsx +27 -0
- package/src/web/client/vite.config.ts +21 -0
- package/src/web/server/env/importer.ts +54 -0
- package/src/web/server/index.ts +74 -0
- package/src/web/server/pty/manager.ts +189 -0
- package/src/web/server/routes/branches.ts +126 -0
- package/src/web/server/routes/config.ts +220 -0
- package/src/web/server/routes/index.ts +37 -0
- package/src/web/server/routes/sessions.ts +130 -0
- package/src/web/server/routes/worktrees.ts +108 -0
- package/src/web/server/services/branches.ts +368 -0
- package/src/web/server/services/worktrees.ts +85 -0
- package/src/web/server/websocket/handler.ts +180 -0
- package/src/worktree.ts +703 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { Select, type SelectItem } from './Select.js';
|
|
4
|
+
|
|
5
|
+
export interface ConfirmProps {
|
|
6
|
+
message: string;
|
|
7
|
+
onConfirm: (confirmed: boolean) => void;
|
|
8
|
+
yesLabel?: string;
|
|
9
|
+
noLabel?: string;
|
|
10
|
+
defaultNo?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Confirm component - Yes/No confirmation dialog
|
|
15
|
+
*/
|
|
16
|
+
export function Confirm({
|
|
17
|
+
message,
|
|
18
|
+
onConfirm,
|
|
19
|
+
yesLabel = 'Yes',
|
|
20
|
+
noLabel = 'No',
|
|
21
|
+
defaultNo = false,
|
|
22
|
+
}: ConfirmProps) {
|
|
23
|
+
const items: SelectItem[] = [
|
|
24
|
+
{ label: yesLabel, value: 'yes' },
|
|
25
|
+
{ label: noLabel, value: 'no' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const handleSelect = (item: SelectItem) => {
|
|
29
|
+
onConfirm(item.value === 'yes');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Box flexDirection="column">
|
|
34
|
+
<Box marginBottom={1}>
|
|
35
|
+
<Text>{message}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
<Select items={items} onSelect={handleSelect} initialIndex={defaultNo ? 1 : 0} />
|
|
38
|
+
</Box>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React, { Component, type ReactNode } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
interface ErrorBoundaryProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
fallback?: React.ComponentType<{ error: Error }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ErrorBoundaryState {
|
|
10
|
+
hasError: boolean;
|
|
11
|
+
error: Error | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Error Boundary component to catch and display errors
|
|
16
|
+
*/
|
|
17
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
18
|
+
constructor(props: ErrorBoundaryProps) {
|
|
19
|
+
super(props);
|
|
20
|
+
this.state = { hasError: false, error: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
24
|
+
return { hasError: true, error };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
|
28
|
+
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
componentDidUpdate(prevProps: ErrorBoundaryProps): void {
|
|
32
|
+
// Reset error state when children change
|
|
33
|
+
if (this.state.hasError && prevProps.children !== this.props.children) {
|
|
34
|
+
this.setState({ hasError: false, error: null });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
render(): ReactNode {
|
|
39
|
+
if (this.state.hasError && this.state.error) {
|
|
40
|
+
const { fallback: Fallback } = this.props;
|
|
41
|
+
|
|
42
|
+
if (Fallback) {
|
|
43
|
+
return <Fallback error={this.state.error} />;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column" padding={1}>
|
|
48
|
+
<Text color="red" bold>
|
|
49
|
+
Error: {this.state.error.message || 'Unknown error'}
|
|
50
|
+
</Text>
|
|
51
|
+
</Box>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.props.children;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
|
|
5
|
+
export interface InputProps {
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
onSubmit: (value: string) => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
label?: string;
|
|
11
|
+
mask?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Input component - wrapper around ink-text-input with optional label
|
|
16
|
+
*/
|
|
17
|
+
export function Input({ value, onChange, onSubmit, placeholder, label, mask }: InputProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Box flexDirection="column">
|
|
20
|
+
{label && (
|
|
21
|
+
<Box marginBottom={0}>
|
|
22
|
+
<Text>{label}</Text>
|
|
23
|
+
</Box>
|
|
24
|
+
)}
|
|
25
|
+
<Box>
|
|
26
|
+
<TextInput
|
|
27
|
+
value={value}
|
|
28
|
+
onChange={onChange}
|
|
29
|
+
onSubmit={onSubmit}
|
|
30
|
+
{...(placeholder !== undefined && { placeholder })}
|
|
31
|
+
{...(mask !== undefined && { mask })}
|
|
32
|
+
/>
|
|
33
|
+
</Box>
|
|
34
|
+
</Box>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
export interface LoadingIndicatorProps {
|
|
5
|
+
/** true にするとローディング表示を開始する */
|
|
6
|
+
isLoading: boolean;
|
|
7
|
+
/** 表示までの遅延時間 (ms)。デフォルトは 300ms */
|
|
8
|
+
delay?: number;
|
|
9
|
+
/** 表示するメッセージ */
|
|
10
|
+
message?: string;
|
|
11
|
+
/** スピナーの更新間隔 (ms)。デフォルトは 80ms */
|
|
12
|
+
interval?: number;
|
|
13
|
+
/** 使用するスピナーフレーム。ASCII のみを想定 */
|
|
14
|
+
frames?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_FRAMES = ['|', '/', '-', '\\'];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ローディング中に簡易スピナーとメッセージを表示するコンポーネント。
|
|
21
|
+
* delay で指定した時間を超えるまでスピナーを表示しないことで、短時間の処理ではちらつきを抑える。
|
|
22
|
+
*/
|
|
23
|
+
export function LoadingIndicator({
|
|
24
|
+
isLoading,
|
|
25
|
+
delay = 300,
|
|
26
|
+
message = 'Loading... please wait',
|
|
27
|
+
interval = 80,
|
|
28
|
+
frames = DEFAULT_FRAMES,
|
|
29
|
+
}: LoadingIndicatorProps) {
|
|
30
|
+
const [visible, setVisible] = useState(false);
|
|
31
|
+
const [frameIndex, setFrameIndex] = useState(0);
|
|
32
|
+
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
33
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
34
|
+
|
|
35
|
+
// スピナーに使用するフレームをキャッシュ
|
|
36
|
+
const safeFrames = useMemo(() => (frames.length > 0 ? frames : DEFAULT_FRAMES), [frames]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// ローディングが開始したら、delay後に表示を有効化
|
|
40
|
+
if (isLoading) {
|
|
41
|
+
delayTimerRef.current = setTimeout(() => {
|
|
42
|
+
setVisible(true);
|
|
43
|
+
}, delay);
|
|
44
|
+
} else {
|
|
45
|
+
setVisible(false);
|
|
46
|
+
setFrameIndex(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
if (delayTimerRef.current) {
|
|
51
|
+
clearTimeout(delayTimerRef.current);
|
|
52
|
+
delayTimerRef.current = null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}, [isLoading, delay]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
// 表示中のみスピナーを回転
|
|
59
|
+
if (visible && isLoading) {
|
|
60
|
+
intervalRef.current = setInterval(() => {
|
|
61
|
+
setFrameIndex((current) => (current + 1) % safeFrames.length);
|
|
62
|
+
}, interval);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
if (intervalRef.current) {
|
|
67
|
+
clearInterval(intervalRef.current);
|
|
68
|
+
intervalRef.current = null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [visible, isLoading, interval, safeFrames.length]);
|
|
72
|
+
|
|
73
|
+
// ローディングが解消されたらタイマーをクリア
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!isLoading && delayTimerRef.current) {
|
|
76
|
+
clearTimeout(delayTimerRef.current);
|
|
77
|
+
delayTimerRef.current = null;
|
|
78
|
+
}
|
|
79
|
+
}, [isLoading]);
|
|
80
|
+
|
|
81
|
+
if (!isLoading || !visible) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Box gap={1}>
|
|
87
|
+
<Text color="yellow" data-testid="loading-indicator-frame">
|
|
88
|
+
{safeFrames[frameIndex]}
|
|
89
|
+
</Text>
|
|
90
|
+
<Text color="yellow" data-testid="loading-indicator-message">
|
|
91
|
+
{message}
|
|
92
|
+
</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useInput, useStdout } from 'ink';
|
|
3
|
+
|
|
4
|
+
export interface SelectItem {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SelectProps<T extends SelectItem = SelectItem> {
|
|
10
|
+
items: T[];
|
|
11
|
+
onSelect: (item: T) => void;
|
|
12
|
+
limit?: number;
|
|
13
|
+
initialIndex?: number;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
renderIndicator?: (item: T, isSelected: boolean) => React.ReactNode;
|
|
16
|
+
renderItem?: (
|
|
17
|
+
item: T,
|
|
18
|
+
isSelected: boolean,
|
|
19
|
+
context: { columns: number }
|
|
20
|
+
) => React.ReactNode;
|
|
21
|
+
// Optional controlled component props for cursor position
|
|
22
|
+
selectedIndex?: number;
|
|
23
|
+
onSelectedIndexChange?: (index: number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom comparison function for React.memo
|
|
28
|
+
* Compares items array by content (value and label) instead of reference
|
|
29
|
+
*/
|
|
30
|
+
function arePropsEqual<T extends SelectItem = SelectItem>(
|
|
31
|
+
prevProps: SelectProps<T>,
|
|
32
|
+
nextProps: SelectProps<T>
|
|
33
|
+
): boolean {
|
|
34
|
+
// Check if non-array props are the same
|
|
35
|
+
if (
|
|
36
|
+
prevProps.limit !== nextProps.limit ||
|
|
37
|
+
prevProps.disabled !== nextProps.disabled ||
|
|
38
|
+
prevProps.initialIndex !== nextProps.initialIndex ||
|
|
39
|
+
prevProps.selectedIndex !== nextProps.selectedIndex ||
|
|
40
|
+
prevProps.onSelect !== nextProps.onSelect ||
|
|
41
|
+
prevProps.onSelectedIndexChange !== nextProps.onSelectedIndexChange ||
|
|
42
|
+
prevProps.renderIndicator !== nextProps.renderIndicator ||
|
|
43
|
+
prevProps.renderItem !== nextProps.renderItem
|
|
44
|
+
) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if items arrays have the same length
|
|
49
|
+
if (prevProps.items.length !== nextProps.items.length) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Compare items by content (value and label)
|
|
54
|
+
for (let i = 0; i < prevProps.items.length; i++) {
|
|
55
|
+
const prevItem = prevProps.items[i];
|
|
56
|
+
const nextItem = nextProps.items[i];
|
|
57
|
+
|
|
58
|
+
if (!prevItem || !nextItem) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (prevItem.value !== nextItem.value || prevItem.label !== nextItem.label) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// All props are equal
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Select component - custom implementation with no looping
|
|
73
|
+
* Cursor stops at top and bottom instead of wrapping around
|
|
74
|
+
* Wrapped with React.memo for performance optimization
|
|
75
|
+
*/
|
|
76
|
+
const SelectComponent = <T extends SelectItem = SelectItem,>({
|
|
77
|
+
items,
|
|
78
|
+
onSelect,
|
|
79
|
+
limit,
|
|
80
|
+
initialIndex = 0,
|
|
81
|
+
disabled = false,
|
|
82
|
+
renderIndicator,
|
|
83
|
+
renderItem,
|
|
84
|
+
selectedIndex: externalSelectedIndex,
|
|
85
|
+
onSelectedIndexChange,
|
|
86
|
+
}: SelectProps<T>) => {
|
|
87
|
+
// Support both controlled and uncontrolled modes
|
|
88
|
+
const [internalSelectedIndex, setInternalSelectedIndex] = useState(initialIndex);
|
|
89
|
+
const [offset, setOffset] = useState(0);
|
|
90
|
+
|
|
91
|
+
// Use external selectedIndex if provided (controlled mode), otherwise use internal state
|
|
92
|
+
const isControlled = externalSelectedIndex !== undefined;
|
|
93
|
+
const selectedIndex = isControlled ? externalSelectedIndex : internalSelectedIndex;
|
|
94
|
+
|
|
95
|
+
const updateSelectedIndex = (value: number | ((prev: number) => number)) => {
|
|
96
|
+
const newIndex = typeof value === 'function' ? value(selectedIndex) : value;
|
|
97
|
+
|
|
98
|
+
if (!isControlled) {
|
|
99
|
+
setInternalSelectedIndex(newIndex);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (onSelectedIndexChange) {
|
|
103
|
+
onSelectedIndexChange(newIndex);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (items.length === 0) {
|
|
109
|
+
updateSelectedIndex(0);
|
|
110
|
+
setOffset(0);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateSelectedIndex((current) => {
|
|
115
|
+
const clamped = Math.min(current, items.length - 1);
|
|
116
|
+
return clamped < 0 ? 0 : clamped;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (limit) {
|
|
120
|
+
setOffset((current) => {
|
|
121
|
+
if (current <= items.length - limit) {
|
|
122
|
+
return current < 0 ? 0 : current;
|
|
123
|
+
}
|
|
124
|
+
const newOffset = Math.max(0, items.length - limit);
|
|
125
|
+
return newOffset;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}, [items, limit]);
|
|
129
|
+
|
|
130
|
+
useInput((input, key) => {
|
|
131
|
+
if (disabled) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Only handle navigation and selection keys
|
|
136
|
+
// Let other keys (q, m, n, c, etc.) propagate to parent components
|
|
137
|
+
if (key.upArrow || input === 'k') {
|
|
138
|
+
// Move up but don't loop - stop at 0
|
|
139
|
+
updateSelectedIndex((current) => {
|
|
140
|
+
const newIndex = Math.max(0, current - 1);
|
|
141
|
+
|
|
142
|
+
// Adjust offset if needed for scrolling
|
|
143
|
+
if (limit && newIndex < offset) {
|
|
144
|
+
setOffset(newIndex);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return newIndex;
|
|
148
|
+
});
|
|
149
|
+
} else if (key.downArrow || input === 'j') {
|
|
150
|
+
// Move down but don't loop - stop at last item
|
|
151
|
+
updateSelectedIndex((current) => {
|
|
152
|
+
const newIndex = Math.min(items.length - 1, current + 1);
|
|
153
|
+
|
|
154
|
+
// Adjust offset if needed for scrolling
|
|
155
|
+
if (limit && newIndex >= offset + limit) {
|
|
156
|
+
setOffset(newIndex - limit + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return newIndex;
|
|
160
|
+
});
|
|
161
|
+
} else if (key.return) {
|
|
162
|
+
// Select current item
|
|
163
|
+
const selectedItem = items[selectedIndex];
|
|
164
|
+
if (selectedItem && !disabled) {
|
|
165
|
+
onSelect(selectedItem);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// All other keys are ignored and will propagate to parent components
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Determine visible items based on limit
|
|
172
|
+
const visibleItems = limit ? items.slice(offset, offset + limit) : items;
|
|
173
|
+
const visibleStartIndex = limit ? offset : 0;
|
|
174
|
+
|
|
175
|
+
const { stdout } = useStdout();
|
|
176
|
+
const columns = stdout?.columns ?? 80;
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<Box flexDirection="column">
|
|
180
|
+
{visibleItems.map((item, index) => {
|
|
181
|
+
const actualIndex = visibleStartIndex + index;
|
|
182
|
+
const isSelected = actualIndex === selectedIndex;
|
|
183
|
+
|
|
184
|
+
if (renderItem) {
|
|
185
|
+
return (
|
|
186
|
+
<Box key={item.value} flexDirection="row">
|
|
187
|
+
{renderItem(item, isSelected, { columns })}
|
|
188
|
+
</Box>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const indicatorElement = renderIndicator
|
|
193
|
+
? renderIndicator(item, isSelected)
|
|
194
|
+
: isSelected
|
|
195
|
+
? <Text color="cyan">›</Text>
|
|
196
|
+
: <Text> </Text>;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<Box key={item.value} flexDirection="row">
|
|
200
|
+
<Box marginRight={1}>{indicatorElement ?? <Text> </Text>}</Box>
|
|
201
|
+
{isSelected ? (
|
|
202
|
+
<Text color="cyan">{item.label}</Text>
|
|
203
|
+
) : (
|
|
204
|
+
<Text>{item.label}</Text>
|
|
205
|
+
)}
|
|
206
|
+
</Box>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</Box>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Export memoized Select component
|
|
215
|
+
*/
|
|
216
|
+
export const Select = React.memo(SelectComponent, arePropsEqual) as typeof SelectComponent;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
export interface FooterAction {
|
|
5
|
+
key: string;
|
|
6
|
+
description: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface FooterProps {
|
|
10
|
+
actions: FooterAction[];
|
|
11
|
+
separator?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Footer component - displays keyboard actions
|
|
16
|
+
* Optimized with React.memo to prevent unnecessary re-renders
|
|
17
|
+
*/
|
|
18
|
+
export const Footer = React.memo(function Footer({
|
|
19
|
+
actions,
|
|
20
|
+
separator = " ",
|
|
21
|
+
}: FooterProps) {
|
|
22
|
+
if (actions.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box>
|
|
28
|
+
{actions.map((action, index) => (
|
|
29
|
+
<Box key={`${action.key}-${index}`}>
|
|
30
|
+
<Text dimColor>[</Text>
|
|
31
|
+
<Text bold color="cyan">
|
|
32
|
+
{action.key}
|
|
33
|
+
</Text>
|
|
34
|
+
<Text dimColor>]</Text>
|
|
35
|
+
<Text> {action.description}</Text>
|
|
36
|
+
{index < actions.length - 1 && <Text dimColor>{separator}</Text>}
|
|
37
|
+
</Box>
|
|
38
|
+
))}
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "ink-testing-library";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { Header } from "./Header.js";
|
|
5
|
+
|
|
6
|
+
describe("Header Component", () => {
|
|
7
|
+
it("正常系: versionプロップありの場合、タイトルとバージョンを表示する", () => {
|
|
8
|
+
const { lastFrame } = render(
|
|
9
|
+
<Header title="gwt" version="1.12.3" />
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const output = lastFrame();
|
|
13
|
+
|
|
14
|
+
// タイトルとバージョンが含まれることを確認
|
|
15
|
+
expect(output).toContain("gwt v1.12.3");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("正常系: versionプロップなし(undefined)の場合、タイトルのみ表示する", () => {
|
|
19
|
+
const { lastFrame } = render(<Header title="gwt" />);
|
|
20
|
+
|
|
21
|
+
const output = lastFrame();
|
|
22
|
+
|
|
23
|
+
// タイトルのみが含まれることを確認
|
|
24
|
+
expect(output).toContain("gwt");
|
|
25
|
+
// "v"が含まれていないことを確認(バージョンが表示されていない)
|
|
26
|
+
expect(output).not.toMatch(/v\d+\.\d+\.\d+/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("正常系: version={null}の場合、タイトルのみ表示する", () => {
|
|
30
|
+
const { lastFrame } = render(
|
|
31
|
+
<Header title="gwt" version={null} />
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const output = lastFrame();
|
|
35
|
+
|
|
36
|
+
// タイトルのみが含まれることを確認
|
|
37
|
+
expect(output).toContain("gwt");
|
|
38
|
+
// "v"が含まれていないことを確認(バージョンが表示されていない)
|
|
39
|
+
expect(output).not.toMatch(/v\d+\.\d+\.\d+/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("正常系: showDivider=trueの場合、区切り線が表示される", () => {
|
|
43
|
+
const { lastFrame } = render(
|
|
44
|
+
<Header
|
|
45
|
+
title="gwt"
|
|
46
|
+
version="1.12.3"
|
|
47
|
+
showDivider={true}
|
|
48
|
+
dividerChar="─"
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const output = lastFrame();
|
|
53
|
+
|
|
54
|
+
// 区切り線が含まれることを確認
|
|
55
|
+
expect(output).toContain("─");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("正常系: showDivider=falseの場合、区切り線が表示されない", () => {
|
|
59
|
+
const { lastFrame } = render(
|
|
60
|
+
<Header
|
|
61
|
+
title="gwt"
|
|
62
|
+
version="1.12.3"
|
|
63
|
+
showDivider={false}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const output = lastFrame();
|
|
68
|
+
|
|
69
|
+
// タイトルとバージョンは含まれる
|
|
70
|
+
expect(output).toContain("gwt v1.12.3");
|
|
71
|
+
// 区切り線が含まれないことを確認(または最小限)
|
|
72
|
+
// 注: Inkのレンダリング結果によっては、完全に区切り線がないとは限らない
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("正常系: プレリリースバージョンも正しく表示される", () => {
|
|
76
|
+
const { lastFrame } = render(
|
|
77
|
+
<Header title="gwt" version="2.0.0-beta.1" />
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const output = lastFrame();
|
|
81
|
+
|
|
82
|
+
// プレリリースバージョンが含まれることを確認
|
|
83
|
+
expect(output).toContain("gwt v2.0.0-beta.1");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
export interface HeaderProps {
|
|
5
|
+
title: string;
|
|
6
|
+
titleColor?: string;
|
|
7
|
+
dividerChar?: string;
|
|
8
|
+
showDivider?: boolean;
|
|
9
|
+
width?: number;
|
|
10
|
+
/**
|
|
11
|
+
* アプリケーションのバージョン文字列
|
|
12
|
+
* - string: バージョンが利用可能(例: "1.12.3")
|
|
13
|
+
* - null: バージョン取得失敗
|
|
14
|
+
* - undefined: バージョン未提供(後方互換性のため)
|
|
15
|
+
* @default undefined
|
|
16
|
+
*/
|
|
17
|
+
version?: string | null | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* 起動時の作業ディレクトリの絶対パス
|
|
20
|
+
* - string: ディレクトリパスが利用可能
|
|
21
|
+
* - undefined: ディレクトリ情報未提供
|
|
22
|
+
* @default undefined
|
|
23
|
+
*/
|
|
24
|
+
workingDirectory?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Header component - displays title and optional divider
|
|
29
|
+
* Optimized with React.memo to prevent unnecessary re-renders
|
|
30
|
+
*/
|
|
31
|
+
export const Header = React.memo(function Header({
|
|
32
|
+
title,
|
|
33
|
+
titleColor = "cyan",
|
|
34
|
+
dividerChar = "─",
|
|
35
|
+
showDivider = true,
|
|
36
|
+
width = 80,
|
|
37
|
+
version,
|
|
38
|
+
workingDirectory,
|
|
39
|
+
}: HeaderProps) {
|
|
40
|
+
const divider = dividerChar.repeat(width);
|
|
41
|
+
const displayTitle = version ? `${title} v${version}` : title;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Box flexDirection="column">
|
|
45
|
+
<Box>
|
|
46
|
+
<Text bold color={titleColor}>
|
|
47
|
+
{displayTitle}
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
{showDivider && (
|
|
51
|
+
<Box>
|
|
52
|
+
<Text dimColor>{divider}</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
)}
|
|
55
|
+
{workingDirectory && (
|
|
56
|
+
<Box>
|
|
57
|
+
<Text dimColor>Working Directory: </Text>
|
|
58
|
+
<Text>{workingDirectory}</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
)}
|
|
61
|
+
</Box>
|
|
62
|
+
);
|
|
63
|
+
});
|