mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-26 01:34:21 +01:00
feat: add @ file completion
This commit is contained in:
@@ -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";
|
||||
@@ -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
|
||||
|
||||
40
src/hooks/useFileCompletion.ts
Normal file
40
src/hooks/useFileCompletion.ts
Normal 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分間キャッシュ
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
96
src/server/service/file-completion/getFileCompletion.ts
Normal file
96
src/server/service/file-completion/getFileCompletion.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user