feat: add @ file completion

This commit is contained in:
d-kimsuon
2025-09-07 00:05:07 +09:00
parent d0fdadeefa
commit 60aaae7a2c
5 changed files with 510 additions and 4 deletions

View File

@@ -0,0 +1,319 @@
import { CheckIcon, FileIcon, FolderIcon } 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 {
type FileCompletionEntry,
useFileCompletion,
} from "../../../../../hooks/useFileCompletion";
import { cn } from "../../../../../lib/utils";
type FileCompletionProps = {
projectId: string;
inputValue: string;
onFileSelect: (filePath: string) => void;
className?: string;
};
export type FileCompletionRef = {
handleKeyDown: (e: React.KeyboardEvent) => boolean;
};
// Parse the @ completion from input value
const parseFileCompletionFromInput = (input: string) => {
// Find the last @ symbol
const lastAtIndex = input.lastIndexOf("@");
if (lastAtIndex === -1) {
return { shouldShow: false, searchPath: "", beforeAt: "", afterAt: "" };
}
// Get the text before and after @
const beforeAt = input.slice(0, lastAtIndex);
const afterAt = input.slice(lastAtIndex + 1);
// Check if we're in the middle of a word after @ (no space after the path)
const parts = afterAt.split(/\s/);
const searchPath = parts[0] || "";
// Don't show completion if there's a space after the path (user has finished typing the path)
// This includes cases like "@hoge " where parts = ["hoge", ""]
const hasSpaceAfterPath = parts.length > 1;
return {
shouldShow: !hasSpaceAfterPath,
searchPath,
beforeAt,
afterAt,
};
};
export const FileCompletion = forwardRef<
FileCompletionRef,
FileCompletionProps
>(({ projectId, inputValue, onFileSelect, className }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Parse the input to extract the path being completed
const { shouldShow, searchPath, beforeAt, afterAt } = useMemo(
() => parseFileCompletionFromInput(inputValue),
[inputValue],
);
// Determine the base path and filter term
const { basePath, filterTerm } = useMemo(() => {
if (!searchPath) {
return { basePath: "/", filterTerm: "" };
}
const lastSlashIndex = searchPath.lastIndexOf("/");
if (lastSlashIndex === -1) {
return { basePath: "/", filterTerm: searchPath };
}
const path = searchPath.slice(0, lastSlashIndex + 1);
const term = searchPath.slice(lastSlashIndex + 1);
return {
basePath: path === "/" ? "/" : path,
filterTerm: term,
};
}, [searchPath]);
// Fetch file completion data
const { data: completionData, isLoading } = useFileCompletion(
projectId,
basePath,
shouldShow,
);
// Filter entries based on the current filter term
const filteredEntries = useMemo(() => {
if (!completionData?.entries) return [];
if (!filterTerm) {
return completionData.entries;
}
return completionData.entries.filter((entry) =>
entry.name.toLowerCase().includes(filterTerm.toLowerCase()),
);
}, [completionData?.entries, filterTerm]);
// Determine if completion should be shown
const shouldBeOpen = shouldShow && !isLoading && filteredEntries.length > 0;
// Update open state when it should change
if (isOpen !== shouldBeOpen) {
setIsOpen(shouldBeOpen);
setSelectedIndex(-1);
}
// Handle file/directory selection with different behaviors for different triggers
const handleEntrySelect = useCallback(
(entry: FileCompletionEntry, forceClose = false) => {
const fullPath = entry.path;
// For directories, add a trailing slash to continue completion (unless forced to close)
const pathToInsert =
entry.type === "directory" && !forceClose ? `${fullPath}/` : fullPath;
// Reconstruct the message with the selected path
const newMessage = `${beforeAt}@${pathToInsert}${afterAt.split(/\s/).slice(1).join(" ")}`;
onFileSelect(newMessage.trim());
// Close completion if it's a file, or if forced to close
if (entry.type === "file" || forceClose) {
setIsOpen(false);
setSelectedIndex(-1);
}
},
[beforeAt, afterAt, onFileSelect],
);
// Scroll to selected entry
const scrollToSelected = useCallback(() => {
if (selectedIndex >= 0 && listRef.current) {
const selectedElement = listRef.current.children[
selectedIndex + 1
] as HTMLElement; // +1 for header
if (selectedElement) {
selectedElement.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
}, [selectedIndex]);
// Keyboard navigation
const handleKeyboardNavigation = useCallback(
(e: React.KeyboardEvent): boolean => {
if (!isOpen || filteredEntries.length === 0) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0;
setTimeout(scrollToSelected, 0);
return newIndex;
});
return true;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1;
setTimeout(scrollToSelected, 0);
return newIndex;
});
return true;
case "Enter":
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
e.preventDefault();
const selectedEntry = filteredEntries[selectedIndex];
if (selectedEntry) {
// Enter always closes completion (even for directories)
handleEntrySelect(selectedEntry, true);
}
return true;
}
break;
case "Tab":
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
e.preventDefault();
const selectedEntry = filteredEntries[selectedIndex];
if (selectedEntry) {
// Tab: continue completion for directories, close for files
handleEntrySelect(selectedEntry, selectedEntry.type === "file");
}
return true;
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
return true;
}
return false;
},
[
isOpen,
filteredEntries.length,
selectedIndex,
handleEntrySelect,
scrollToSelected,
filteredEntries,
],
);
// Handle clicks outside the component
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);
}, []);
// Expose keyboard handler to parent
useImperativeHandle(
ref,
() => ({
handleKeyDown: handleKeyboardNavigation,
}),
[handleKeyboardNavigation],
);
if (!shouldShow || isLoading || filteredEntries.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 files and directories"
>
{filteredEntries.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"
>
<FileIcon className="w-3 h-3" />
Files & Directories ({filteredEntries.length})
{basePath !== "/" && (
<span className="text-xs font-mono text-muted-foreground/70">
in {basePath}
</span>
)}
</div>
{filteredEntries.map((entry, index) => (
<Button
key={entry.path}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
>
{entry.type === "directory" ? (
<FolderIcon className="w-3 h-3 mr-2 text-blue-500" />
) : (
<FileIcon className="w-3 h-3 mr-2 text-gray-500" />
)}
<span className="font-medium">{entry.name}</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1">/</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
)}
</Button>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
});
FileCompletion.displayName = "FileCompletion";

View File

@@ -9,6 +9,7 @@ import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
export const NewChat: FC<{
projectId: string;
@@ -51,6 +52,7 @@ export const NewChat: FC<{
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
@@ -59,7 +61,12 @@ export const NewChat: FC<{
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
// まずファイル補完のキーボードイベントを処理
if (fileCompletionRef.current?.handleKeyDown(e)) {
return;
}
// 次にコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
}
@@ -76,6 +83,11 @@ export const NewChat: FC<{
textareaRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
setMessage(filePath);
textareaRef.current?.focus();
};
return (
<div className="space-y-4">
{startNewChat.error && (
@@ -92,13 +104,13 @@ export const NewChat: FC<{
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)"
placeholder="Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)"
className="min-h-[100px] resize-none"
disabled={startNewChat.isPending}
maxLength={4000}
aria-label="Message input with command completion"
aria-describedby={helpId}
aria-expanded={message.startsWith("/")}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
@@ -110,12 +122,19 @@ export const NewChat: FC<{
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className="absolute top-full left-0 right-0"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000 characters Use arrow keys to navigate
commands
completions
</span>
<Button

View File

@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import { honoClient } from "../lib/api/client";
export type FileCompletionEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type FileCompletionResult = {
entries: FileCompletionEntry[];
basePath: string;
projectPath: string;
};
export const useFileCompletion = (
projectId: string,
basePath: string,
enabled = true,
) => {
return useQuery({
queryKey: ["file-completion", projectId, basePath],
queryFn: async (): Promise<FileCompletionResult> => {
const response = await honoClient.api.projects[":projectId"][
"file-completion"
].$get({
param: { projectId },
query: { basePath },
});
if (!response.ok) {
throw new Error("Failed to fetch file completion");
}
return response.json();
},
enabled: enabled && !!projectId,
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
});
};

View File

@@ -11,6 +11,7 @@ import type { SerializableAliveTask } from "../service/claude-code/types";
import { getEventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEventResponse } from "../service/events/sseEventResponse";
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getMcpList } from "../service/mcp/getMcpList";
import { getProject } from "../service/project/getProject";
import { getProjects } from "../service/project/getProjects";
@@ -135,6 +136,37 @@ export const routes = (app: HonoAppType) => {
return c.json({ session });
})
.get(
"/projects/:projectId/file-completion",
zValidator(
"query",
z.object({
basePath: z.string().optional().default("/"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { basePath } = c.req.valid("query");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getFileCompletion(
project.meta.projectPath,
basePath,
);
return c.json(result);
} catch (error) {
console.error("File completion error:", error);
return c.json({ error: "Failed to get file completion" }, 500);
}
},
)
.get("/projects/:projectId/claude-commands", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);

View File

@@ -0,0 +1,96 @@
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
export type FileCompletionEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type FileCompletionResult = {
entries: FileCompletionEntry[];
basePath: string;
projectPath: string;
};
/**
* Get file and directory completions for a given project path
* @param projectPath - The root project path
* @param basePath - The relative path from project root (default: "/")
* @returns File and directory entries at the specified path level
*/
export const getFileCompletion = async (
projectPath: string,
basePath = "/",
): Promise<FileCompletionResult> => {
// Normalize basePath to prevent directory traversal
const normalizedBasePath = basePath.startsWith("/")
? basePath.slice(1)
: basePath;
const targetPath = resolve(projectPath, normalizedBasePath);
// Security check: ensure target path is within project directory
if (!targetPath.startsWith(resolve(projectPath))) {
throw new Error("Invalid path: outside project directory");
}
// Check if the target path exists
if (!existsSync(targetPath)) {
return {
entries: [],
basePath: normalizedBasePath,
projectPath,
};
}
try {
const dirents = await readdir(targetPath, { withFileTypes: true });
const entries: FileCompletionEntry[] = [];
// Process each directory entry
for (const dirent of dirents) {
// Skip hidden files and directories (starting with .)
if (dirent.name.startsWith(".")) {
continue;
}
const entryPath = join(normalizedBasePath, dirent.name);
if (dirent.isDirectory()) {
entries.push({
name: dirent.name,
type: "directory",
path: entryPath,
});
} else if (dirent.isFile()) {
entries.push({
name: dirent.name,
type: "file",
path: entryPath,
});
}
}
// Sort entries: directories first, then files, both alphabetically
entries.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
entries,
basePath: normalizedBasePath,
projectPath,
};
} catch (error) {
console.error("Error reading directory:", error);
return {
entries: [],
basePath: normalizedBasePath,
projectPath,
};
}
};