mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-19 13:34:21 +01:00
237 lines
7.6 KiB
TypeScript
237 lines
7.6 KiB
TypeScript
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>
|
||
);
|
||
});
|