feat: add @ file mention support in chat input and unify directory attachment
Support typing "@" in the chat input to trigger a file list popup with filtering, keyboard navigation, and file/directory attachment. Directories from the sidebar file tree now attach as resource links instead of injecting text into the input. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
62
src/components/chat/file-mention-menu.tsx
Normal file
62
src/components/chat/file-mention-menu.tsx
Normal file
@@ -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<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = listRef.current?.children[selectedIndex] as
|
||||||
|
| HTMLElement
|
||||||
|
| undefined
|
||||||
|
el?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, [selectedIndex])
|
||||||
|
|
||||||
|
if (files.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="absolute bottom-full left-0 right-0 z-50 mb-1 max-h-48 overflow-y-auto rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||||
|
>
|
||||||
|
{files.map((entry, i) => (
|
||||||
|
<button
|
||||||
|
key={entry.relativePath}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-sm",
|
||||||
|
i === selectedIndex
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSelect(entry)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.kind === "dir" ? (
|
||||||
|
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="truncate font-mono text-xs">
|
||||||
|
{entry.relativePath}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
import { ModeSelector } from "@/components/chat/mode-selector"
|
import { ModeSelector } from "@/components/chat/mode-selector"
|
||||||
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
import { SessionConfigSelector } from "@/components/chat/session-config-selector"
|
||||||
import { SlashCommandMenu } from "@/components/chat/slash-command-menu"
|
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 {
|
import {
|
||||||
clearMessageInputDraft,
|
clearMessageInputDraft,
|
||||||
loadMessageInputDraft,
|
loadMessageInputDraft,
|
||||||
@@ -433,6 +436,37 @@ export function MessageInput({
|
|||||||
)
|
)
|
||||||
}, [slashMenuOpen, slashCommands, text])
|
}, [slashMenuOpen, slashCommands, text])
|
||||||
|
|
||||||
|
// ── @ file mention autocomplete ──
|
||||||
|
const [atMenuOpen, setAtMenuOpen] = useState(false)
|
||||||
|
const [atSelectedIndex, setAtSelectedIndex] = useState(0)
|
||||||
|
const [atTriggerPos, setAtTriggerPos] = useState<number | null>(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(
|
const appendResourceLinks = useCallback(
|
||||||
(
|
(
|
||||||
links: Array<{
|
links: Array<{
|
||||||
@@ -744,18 +778,69 @@ export function MessageInput({
|
|||||||
setSlashMenuOpen(false)
|
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(
|
const handleTextChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setText(value)
|
setText(value)
|
||||||
|
|
||||||
|
// Slash command detection (only at start of input)
|
||||||
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
|
if (slashCommands.length > 0 && /^\/(\S*)$/.test(value)) {
|
||||||
setSlashSelectedIndex(0)
|
setSlashSelectedIndex(0)
|
||||||
setSlashMenuOpen(true)
|
setSlashMenuOpen(true)
|
||||||
|
setAtMenuOpen(false)
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
setSlashMenuOpen(false)
|
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 () => {
|
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") {
|
if (isEditingQueueItem && e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onCancelQueueEdit?.()
|
onCancelQueueEdit?.()
|
||||||
@@ -1097,6 +1209,10 @@ export function MessageInput({
|
|||||||
filteredSlashCommands,
|
filteredSlashCommands,
|
||||||
slashSelectedIndex,
|
slashSelectedIndex,
|
||||||
handleSlashSelect,
|
handleSlashSelect,
|
||||||
|
atMenuOpen,
|
||||||
|
filteredAtFiles,
|
||||||
|
atSelectedIndex,
|
||||||
|
handleAtSelect,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1270,6 +1386,13 @@ export function MessageInput({
|
|||||||
onSelect={handleSlashSelect}
|
onSelect={handleSlashSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{atMenuOpen && filteredAtFiles.length > 0 && (
|
||||||
|
<FileMentionMenu
|
||||||
|
files={filteredAtFiles}
|
||||||
|
selectedIndex={atSelectedIndex}
|
||||||
|
onSelect={handleAtSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col rounded-xl border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
|
"flex flex-col rounded-xl border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
|
||||||
|
|||||||
@@ -4,23 +4,18 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
|||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||||
import { File, Folder } from "lucide-react"
|
import { File, Folder } from "lucide-react"
|
||||||
import ig from "ignore"
|
|
||||||
import { useLocale, useTranslations } from "next-intl"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||||
import {
|
import { listFolderConversations } from "@/lib/api"
|
||||||
getFileTree,
|
|
||||||
listFolderConversations,
|
|
||||||
readFilePreview,
|
|
||||||
} from "@/lib/api"
|
|
||||||
import type {
|
import type {
|
||||||
AgentType,
|
AgentType,
|
||||||
ConversationStatus,
|
ConversationStatus,
|
||||||
DbConversationSummary,
|
DbConversationSummary,
|
||||||
FileTreeNode,
|
|
||||||
} from "@/lib/types"
|
} from "@/lib/types"
|
||||||
|
import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree"
|
||||||
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
|
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
|
||||||
import { AgentIcon } from "@/components/agent-icon"
|
import { AgentIcon } from "@/components/agent-icon"
|
||||||
import {
|
import {
|
||||||
@@ -35,49 +30,6 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type SearchTab = "conversations" | "files"
|
type SearchTab = "conversations" | "files"
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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`. */
|
|
||||||
function hasIgnoredAncestor(path: string, ignoredDirs: Set<string>): 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 {
|
interface SearchCommandDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -103,98 +55,23 @@ export function SearchCommandDialog({
|
|||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||||
|
|
||||||
// File search state
|
|
||||||
const [allFiles, setAllFiles] = useState<FlatFileEntry[]>([])
|
|
||||||
const [filesLoading, setFilesLoading] = useState(false)
|
|
||||||
const filesLoadedRef = useRef(false)
|
|
||||||
|
|
||||||
const folderPath = folder?.path ?? ""
|
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
|
// Compute which agent types exist in current folder
|
||||||
const availableAgents = Array.from(
|
const availableAgents = Array.from(
|
||||||
new Set(conversations.map((c) => c.agent_type))
|
new Set(conversations.map((c) => c.agent_type))
|
||||||
).sort(compareAgentType)
|
).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<typeof ig> }[] =
|
|
||||||
[]
|
|
||||||
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<string>()
|
|
||||||
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
|
// Filter files by query using pre-computed lowercase fields
|
||||||
const filteredFiles = useMemo(() => {
|
const filteredFiles = useMemo(() => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim()
|
||||||
@@ -253,10 +130,9 @@ export function SearchCommandDialog({
|
|||||||
setAgentFilter(null)
|
setAgentFilter(null)
|
||||||
setResults([])
|
setResults([])
|
||||||
setActiveTab("conversations")
|
setActiveTab("conversations")
|
||||||
filesLoadedRef.current = false
|
resetFileTree()
|
||||||
setAllFiles([])
|
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, resetFileTree])
|
||||||
|
|
||||||
const handleSelectConversation = useCallback(
|
const handleSelectConversation = useCallback(
|
||||||
(conv: DbConversationSummary) => {
|
(conv: DbConversationSummary) => {
|
||||||
|
|||||||
@@ -36,10 +36,7 @@ import {
|
|||||||
startFileTreeWatch,
|
startFileTreeWatch,
|
||||||
stopFileTreeWatch,
|
stopFileTreeWatch,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import {
|
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
||||||
emitAttachFileToSession,
|
|
||||||
emitAppendTextToSession,
|
|
||||||
} from "@/lib/session-attachment-events"
|
|
||||||
import type {
|
import type {
|
||||||
FileTreeChangedEvent,
|
FileTreeChangedEvent,
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
@@ -86,16 +83,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { joinFsPath } from "@/lib/path-utils"
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentDir(filePath: string): string {
|
function parentDir(filePath: string): string {
|
||||||
const slashIndex = filePath.lastIndexOf("/")
|
const slashIndex = filePath.lastIndexOf("/")
|
||||||
@@ -618,12 +606,9 @@ function RenderNode({
|
|||||||
|
|
||||||
const handleAttachDirToSession = () => {
|
const handleAttachDirToSession = () => {
|
||||||
if (!activeSessionTabId) return
|
if (!activeSessionTabId) return
|
||||||
const relativePath = node.path.endsWith("/")
|
emitAttachFileToSession({
|
||||||
? `@${node.path} `
|
|
||||||
: `@${node.path}/ `
|
|
||||||
emitAppendTextToSession({
|
|
||||||
tabId: activeSessionTabId,
|
tabId: activeSessionTabId,
|
||||||
text: relativePath,
|
path: absolutePath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
src/hooks/use-file-tree.ts
Normal file
166
src/hooks/use-file-tree.ts
Normal file
@@ -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<string>
|
||||||
|
): 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<FlatFileEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const loadedForPathRef = useRef<string | null>(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<typeof ig>
|
||||||
|
}[] = []
|
||||||
|
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<string>()
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/lib/path-utils.ts
Normal file
9
src/lib/path-utils.ts
Normal file
@@ -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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user