diff --git a/src/components/chat/file-mention-menu.tsx b/src/components/chat/file-mention-menu.tsx new file mode 100644 index 0000000..0d3102c --- /dev/null +++ b/src/components/chat/file-mention-menu.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useEffect, useRef } from "react" +import { File, Folder } from "lucide-react" +import { cn } from "@/lib/utils" +import type { FlatFileEntry } from "@/hooks/use-file-tree" + +interface FileMentionMenuProps { + files: FlatFileEntry[] + selectedIndex: number + onSelect: (entry: FlatFileEntry) => void +} + +export function FileMentionMenu({ + files, + selectedIndex, + onSelect, +}: FileMentionMenuProps) { + const listRef = useRef(null) + + useEffect(() => { + const el = listRef.current?.children[selectedIndex] as + | HTMLElement + | undefined + el?.scrollIntoView({ block: "nearest" }) + }, [selectedIndex]) + + if (files.length === 0) return null + + return ( +
+ {files.map((entry, i) => ( + + ))} +
+ ) +} diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 1e66e40..35b8f55 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -53,6 +53,9 @@ import { import { ModeSelector } from "@/components/chat/mode-selector" import { SessionConfigSelector } from "@/components/chat/session-config-selector" import { SlashCommandMenu } from "@/components/chat/slash-command-menu" +import { FileMentionMenu } from "@/components/chat/file-mention-menu" +import { useFileTree } from "@/hooks/use-file-tree" +import { joinFsPath } from "@/lib/path-utils" import { clearMessageInputDraft, loadMessageInputDraft, @@ -433,6 +436,37 @@ export function MessageInput({ ) }, [slashMenuOpen, slashCommands, text]) + // ── @ file mention autocomplete ── + const [atMenuOpen, setAtMenuOpen] = useState(false) + const [atSelectedIndex, setAtSelectedIndex] = useState(0) + const [atTriggerPos, setAtTriggerPos] = useState(null) + const [atFileTreeEnabled, setAtFileTreeEnabled] = useState(false) + + const { allFiles: atAllFiles } = useFileTree({ + folderPath: defaultPath, + enabled: atFileTreeEnabled, + }) + + const filteredAtFiles = useMemo(() => { + if (!atMenuOpen || atTriggerPos == null) return [] + // Extract the query after "@" up to the next space or end of text + const afterAt = text.slice(atTriggerPos + 1) + const spaceIdx = afterAt.indexOf(" ") + const filter = + spaceIdx === -1 + ? afterAt.toLowerCase() + : afterAt.slice(0, spaceIdx).toLowerCase() + if (!filter) return atAllFiles.slice(0, 50) + const matched: typeof atAllFiles = [] + for (const f of atAllFiles) { + if (f.lowerName.includes(filter) || f.lowerPath.includes(filter)) { + matched.push(f) + if (matched.length >= 50) break + } + } + return matched + }, [atMenuOpen, atTriggerPos, text, atAllFiles]) + const appendResourceLinks = useCallback( ( links: Array<{ @@ -744,18 +778,69 @@ export function MessageInput({ setSlashMenuOpen(false) }, []) + const atTriggerPosRef = useRef(atTriggerPos) + useEffect(() => { + atTriggerPosRef.current = atTriggerPos + }, [atTriggerPos]) + + const handleAtSelect = useCallback( + (entry: { relativePath: string }) => { + const pos = atTriggerPosRef.current + if (!defaultPath || pos == null) return + + // Remove the @... token from text + const current = textRef.current + const beforeAt = current.slice(0, pos) + const afterAt = current.slice(pos) + const spaceIdx = afterAt.indexOf(" ", 1) + const afterToken = spaceIdx === -1 ? "" : afterAt.slice(spaceIdx) + setText(beforeAt + afterToken) + + // Attach the file + const absPath = joinFsPath(defaultPath, entry.relativePath) + appendResourceAttachments([absPath]) + + setAtMenuOpen(false) + setAtTriggerPos(null) + + requestAnimationFrame(() => textareaRef.current?.focus()) + }, + [defaultPath, appendResourceAttachments] + ) + const handleTextChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value setText(value) + + // Slash command detection (only at start of input) if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) { setSlashSelectedIndex(0) setSlashMenuOpen(true) + setAtMenuOpen(false) + return } else { setSlashMenuOpen(false) } + + // @ file mention detection (at any cursor position) + const cursorPos = e.target.selectionStart + if (cursorPos != null && defaultPath) { + const beforeCursor = value.slice(0, cursorPos) + const atMatch = beforeCursor.match(/(^|[\s])@([^\s]*)$/) + if (atMatch) { + const atPos = + beforeCursor.length - atMatch[0].length + atMatch[1].length + setAtTriggerPos(atPos) + setAtSelectedIndex(0) + setAtMenuOpen(true) + setAtFileTreeEnabled(true) + return + } + } + setAtMenuOpen(false) }, - [slashCommands.length] + [slashCommands.length, defaultPath] ) const handlePickFiles = useCallback(async () => { @@ -1064,6 +1149,33 @@ export function MessageInput({ } } + if (atMenuOpen && filteredAtFiles.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault() + setAtSelectedIndex((i) => + i < filteredAtFiles.length - 1 ? i + 1 : 0 + ) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + setAtSelectedIndex((i) => + i > 0 ? i - 1 : filteredAtFiles.length - 1 + ) + return + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault() + handleAtSelect(filteredAtFiles[atSelectedIndex]) + return + } + if (e.key === "Escape") { + e.preventDefault() + setAtMenuOpen(false) + return + } + } + if (isEditingQueueItem && e.key === "Escape") { e.preventDefault() onCancelQueueEdit?.() @@ -1097,6 +1209,10 @@ export function MessageInput({ filteredSlashCommands, slashSelectedIndex, handleSlashSelect, + atMenuOpen, + filteredAtFiles, + atSelectedIndex, + handleAtSelect, ] ) @@ -1270,6 +1386,13 @@ export function MessageInput({ onSelect={handleSlashSelect} /> )} + {atMenuOpen && filteredAtFiles.length > 0 && ( + + )}
): boolean { - let idx = path.indexOf("/") - while (idx !== -1) { - if (ignoredDirs.has(path.slice(0, idx))) return true - idx = path.indexOf("/", idx + 1) - } - return false -} - interface SearchCommandDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -103,98 +55,23 @@ export function SearchCommandDialog({ const [searching, setSearching] = useState(false) const debounceRef = useRef>(undefined) - // File search state - const [allFiles, setAllFiles] = useState([]) - const [filesLoading, setFilesLoading] = useState(false) - const filesLoadedRef = useRef(false) - const folderPath = folder?.path ?? "" + // File search via shared hook (lazy-loaded when files tab is active) + const { + allFiles, + loading: filesLoading, + reset: resetFileTree, + } = useFileTree({ + folderPath: folderPath || undefined, + enabled: activeTab === "files", + }) + // Compute which agent types exist in current folder const availableAgents = Array.from( new Set(conversations.map((c) => c.agent_type)) ).sort(compareAgentType) - // Load file tree when switching to files tab, filtering by .gitignore - useEffect(() => { - if (activeTab !== "files" || !folderPath || filesLoadedRef.current) return - let canceled = false - setFilesLoading(true) - - async function load() { - try { - const tree = await getFileTree(folderPath, 10) - const flat = flattenTree(tree) - - // Collect all .gitignore files from the tree - const gitignoreEntries = flat.filter( - (f) => f.kind === "file" && f.name === ".gitignore" - ) - - // Build matchers keyed by directory prefix - const matchers: { prefix: string; matcher: ReturnType }[] = - [] - await Promise.all( - gitignoreEntries.map(async (entry) => { - try { - const result = await readFilePreview( - folderPath, - entry.relativePath - ) - const lastSlash = entry.relativePath.lastIndexOf("/") - const dir = - lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash) - matchers.push({ - prefix: dir ? dir + "/" : "", - matcher: ig().add(result.content), - }) - } catch { - // skip unreadable .gitignore - } - }) - ) - - // Sort matchers by prefix length (shortest/root first) so that - // parent rules are evaluated before child rules. - matchers.sort((a, b) => a.prefix.length - b.prefix.length) - - // Filter: check each entry against all applicable .gitignore matchers - const ignoredDirs = new Set() - const filtered = flat.filter((f) => { - // Skip .gitignore files themselves from results - if (f.name === ".gitignore") return false - // If an ancestor directory is already ignored, skip — O(depth) lookup - if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false - for (const { prefix, matcher } of matchers) { - if (!f.relativePath.startsWith(prefix)) continue - const relPath = f.relativePath.slice(prefix.length) - if (!relPath) continue - const testPath = f.kind === "dir" ? `${relPath}/` : relPath - if (matcher.ignores(testPath)) { - if (f.kind === "dir") ignoredDirs.add(f.relativePath) - return false - } - } - return true - }) - - if (!canceled) { - setAllFiles(filtered) - filesLoadedRef.current = true - } - } catch { - if (!canceled) setAllFiles([]) - } finally { - if (!canceled) setFilesLoading(false) - } - } - - void load() - return () => { - canceled = true - } - }, [activeTab, folderPath]) - // Filter files by query using pre-computed lowercase fields const filteredFiles = useMemo(() => { const trimmed = query.trim() @@ -253,10 +130,9 @@ export function SearchCommandDialog({ setAgentFilter(null) setResults([]) setActiveTab("conversations") - filesLoadedRef.current = false - setAllFiles([]) + resetFileTree() } - }, [open]) + }, [open, resetFileTree]) const handleSelectConversation = useCallback( (conv: DbConversationSummary) => { diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index 7e3b8cf..9e7c155 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -36,10 +36,7 @@ import { startFileTreeWatch, stopFileTreeWatch, } from "@/lib/api" -import { - emitAttachFileToSession, - emitAppendTextToSession, -} from "@/lib/session-attachment-events" +import { emitAttachFileToSession } from "@/lib/session-attachment-events" import type { FileTreeChangedEvent, FileTreeNode, @@ -86,16 +83,7 @@ import { CollapsibleTrigger, } from "@/components/ui/collapsible" import { Skeleton } from "@/components/ui/skeleton" - -function joinFsPath(basePath: string, relPath: string): string { - if (!relPath) return basePath - const separator = basePath.includes("\\") ? "\\" : "/" - const normalizedRel = relPath.replace(/[\\/]/g, separator) - if (basePath.endsWith("/") || basePath.endsWith("\\")) { - return `${basePath}${normalizedRel}` - } - return `${basePath}${separator}${normalizedRel}` -} +import { joinFsPath } from "@/lib/path-utils" function parentDir(filePath: string): string { const slashIndex = filePath.lastIndexOf("/") @@ -618,12 +606,9 @@ function RenderNode({ const handleAttachDirToSession = () => { if (!activeSessionTabId) return - const relativePath = node.path.endsWith("/") - ? `@${node.path} ` - : `@${node.path}/ ` - emitAppendTextToSession({ + emitAttachFileToSession({ tabId: activeSessionTabId, - text: relativePath, + path: absolutePath, }) } diff --git a/src/hooks/use-file-tree.ts b/src/hooks/use-file-tree.ts new file mode 100644 index 0000000..8689d72 --- /dev/null +++ b/src/hooks/use-file-tree.ts @@ -0,0 +1,166 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import ig from "ignore" +import { getFileTree, readFilePreview } from "@/lib/api" +import type { FileTreeNode } from "@/lib/types" + +export interface FlatFileEntry { + name: string + /** Relative path from folder root (same as FileTreeNode.path) */ + relativePath: string + kind: "file" | "dir" + /** Pre-computed lowercase relativePath for filtering */ + lowerPath: string + /** Pre-computed lowercase name for filtering */ + lowerName: string +} + +export function flattenTree(nodes: FileTreeNode[]): FlatFileEntry[] { + const entries: FlatFileEntry[] = [] + function walk(node: FileTreeNode) { + entries.push({ + name: node.name, + relativePath: node.path, + kind: node.kind, + lowerPath: node.path.toLowerCase(), + lowerName: node.name.toLowerCase(), + }) + if (node.kind === "dir" && node.children) { + for (const child of node.children) { + walk(child) + } + } + } + for (const node of nodes) { + walk(node) + } + return entries +} + +/** Check whether any ancestor directory of `path` is in `ignoredDirs`. */ +export function hasIgnoredAncestor( + path: string, + ignoredDirs: Set +): boolean { + let idx = path.indexOf("/") + while (idx !== -1) { + if (ignoredDirs.has(path.slice(0, idx))) return true + idx = path.indexOf("/", idx + 1) + } + return false +} + +interface UseFileTreeOptions { + folderPath: string | undefined + enabled: boolean +} + +interface UseFileTreeResult { + allFiles: FlatFileEntry[] + loading: boolean + loaded: boolean + /** Clear cached data so the next `enabled=true` triggers a fresh load. */ + reset: () => void +} + +export function useFileTree({ + folderPath, + enabled, +}: UseFileTreeOptions): UseFileTreeResult { + const [allFiles, setAllFiles] = useState([]) + const [loading, setLoading] = useState(false) + const loadedForPathRef = useRef(null) + + useEffect(() => { + if (!enabled || !folderPath) return + if (loadedForPathRef.current === folderPath) return + + let canceled = false + setLoading(true) + + async function load() { + try { + const tree = await getFileTree(folderPath!, 10) + const flat = flattenTree(tree) + + // Collect all .gitignore files from the tree + const gitignoreEntries = flat.filter( + (f) => f.kind === "file" && f.name === ".gitignore" + ) + + // Build matchers keyed by directory prefix + const matchers: { + prefix: string + matcher: ReturnType + }[] = [] + await Promise.all( + gitignoreEntries.map(async (entry) => { + try { + const result = await readFilePreview( + folderPath!, + entry.relativePath + ) + const lastSlash = entry.relativePath.lastIndexOf("/") + const dir = + lastSlash === -1 ? "" : entry.relativePath.slice(0, lastSlash) + matchers.push({ + prefix: dir ? dir + "/" : "", + matcher: ig().add(result.content), + }) + } catch { + // skip unreadable .gitignore + } + }) + ) + + // Sort matchers by prefix length (shortest/root first) + matchers.sort((a, b) => a.prefix.length - b.prefix.length) + + // Filter: check each entry against all applicable .gitignore matchers + const ignoredDirs = new Set() + const filtered = flat.filter((f) => { + if (f.name === ".gitignore") return false + if (hasIgnoredAncestor(f.relativePath, ignoredDirs)) return false + for (const { prefix, matcher } of matchers) { + if (!f.relativePath.startsWith(prefix)) continue + const relPath = f.relativePath.slice(prefix.length) + if (!relPath) continue + const testPath = f.kind === "dir" ? `${relPath}/` : relPath + if (matcher.ignores(testPath)) { + if (f.kind === "dir") ignoredDirs.add(f.relativePath) + return false + } + } + return true + }) + + if (!canceled) { + setAllFiles(filtered) + loadedForPathRef.current = folderPath! + } + } catch { + if (!canceled) setAllFiles([]) + } finally { + if (!canceled) setLoading(false) + } + } + + void load() + return () => { + canceled = true + } + }, [enabled, folderPath]) + + const reset = useCallback(() => { + loadedForPathRef.current = null + setAllFiles([]) + }, []) + + return { + allFiles, + loading, + loaded: loadedForPathRef.current === folderPath, + reset, + } +} diff --git a/src/lib/path-utils.ts b/src/lib/path-utils.ts new file mode 100644 index 0000000..3d5f5e9 --- /dev/null +++ b/src/lib/path-utils.ts @@ -0,0 +1,9 @@ +export function joinFsPath(basePath: string, relPath: string): string { + if (!relPath) return basePath + const separator = basePath.includes("\\") ? "\\" : "/" + const normalizedRel = relPath.replace(/[\\/]/g, separator) + if (basePath.endsWith("/") || basePath.endsWith("\\")) { + return `${basePath}${normalizedRel}` + } + return `${basePath}${separator}${normalizedRel}` +}