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 { 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<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(
|
||||
(
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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 && (
|
||||
<FileMentionMenu
|
||||
files={filteredAtFiles}
|
||||
selectedIndex={atSelectedIndex}
|
||||
onSelect={handleAtSelect}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
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",
|
||||
|
||||
@@ -4,23 +4,18 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||
import { File, Folder } from "lucide-react"
|
||||
import ig from "ignore"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import {
|
||||
getFileTree,
|
||||
listFolderConversations,
|
||||
readFilePreview,
|
||||
} from "@/lib/api"
|
||||
import { listFolderConversations } from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
DbConversationSummary,
|
||||
FileTreeNode,
|
||||
} from "@/lib/types"
|
||||
import { useFileTree, type FlatFileEntry } from "@/hooks/use-file-tree"
|
||||
import { AGENT_LABELS, STATUS_COLORS, compareAgentType } from "@/lib/types"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
@@ -35,49 +30,6 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
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 {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -103,98 +55,23 @@ export function SearchCommandDialog({
|
||||
const [searching, setSearching] = useState(false)
|
||||
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 ?? ""
|
||||
|
||||
// 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<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
|
||||
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) => {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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