mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add @-triggered file search popup with directory navigation
- Input @ in composer to open file search popup showing project root - Filter files/directories by typing, navigate with keyboard or mouse - Click directory to enter subdirectory, type / to navigate deeper - Enter selects item and inserts path text at cursor, popup closes - Parse filter path (e.g. @src/components/) to auto-navigate and search - Desktop: filesystem browse/search API supports includeFiles and search - Web: directory tree browser with real-time search filtering
This commit is contained in:
parent
da3d51b006
commit
ad4ba88a62
@ -1,18 +1,30 @@
|
||||
import { api } from './client'
|
||||
|
||||
type DirEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
type BrowseResult = {
|
||||
currentPath: string
|
||||
parentPath: string
|
||||
entries: Array<{
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
}>
|
||||
entries: DirEntry[]
|
||||
query?: string
|
||||
}
|
||||
|
||||
export const filesystemApi = {
|
||||
browse(path?: string) {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : ''
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse${params}`)
|
||||
browse(path?: string, options?: { includeFiles?: boolean }) {
|
||||
const q = new URLSearchParams()
|
||||
if (path) q.set('path', path)
|
||||
if (options?.includeFiles) q.set('includeFiles', 'true')
|
||||
const qs = q.toString()
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
search(query: string, cwd?: string) {
|
||||
const q = new URLSearchParams({ search: query, maxResults: '200' })
|
||||
if (cwd) q.set('path', cwd)
|
||||
return api.get<BrowseResult>(`/api/filesystem/browse?${q}`)
|
||||
},
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { ModelSelector } from '../controls/ModelSelector'
|
||||
import type { AttachmentRef } from '../../types/chat'
|
||||
import { AttachmentGallery } from './AttachmentGallery'
|
||||
import { ProjectContextChip } from '../shared/ProjectContextChip'
|
||||
import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu'
|
||||
import {
|
||||
FALLBACK_SLASH_COMMANDS,
|
||||
findSlashTrigger,
|
||||
@ -29,12 +30,16 @@ export function ChatInput() {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
const [fileSearchOpen, setFileSearchOpen] = useState(false)
|
||||
const [atFilter, setAtFilter] = useState('')
|
||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||
const [slashFilter, setSlashFilter] = useState('')
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const { sendMessage, chatState, stopGeneration, slashCommands } = useChatStore()
|
||||
const activeSessionId = useSessionStore((state) => state.activeSessionId)
|
||||
@ -91,6 +96,23 @@ export function ChatInput() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [slashMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileSearchOpen) return
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const menu = document.getElementById('file-search-menu')
|
||||
if (
|
||||
menu &&
|
||||
!menu.contains(event.target as Node) &&
|
||||
textareaRef.current &&
|
||||
!textareaRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setFileSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [fileSearchOpen])
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const source = slashCommands.length > 0 ? slashCommands : FALLBACK_SLASH_COMMANDS
|
||||
if (!slashFilter) return source
|
||||
@ -118,14 +140,51 @@ export function ChatInput() {
|
||||
return
|
||||
}
|
||||
|
||||
setFileSearchOpen(false)
|
||||
setSlashFilter(token.filter)
|
||||
setSlashMenuOpen(true)
|
||||
}, [])
|
||||
|
||||
// Detect @ trigger (file search)
|
||||
const detectAtTrigger = useCallback((value: string, cursorPos: number) => {
|
||||
const textBeforeCursor = value.slice(0, cursorPos)
|
||||
let pos = -1
|
||||
|
||||
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
|
||||
const ch = textBeforeCursor[i]!
|
||||
if (ch === '@') {
|
||||
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
|
||||
pos = i
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (pos < 0) {
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract filter text after @
|
||||
const filter = textBeforeCursor.slice(pos + 1)
|
||||
setAtFilter(filter)
|
||||
setAtCursorPos(cursorPos)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value
|
||||
const cursorPos = event.target.selectionStart ?? value.length
|
||||
setInput(value)
|
||||
detectSlashTrigger(value, event.target.selectionStart ?? value.length)
|
||||
detectSlashTrigger(value, cursorPos)
|
||||
detectAtTrigger(value, cursorPos)
|
||||
}
|
||||
|
||||
const selectSlashCommand = useCallback((command: string) => {
|
||||
@ -159,6 +218,24 @@ export function ChatInput() {
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
// Route file search navigation keys to FileSearchMenu
|
||||
if (fileSearchOpen) {
|
||||
const key = event.key
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (key === 'Escape') {
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
return
|
||||
}
|
||||
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
|
||||
return
|
||||
}
|
||||
// Other keys (typing) should go to the textarea - let it propagate
|
||||
return
|
||||
}
|
||||
|
||||
if (slashMenuOpen && filteredCommands.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
@ -287,6 +364,29 @@ export function ChatInput() {
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
ref={fileSearchRef}
|
||||
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
|
||||
filter={atFilter}
|
||||
onSelect={(_path, name) => {
|
||||
if (atCursorPos >= 0) {
|
||||
// Insert name at cursor position, replacing filter text
|
||||
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
||||
const newCursorPos = atCursorPos + name.length
|
||||
setInput(newValue)
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
void textareaRef.current?.focus()
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
|
||||
196
desktop/src/components/chat/FileSearchMenu.tsx
Normal file
196
desktop/src/components/chat/FileSearchMenu.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react'
|
||||
import { filesystemApi } from '../../api/filesystem'
|
||||
|
||||
type DirEntry = {
|
||||
name: string
|
||||
path: string
|
||||
isDirectory: boolean
|
||||
}
|
||||
|
||||
export type FileSearchMenuHandle = {
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
cwd: string
|
||||
filter?: string
|
||||
onSelect: (path: string, relativePath: string) => void
|
||||
}
|
||||
|
||||
export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, filter = '', onSelect }, ref) => {
|
||||
const [entries, setEntries] = useState<DirEntry[]>([])
|
||||
const [currentPath, setCurrentPath] = useState(cwd)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const currentPathRef = useRef(cwd)
|
||||
|
||||
// Parse filter: if it contains '/', navigate to that subdir and search the rest
|
||||
// Uses currentPathRef as base so nested paths navigate from current depth
|
||||
const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => {
|
||||
const base = currentPathRef.current
|
||||
if (!rawFilter || !rawFilter.includes('/')) {
|
||||
return { navigateTo: base, searchQuery: rawFilter }
|
||||
}
|
||||
const lastSlash = rawFilter.lastIndexOf('/')
|
||||
const dirPart = rawFilter.slice(0, lastSlash + 1)
|
||||
const searchPart = rawFilter.slice(lastSlash + 1)
|
||||
const navigateTo = dirPart === '' ? base : `${base}/${dirPart}`
|
||||
return { navigateTo, searchQuery: searchPart }
|
||||
}
|
||||
|
||||
// Load directory entries
|
||||
const loadDir = useCallback(async (dirPath: string, searchQuery: string) => {
|
||||
setLoading(true)
|
||||
// Only update currentPath if actually navigating to a different directory
|
||||
if (dirPath !== currentPathRef.current) {
|
||||
setCurrentPath(dirPath)
|
||||
currentPathRef.current = dirPath
|
||||
}
|
||||
try {
|
||||
if (searchQuery) {
|
||||
const result = await filesystemApi.search(searchQuery, dirPath)
|
||||
setEntries(result.entries)
|
||||
} else {
|
||||
const result = await filesystemApi.browse(dirPath, { includeFiles: true })
|
||||
setEntries(result.entries)
|
||||
}
|
||||
setSelectedIndex(0)
|
||||
} catch {
|
||||
setEntries([])
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
// Initial load: parse filter path and navigate accordingly
|
||||
useEffect(() => {
|
||||
currentPathRef.current = cwd
|
||||
const { navigateTo, searchQuery } = parseFilter(filter)
|
||||
void loadDir(navigateTo, searchQuery)
|
||||
}, [cwd, filter, loadDir])
|
||||
|
||||
// Keyboard navigation handler exposed via ref
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, entries.length - 1))
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
if (entries[selectedIndex]) {
|
||||
onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name)
|
||||
}
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [entries, selectedIndex])
|
||||
|
||||
useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown])
|
||||
|
||||
// Scroll selected into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`) as HTMLButtonElement | null
|
||||
el?.scrollIntoView({ block: 'nearest' })
|
||||
}, [selectedIndex])
|
||||
|
||||
// Build breadcrumb segments from current path relative to cwd
|
||||
const breadcrumbs: string[] = []
|
||||
if (currentPath !== cwd && currentPath.startsWith(cwd)) {
|
||||
const rel = currentPath.slice(cwd.length).replace(/^\//, '')
|
||||
if (rel) breadcrumbs.push(...rel.split('/'))
|
||||
}
|
||||
|
||||
const dirs = entries.filter((e) => e.isDirectory)
|
||||
const files = entries.filter((e) => !e.isDirectory)
|
||||
|
||||
return (
|
||||
<div
|
||||
id="file-search-menu"
|
||||
className="absolute left-0 bottom-full mb-2 z-50 w-full min-w-[480px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header with path */}
|
||||
<div className="flex items-center gap-1.5 border-b border-[var(--color-border)] px-3 py-2 text-[11px]">
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">folder_open</span>
|
||||
<span className="text-[var(--color-text-tertiary)] font-mono">{cwd.split('/').pop() || cwd}</span>
|
||||
{breadcrumbs.map((seg, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className="text-[var(--color-text-tertiary)]">/</span>
|
||||
<span className="text-[var(--color-text-primary)] font-mono">{seg}</span>
|
||||
</span>
|
||||
))}
|
||||
{loading && (
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)] animate-spin ml-1">progress_activity</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
<div ref={listRef} className="max-h-[300px] overflow-y-auto py-1">
|
||||
{loading && entries.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">Searching...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{filter ? 'No files match' : 'No files in this directory'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Directories */}
|
||||
{dirs.map((entry, i) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
data-index={i}
|
||||
onClick={() => {
|
||||
void loadDir(entry.path, filter)
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
selectedIndex === i ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-brand)]">folder</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Files */}
|
||||
{files.map((entry, i) => {
|
||||
const idx = dirs.length + i
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
data-index={idx}
|
||||
onClick={() => onSelect(entry.path, entry.name)}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left transition-colors ${
|
||||
selectedIndex === idx ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">description</span>
|
||||
<span className="text-sm text-[var(--color-text-primary)] truncate">{entry.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
|
||||
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">↑↓</kbd>
|
||||
<span>navigate</span>
|
||||
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
|
||||
<span>attach</span>
|
||||
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
|
||||
<span>close</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
FileSearchMenu.displayName = 'FileSearchMenu'
|
||||
@ -6,6 +6,7 @@ import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
||||
import { FileSearchMenu, type FileSearchMenuHandle } from '../components/chat/FileSearchMenu'
|
||||
import {
|
||||
FALLBACK_SLASH_COMMANDS,
|
||||
findSlashToken,
|
||||
@ -30,12 +31,16 @@ export function EmptySession() {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
const [fileSearchOpen, setFileSearchOpen] = useState(false)
|
||||
const [atFilter, setAtFilter] = useState('')
|
||||
const [atCursorPos, setAtCursorPos] = useState(-1)
|
||||
const [slashFilter, setSlashFilter] = useState('')
|
||||
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const createSession = useSessionStore((state) => state.createSession)
|
||||
const sendMessage = useChatStore((state) => state.sendMessage)
|
||||
@ -74,6 +79,23 @@ export function EmptySession() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [slashMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileSearchOpen) return
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const menu = document.getElementById('file-search-menu')
|
||||
if (
|
||||
menu &&
|
||||
!menu.contains(event.target as Node) &&
|
||||
textareaRef.current &&
|
||||
!textareaRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setFileSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [fileSearchOpen])
|
||||
|
||||
const filteredCommands = FALLBACK_SLASH_COMMANDS.filter((command) => {
|
||||
if (!slashFilter) return true
|
||||
const lower = slashFilter.toLowerCase()
|
||||
@ -123,13 +145,57 @@ export function EmptySession() {
|
||||
const token = findSlashToken(value, cursorPos)
|
||||
if (!token) {
|
||||
setSlashMenuOpen(false)
|
||||
return
|
||||
} else {
|
||||
setSlashFilter(token.filter)
|
||||
setSlashMenuOpen(true)
|
||||
}
|
||||
|
||||
// Detect @ trigger for file search
|
||||
const textBeforeCursor = value.slice(0, cursorPos)
|
||||
let pos = -1
|
||||
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
|
||||
const ch = textBeforeCursor[i]!
|
||||
if (ch === '@') {
|
||||
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
|
||||
pos = i
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (pos < 0) {
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
} else {
|
||||
setAtFilter(textBeforeCursor.slice(pos + 1))
|
||||
setAtCursorPos(cursorPos)
|
||||
setSlashMenuOpen(false)
|
||||
setFileSearchOpen(true)
|
||||
}
|
||||
setSlashFilter(token.filter)
|
||||
setSlashMenuOpen(true)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
// Route file search navigation keys to FileSearchMenu
|
||||
if (fileSearchOpen) {
|
||||
const key = event.key
|
||||
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (key === 'Escape') {
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
return
|
||||
}
|
||||
fileSearchRef.current?.handleKeyDown(event.nativeEvent)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (slashMenuOpen && filteredCommands.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
@ -284,6 +350,28 @@ export function EmptySession() {
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{fileSearchOpen && (
|
||||
<FileSearchMenu
|
||||
ref={fileSearchRef}
|
||||
cwd={workDir || ''}
|
||||
filter={atFilter}
|
||||
onSelect={(_path, name) => {
|
||||
if (atCursorPos >= 0) {
|
||||
const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}`
|
||||
const newCursorPos = atCursorPos + name.length
|
||||
setInput(newValue)
|
||||
setFileSearchOpen(false)
|
||||
setAtFilter('')
|
||||
setAtCursorPos(-1)
|
||||
void textareaRef.current?.focus()
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos)
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{slashMenuOpen && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={slashMenuRef}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Filesystem browser API — returns directory listings for the DirectoryPicker component.
|
||||
* Filesystem browser & search API — supports directory browsing and file search
|
||||
* for the DirectoryPicker component and @-triggered file search popup.
|
||||
*/
|
||||
|
||||
import * as path from 'path'
|
||||
@ -16,6 +17,9 @@ export async function handleFilesystemRoute(pathname: string, url: URL): Promise
|
||||
async function handleBrowse(url: URL): Promise<Response> {
|
||||
const targetPath = url.searchParams.get('path') || process.env.HOME || '/'
|
||||
const resolvedPath = path.resolve(targetPath)
|
||||
const searchQuery = url.searchParams.get('search') || ''
|
||||
const includeFiles = url.searchParams.get('includeFiles') === 'true'
|
||||
const maxResults = Math.min(parseInt(url.searchParams.get('maxResults') || '200', 10), 200)
|
||||
|
||||
try {
|
||||
const stat = fs.statSync(resolvedPath)
|
||||
@ -24,19 +28,59 @@ async function handleBrowse(url: URL): Promise<Response> {
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true })
|
||||
const dirs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
|
||||
if (searchQuery) {
|
||||
// Search mode: filter by filename, include both dirs and files
|
||||
const query = searchQuery.toLowerCase()
|
||||
const results = entries
|
||||
.filter((e) => {
|
||||
if (e.name.startsWith('.')) return false
|
||||
if (e.isDirectory()) return e.name.toLowerCase().includes(query)
|
||||
if (!includeFiles) return false
|
||||
return e.name.toLowerCase().includes(query)
|
||||
})
|
||||
.slice(0, maxResults)
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: path.join(resolvedPath, e.name),
|
||||
isDirectory: e.isDirectory(),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
// Directories first, then alphabetically
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return json({
|
||||
currentPath: resolvedPath,
|
||||
parentPath: path.dirname(resolvedPath),
|
||||
entries: results,
|
||||
query: searchQuery,
|
||||
})
|
||||
}
|
||||
|
||||
// Browse mode: show all directories (and optionally files)
|
||||
const filtered = entries.filter((e) => {
|
||||
if (e.name.startsWith('.')) return false
|
||||
if (e.isDirectory()) return true
|
||||
return includeFiles
|
||||
})
|
||||
|
||||
const entries_list = filtered
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: path.join(resolvedPath, e.name),
|
||||
isDirectory: true,
|
||||
isDirectory: e.isDirectory(),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return json({
|
||||
currentPath: resolvedPath,
|
||||
parentPath: path.dirname(resolvedPath),
|
||||
entries: dirs,
|
||||
entries: entries_list,
|
||||
})
|
||||
} catch (err) {
|
||||
return json({ error: `Cannot read directory: ${err}`, path: resolvedPath }, 500)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user