Files
claude-code-viewer/src/app/projects/[projectId]/components/chatForm/CommandCompletion.tsx
2025-09-18 21:08:15 +09:00

237 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useQuery } from "@tanstack/react-query";
import { CheckIcon, TerminalIcon } from "lucide-react";
import type React from "react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "../../../../../components/ui/button";
import {
Collapsible,
CollapsibleContent,
} from "../../../../../components/ui/collapsible";
import { claudeCommandsQuery } from "../../../../../lib/api/queries";
import { cn } from "../../../../../lib/utils";
type CommandCompletionProps = {
projectId: string;
inputValue: string;
onCommandSelect: (command: string) => void;
className?: string;
};
export type CommandCompletionRef = {
handleKeyDown: (e: React.KeyboardEvent) => boolean;
};
export const CommandCompletion = forwardRef<
CommandCompletionRef,
CommandCompletionProps
>(({ projectId, inputValue, onCommandSelect, className }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// コマンドリストを取得
const { data: commandData } = useQuery({
queryKey: claudeCommandsQuery(projectId).queryKey,
queryFn: claudeCommandsQuery(projectId).queryFn,
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
});
// メモ化されたコマンドフィルタリング
const { shouldShowCompletion, filteredCommands } = useMemo(() => {
const allCommands = [
...(commandData?.defaultCommands || []),
...(commandData?.globalCommands || []),
...(commandData?.projectCommands || []),
];
const shouldShow = inputValue.startsWith("/");
const searchTerm = shouldShow ? inputValue.slice(1).toLowerCase() : "";
const filtered = shouldShow
? allCommands.filter((cmd) => cmd.toLowerCase().includes(searchTerm))
: [];
return { shouldShowCompletion: shouldShow, filteredCommands: filtered };
}, [commandData, inputValue]);
// 表示状態の導出useEffectを削除
const shouldBeOpen = shouldShowCompletion && filteredCommands.length > 0;
// 状態が変更された時のリセット処理
if (isOpen !== shouldBeOpen) {
setIsOpen(shouldBeOpen);
setSelectedIndex(-1);
}
// メモ化されたコマンド選択処理
const handleCommandSelect = useCallback(
(command: string) => {
onCommandSelect(`/${command} `);
setIsOpen(false);
setSelectedIndex(-1);
},
[onCommandSelect],
);
// スクロール処理
const scrollToSelected = useCallback((index: number) => {
if (index >= 0 && listRef.current) {
// ボタン要素を直接検索
const buttons = listRef.current.querySelectorAll('button[role="option"]');
const selectedButton = buttons[index] as HTMLElement;
if (selectedButton) {
selectedButton.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, []);
// メモ化されたキーボードナビゲーション処理
const handleKeyboardNavigation = useCallback(
(e: React.KeyboardEvent): boolean => {
if (!isOpen || filteredCommands.length === 0) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredCommands.length - 1 ? prev + 1 : 0;
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredCommands.length - 1;
// スクロールを次のフレームで実行
requestAnimationFrame(() => scrollToSelected(newIndex));
return newIndex;
});
return true;
case "Enter":
case "Tab":
if (selectedIndex >= 0 && selectedIndex < filteredCommands.length) {
e.preventDefault();
const selectedCommand = filteredCommands[selectedIndex];
if (selectedCommand) {
handleCommandSelect(selectedCommand);
}
return true;
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
return true;
}
return false;
},
[
isOpen,
filteredCommands.length,
selectedIndex,
handleCommandSelect,
scrollToSelected,
filteredCommands,
],
);
// 外部クリック処理をuseEffectで設定
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSelectedIndex(-1);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// useImperativeHandleでキーボードハンドラーを公開
useImperativeHandle(
ref,
() => ({
handleKeyDown: handleKeyboardNavigation,
}),
[handleKeyboardNavigation],
);
if (!shouldShowCompletion || filteredCommands.length === 0) {
return null;
}
return (
<div ref={containerRef} className={cn("relative", className)}>
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleContent>
<div
ref={listRef}
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
role="listbox"
aria-label="Available commands"
>
{filteredCommands.length > 0 && (
<div className="p-1">
<div
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
role="presentation"
>
<TerminalIcon className="w-3 h-3" />
Available Commands ({filteredCommands.length})
</div>
{filteredCommands.map((command, index) => (
<Button
key={command}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
>
<span className="text-muted-foreground mr-1 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
});