From 97e3d2422aca337348c4f1724ae55cc0de2de778 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 16 Jan 2026 13:19:07 +0800 Subject: [PATCH 1/8] feat: extend @ keyword in chat input to search the file path --- electron/main/ipc/index.ts | 2 + electron/main/ipc/path.ts | 88 ++++++ src-tauri/src/command/mod.rs | 1 + src-tauri/src/command/path.rs | 100 +++++++ src-tauri/src/lib.rs | 2 + src/components/ChatInput.tsx | 390 ++++++++++++-------------- src/components/SuggestionMenu.tsx | 127 +++++++++ src/ipc/index.ts | 1 + src/ipc/path.ts | 21 ++ src/locales/de/translation.json | 2 + src/locales/en/translation.json | 2 + src/locales/es/translation.json | 2 + src/locales/fi/translation.json | 2 + src/locales/fil/translation.json | 2 + src/locales/fr/translation.json | 2 + src/locales/id/translation.json | 2 + src/locales/it/translation.json | 2 + src/locales/ja/translation.json | 2 + src/locales/ko/translation.json | 2 + src/locales/lo/translation.json | 2 + src/locales/no/translation.json | 2 + src/locales/pl/translation.json | 2 + src/locales/pt/translation.json | 2 + src/locales/ru/translation.json | 2 + src/locales/sv/translation.json | 2 + src/locales/th/translation.json | 2 + src/locales/tr/translation.json | 2 + src/locales/uk/translation.json | 2 + src/locales/vi/translation.json | 2 + src/locales/zh-CN/translation.json | 2 + src/locales/zh-TW/translation.json | 2 + src/styles/components/_ChatInput.scss | 51 ++-- 32 files changed, 581 insertions(+), 246 deletions(-) create mode 100644 electron/main/ipc/path.ts create mode 100644 src-tauri/src/command/path.rs create mode 100644 src/components/SuggestionMenu.tsx create mode 100644 src/ipc/path.ts diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index bbfc4fd4..d6780b67 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -6,6 +6,7 @@ import { ipcLlmHandler } from "./llm" import { ipcMenuHandler } from "./menu" import { ipcOapHandler } from "./oap" import { ipcLocalIPCHandler } from "./lipc" +import { ipcPathHandler } from "./path" export function ipcHandler(win: BrowserWindow) { ipcEnvHandler(win) @@ -15,4 +16,5 @@ export function ipcHandler(win: BrowserWindow) { ipcMenuHandler(win) ipcOapHandler(win) ipcLocalIPCHandler(win) + ipcPathHandler(win) } diff --git a/electron/main/ipc/path.ts b/electron/main/ipc/path.ts new file mode 100644 index 00000000..3f6ad6e4 --- /dev/null +++ b/electron/main/ipc/path.ts @@ -0,0 +1,88 @@ +import { ipcMain, BrowserWindow } from "electron" +import * as fs from "fs" +import * as path from "path" +import * as os from "os" + +interface PathEntry { + name: string + path: string + isDir: boolean +} + +interface PathSearchResult { + entries: PathEntry[] + error?: string +} + +export function ipcPathHandler(_win: BrowserWindow) { + ipcMain.handle("path:search", async (_event, searchPath: string): Promise => { + try { + // Expand ~ to home directory + let normalizedPath = searchPath + if (normalizedPath.startsWith("~")) { + normalizedPath = path.join(os.homedir(), normalizedPath.slice(1)) + } + + // Determine parent directory and prefix for filtering + let dirToSearch: string + let filterPrefix: string + + if (normalizedPath.endsWith(path.sep) || normalizedPath.endsWith("/")) { + // User typed a complete directory path, list its contents + dirToSearch = normalizedPath + filterPrefix = "" + } else { + // User is typing a partial name, list parent and filter + dirToSearch = path.dirname(normalizedPath) + filterPrefix = path.basename(normalizedPath).toLowerCase() + } + + // Check if directory exists + if (!fs.existsSync(dirToSearch)) { + return { entries: [], error: "Directory not found" } + } + + const stat = fs.statSync(dirToSearch) + if (!stat.isDirectory()) { + return { entries: [], error: "Not a directory" } + } + + // Read directory contents + const items = fs.readdirSync(dirToSearch, { withFileTypes: true }) + + // Filter and map entries + const entries: PathEntry[] = items + .filter(item => { + // Hide dotfiles unless user is searching for them (filter starts with .) + if (item.name.startsWith('.') && !filterPrefix.startsWith('.')) { + return false + } + + // Filter by prefix if provided + if (filterPrefix) { + return item.name.toLowerCase().startsWith(filterPrefix) + } + return true + }) + .map(item => ({ + name: item.name, + path: path.join(dirToSearch, item.name), + isDir: item.isDirectory() + })) + // Sort: directories first, then by name + .sort((a, b) => { + if (a.isDir !== b.isDir) { + return a.isDir ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + // Limit to 20 entries + .slice(0, 20) + + return { entries } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error" + return { entries: [], error: message } + } + }) +} diff --git a/src-tauri/src/command/mod.rs b/src-tauri/src/command/mod.rs index b858d7c4..764f49b1 100644 --- a/src-tauri/src/command/mod.rs +++ b/src-tauri/src/command/mod.rs @@ -15,6 +15,7 @@ pub mod host; pub mod lipc; pub mod llm; pub mod oap; +pub mod path; pub mod system; #[tauri::command] diff --git a/src-tauri/src/command/path.rs b/src-tauri/src/command/path.rs new file mode 100644 index 00000000..02e4929b --- /dev/null +++ b/src-tauri/src/command/path.rs @@ -0,0 +1,100 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize)] +pub struct PathEntry { + pub name: String, + pub path: String, + #[serde(rename = "isDir")] + pub is_dir: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct PathSearchResult { + pub entries: Vec, + pub error: Option, +} + +#[tauri::command] +pub async fn path_search(path: String) -> PathSearchResult { + match search_path_impl(&path) { + Ok(entries) => PathSearchResult { + entries, + error: None, + }, + Err(e) => PathSearchResult { + entries: vec![], + error: Some(e.to_string()), + }, + } +} + +fn search_path_impl(search_path: &str) -> Result, Box> { + // Expand ~ to home directory + let normalized_path = if search_path.starts_with('~') { + let home = dirs::home_dir().ok_or("Could not find home directory")?; + home.join(&search_path[1..]) + } else { + PathBuf::from(search_path) + }; + + // Determine directory to search and filter prefix + let (dir_to_search, filter_prefix): (PathBuf, String) = + if search_path.ends_with('/') || search_path.ends_with('\\') { + (normalized_path.clone(), String::new()) + } else { + let parent = normalized_path + .parent() + .ok_or("Invalid path")? + .to_path_buf(); + let prefix = normalized_path + .file_name() + .map(|s| s.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + (parent, prefix) + }; + + // Check if directory exists + if !dir_to_search.exists() || !dir_to_search.is_dir() { + return Ok(vec![]); + } + + // Read directory contents + let mut entries: Vec = std::fs::read_dir(&dir_to_search)? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + let name_lower = name.to_lowercase(); + + // Hide dotfiles unless user is searching for them (filter starts with .) + if name.starts_with('.') && !filter_prefix.starts_with('.') { + return false; + } + + if filter_prefix.is_empty() { + return true; + } + name_lower.starts_with(&filter_prefix) + }) + .map(|entry| { + let metadata = entry.metadata().ok(); + PathEntry { + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + is_dir: metadata.map(|m| m.is_dir()).unwrap_or(false), + } + }) + .collect(); + + // Sort: directories first, then by name + entries.sort_by(|a, b| match (a.is_dir, b.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()), + }); + + // Limit to 20 entries + entries.truncate(20); + + Ok(entries) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80373662..36d094ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,8 @@ pub fn run() { // lipc command::lipc::response_mcp_elicitation, command::oap::oap_get_mcp_tags, + // path + command::path::path_search, ]) .append_invoke_initialization_script(include_str!("../../shared/preload.js")) .build(tauri::generate_context!()); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 1d30ac33..40b7c72c 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -1,6 +1,6 @@ import "../styles/components/_ChatInput.scss" -import React, { useState, useRef, useEffect, useCallback } from "react" +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" import Tooltip from "./Tooltip" import useHotkeyEvent from "../hooks/useHotkeyEvent" @@ -19,6 +19,8 @@ import Button from "./Button" import { invokeIPC, isTauri } from "../ipc" import ToolDropDown from "./ToolDropDown" import { historiesAtom } from "../atoms/historyState" +import { searchPath, type PathEntry } from "../ipc/path" +import SuggestionMenu from "./SuggestionMenu" interface Props { page: "welcome" | "chat" @@ -64,11 +66,17 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // Tool mention states const [showToolMenu, setShowToolMenu] = useState(false) - const [toolMenuPosition, setToolMenuPosition] = useState({ top: 0, left: 0 }) const [toolSearchQuery, setToolSearchQuery] = useState("") const [selectedToolIndex, setSelectedToolIndex] = useState(0) const [mentionStartPos, setMentionStartPos] = useState(0) - const toolMenuRef = useRef(null) + + // Path search states + const [showPathMenu, setShowPathMenu] = useState(false) + const [pathSearchQuery, setPathSearchQuery] = useState("") + const [selectedPathIndex, setSelectedPathIndex] = useState(0) + const [pathStartPos, setPathStartPos] = useState(0) + const [pathEntries, setPathEntries] = useState([]) + const [isSearchingPath, setIsSearchingPath] = useState(false) // Calculate chat key for draft storage const chatKey = page === "welcome" ? "__new_chat__" : currentChatId || "__new_chat__" @@ -286,102 +294,6 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } }, []) - // Handle click outside tool menu - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if (toolMenuRef.current && !toolMenuRef.current.contains(e.target as Node) && - textareaRef.current && !textareaRef.current.contains(e.target as Node)) { - setShowToolMenu(false) - } - } - - if (showToolMenu) { - document.addEventListener("mousedown", handleClickOutside) - return () => { - document.removeEventListener("mousedown", handleClickOutside) - } - } - }, [showToolMenu]) - - // Scroll selected item into view - useEffect(() => { - if (showToolMenu && toolMenuRef.current) { - const selectedItem = toolMenuRef.current.querySelector(".tool-mention-item.selected") - if (selectedItem) { - selectedItem.scrollIntoView({ - block: "nearest", - behavior: "smooth" - }) - } - } - }, [selectedToolIndex, showToolMenu]) - - // Update menu position when message changes and menu is visible - useEffect(() => { - if (showToolMenu && textareaRef.current) { - const textarea = textareaRef.current - const textareaRect = textarea.getBoundingClientRect() - const cursorPos = textarea.selectionStart - - // Create a mirror div to measure text position - const div = document.createElement("div") - const style = window.getComputedStyle(textarea) - - // Copy textarea styles to mirror div - const properties = [ - "boxSizing", "width", "height", "overflowX", "overflowY", - "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", - "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", - "fontFamily", "fontSize", "fontWeight", "lineHeight", - "letterSpacing", "whiteSpace", "wordWrap", "wordBreak" - ] - - properties.forEach(prop => { - div.style[prop as any] = style[prop as any] - }) - - div.style.position = "absolute" - div.style.visibility = "hidden" - div.style.top = "0" - div.style.left = "0" - div.style.whiteSpace = "pre-wrap" - div.style.wordWrap = "break-word" - - document.body.appendChild(div) - - // Add text up to cursor position - const textBeforeCursor = textarea.value.substring(0, cursorPos) - div.textContent = textBeforeCursor - - // Add a span at cursor position to measure - const span = document.createElement("span") - span.textContent = "|" - div.appendChild(span) - - const spanRect = span.getBoundingClientRect() - const divRect = div.getBoundingClientRect() - document.body.removeChild(div) - - // Calculate relative position - const top = textareaRect.top + (spanRect.top - divRect.top) + textarea.scrollTop - const left = textareaRect.left + (spanRect.left - divRect.left) - const lineHeight = parseInt(style.lineHeight) || 20 - const menuMaxHeight = 240 // Max height from CSS - const windowHeight = window.innerHeight - - // Calculate space below cursor - const spaceBelow = windowHeight - (top + lineHeight) - - // If not enough space below, show menu above cursor - const showAbove = spaceBelow < menuMaxHeight && top > menuMaxHeight - - setToolMenuPosition({ - top: showAbove ? top - menuMaxHeight : top + lineHeight, - left: left - }) - } - }, [message, showToolMenu]) - useEffect(() => { if (prevDisabled.current && !disabled) { textareaRef.current?.focus() @@ -511,70 +423,79 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) ) }, [toolSearchQuery, getToolOptions]) - // Calculate cursor position in pixels - const getCursorPosition = useCallback(() => { - if (!textareaRef.current) { - return { top: 0, left: 0 } + // Debounced path search + const searchPathDebounced = useMemo(() => { + let timeoutId: ReturnType | null = null + return (query: string) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(async () => { + setIsSearchingPath(true) + try { + const result = await searchPath(query) + if (!result.error) { + setPathEntries(result.entries) + } else { + setPathEntries([]) + } + } catch { + setPathEntries([]) + } finally { + setIsSearchingPath(false) + } + }, 150) } + }, []) - const textarea = textareaRef.current - const textareaRect = textarea.getBoundingClientRect() - const cursorPos = textarea.selectionStart - - // Create a mirror div to measure text position - const div = document.createElement("div") - const style = window.getComputedStyle(textarea) - - // Copy textarea styles to mirror div - const properties = [ - "boxSizing", "width", "height", "overflowX", "overflowY", - "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", - "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", - "fontFamily", "fontSize", "fontWeight", "lineHeight", - "letterSpacing", "whiteSpace", "wordWrap", "wordBreak" - ] - - properties.forEach(prop => { - div.style[prop as any] = style[prop as any] - }) - - div.style.position = "absolute" - div.style.visibility = "hidden" - div.style.top = "0" - div.style.left = "0" - div.style.whiteSpace = "pre-wrap" - div.style.wordWrap = "break-word" + const handleTextareaChange = (e: React.ChangeEvent) => { + const newValue = e.target.value + const cursorPos = e.target.selectionStart - document.body.appendChild(div) + setMessage(newValue) - // Add text up to cursor position - const textBeforeCursor = textarea.value.substring(0, cursorPos) - div.textContent = textBeforeCursor + const textBeforeCursor = newValue.substring(0, cursorPos) - // Add a span at cursor position to measure - const span = document.createElement("span") - span.textContent = "|" - div.appendChild(span) + // Find the start of the current token (word) + let tokenStart = -1 + for (let i = cursorPos - 1; i >= 0; i--) { + const char = textBeforeCursor[i] + if (char === " " || char === "\n") { + tokenStart = i + 1 + break + } + if (i === 0) { + tokenStart = 0 + } + } - const spanRect = span.getBoundingClientRect() - const divRect = div.getBoundingClientRect() - document.body.removeChild(div) + if (tokenStart !== -1) { + const currentToken = textBeforeCursor.substring(tokenStart) - // Calculate relative position - const top = textareaRect.top + (spanRect.top - divRect.top) + textarea.scrollTop - const left = textareaRect.left + (spanRect.left - divRect.left) + // Check if it's a path pattern with @ prefix: + // 1. Unix-style: starts with "@/" + // 2. Windows-style: @ followed by single letter and ":/" or ":\\" (e.g., @C:/) + const isUnixPath = currentToken.startsWith("@/") + const isWindowsPath = /^@[A-Za-z]:[\\/]/.test(currentToken) - return { top, left } - }, []) + if (isUnixPath || isWindowsPath) { + // Store the full token including @ for replacement later + setPathSearchQuery(currentToken) + setPathStartPos(tokenStart) + setShowPathMenu(true) + setSelectedPathIndex(0) + setShowToolMenu(false) - const handleTextareaChange = (e: React.ChangeEvent) => { - const newValue = e.target.value - const cursorPos = e.target.selectionStart + // Trigger path search (strip the @ prefix for the actual search) + searchPathDebounced(currentToken.substring(1)) + return + } + } - setMessage(newValue) + // Hide path menu if no valid path pattern found + setShowPathMenu(false) - // Check if @ symbol was just typed - const textBeforeCursor = newValue.substring(0, cursorPos) + // Check if @ symbol was just typed (original tool mention logic) const lastAtIndex = textBeforeCursor.lastIndexOf("@") if (lastAtIndex !== -1) { @@ -598,25 +519,6 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) setMentionStartPos(lastAtIndex) setShowToolMenu(true) setSelectedToolIndex(0) - - // Calculate position for the menu at cursor position - if (textareaRef.current) { - const cursorPosition = getCursorPosition() - const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight) || 20 - const menuMaxHeight = 240 // Max height from CSS - const windowHeight = window.innerHeight - - // Calculate space below cursor - const spaceBelow = windowHeight - (cursorPosition.top + lineHeight) - - // If not enough space below, show menu above cursor - const showAbove = spaceBelow < menuMaxHeight && cursorPosition.top > menuMaxHeight - - setToolMenuPosition({ - top: showAbove ? cursorPosition.top - menuMaxHeight : cursorPosition.top + lineHeight, - left: cursorPosition.left - }) - } return } } @@ -646,6 +548,40 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) }, 0) }, [message, mentionStartPos, toolSearchQuery]) + // Handle path selection + const selectPath = useCallback((entry: PathEntry) => { + const beforePath = message.substring(0, pathStartPos) + const afterPath = message.substring(pathStartPos + pathSearchQuery.length) + + // If it's a directory, append "/" to continue searching + // Add @ prefix since our trigger pattern uses @ + const selectedPath = entry.isDir ? "@" + entry.path + "/" : "@" + entry.path + " " + const newMessage = beforePath + selectedPath + afterPath + + setMessage(newMessage) + + if (entry.isDir) { + // Continue showing menu for directory navigation + // pathSearchQuery includes @ prefix + setPathSearchQuery("@" + entry.path + "/") + setSelectedPathIndex(0) + // Trigger new search for directory contents (without @ prefix) + searchPathDebounced(entry.path + "/") + } else { + setShowPathMenu(false) + setPathSearchQuery("") + } + + // Focus and position cursor + setTimeout(() => { + if (textareaRef.current) { + const newCursorPos = beforePath.length + selectedPath.length + textareaRef.current.focus() + textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) + } + }, 0) + }, [message, pathStartPos, pathSearchQuery, searchPathDebounced]) + const saveMessageToHistory = (msg: string) => { if (!msg.trim()) { return @@ -721,50 +657,64 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } const onKeydown = (e: React.KeyboardEvent) => { - // Handle tool menu navigation - if (showToolMenu) { - const filteredOptions = getFilteredToolOptions() - - if (e.key === "ArrowDown") { + // Handle path menu keyboard navigation + if (showPathMenu) { + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault() - setSelectedToolIndex((prev) => - prev < filteredOptions.length - 1 ? prev + 1 : prev - ) + setSelectedPathIndex(prev => Math.min(prev + 1, pathEntries.length - 1)) return } - - if (e.key === "ArrowUp") { + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { e.preventDefault() - setSelectedToolIndex((prev) => prev > 0 ? prev - 1 : 0) + setSelectedPathIndex(prev => Math.max(prev - 1, 0)) return } - - if (e.key === "Enter" && !e.shiftKey && !e.altKey) { + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { e.preventDefault() - if (filteredOptions[selectedToolIndex]) { - selectTool(filteredOptions[selectedToolIndex].value) + if (pathEntries[selectedPathIndex]) { + selectPath({ name: pathEntries[selectedPathIndex].name, path: pathEntries[selectedPathIndex].path, isDir: pathEntries[selectedPathIndex].isDir }) } return } + if (e.key === "Escape") { + e.preventDefault() + setShowPathMenu(false) + return + } + return + } - if (e.key === "Tab") { + // Handle tool menu keyboard navigation + if (showToolMenu) { + const filteredOptions = getFilteredToolOptions() + if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { + e.preventDefault() + setSelectedToolIndex(prev => Math.min(prev + 1, filteredOptions.length - 1)) + return + } + if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { + e.preventDefault() + setSelectedToolIndex(prev => Math.max(prev - 1, 0)) + return + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { e.preventDefault() if (filteredOptions[selectedToolIndex]) { selectTool(filteredOptions[selectedToolIndex].value) } return } - if (e.key === "Escape") { e.preventDefault() setShowToolMenu(false) return } + return } // chat-input:history-up // Handle message history navigation with ArrowUp/ArrowDown - if (e.key === "ArrowUp" && !showToolMenu) { + if (e.key === "ArrowUp" && !showToolMenu && !showPathMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart // Only trigger if cursor is at the beginning of the textarea @@ -789,7 +739,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } } - if (e.key === "ArrowDown" && !showToolMenu) { + if (e.key === "ArrowDown" && !showToolMenu && !showPathMenu) { const textarea = e.currentTarget const cursorPosition = textarea.selectionStart const textLength = textarea.value.length @@ -947,34 +897,38 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) placeholder={t("chat.placeholder")} rows={1} /> - {showToolMenu && (() => { - const filteredOptions = getFilteredToolOptions() - if (filteredOptions.length === 0) { - return null - } - return ( -
- {filteredOptions.map((option, index) => ( -
selectTool(option.value)} - onMouseEnter={() => setSelectedToolIndex(index)} - > -
{option.label}
-
- ))} -
- ) - })()} + 0} + items={getFilteredToolOptions().map(opt => ({ key: opt.value, ...opt }))} + selectedIndex={selectedToolIndex} + onSelectedIndexChange={setSelectedToolIndex} + onSelect={(item) => selectTool(item.value)} + onClose={() => setShowToolMenu(false)} + textareaRef={textareaRef} + renderItem={(item) => ( + {item.label} + )} + /> + ({ key: entry.path, ...entry }))} + selectedIndex={selectedPathIndex} + onSelectedIndexChange={setSelectedPathIndex} + onSelect={(item) => selectPath({ name: item.name, path: item.path, isDir: item.isDir })} + onClose={() => setShowPathMenu(false)} + textareaRef={textareaRef} + isLoading={isSearchingPath} + loadingContent={t("chat.searchingPath")} + emptyContent={t("chat.noPathResults")} + renderItem={(item) => ( + <> + + {item.isDir ? "📁" : "📄"} + + {item.name} + + )} + /> {previews.length > 0 && (
diff --git a/src/components/SuggestionMenu.tsx b/src/components/SuggestionMenu.tsx new file mode 100644 index 00000000..90a5a2b6 --- /dev/null +++ b/src/components/SuggestionMenu.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useRef, useCallback } from "react" + +export interface SuggestionMenuItem { + key: string + [key: string]: unknown +} + +interface SuggestionMenuProps { + show: boolean + items: T[] + selectedIndex: number + onSelectedIndexChange: (index: number) => void + onSelect: (item: T) => void + onClose: () => void + renderItem: (item: T, isSelected: boolean) => React.ReactNode + textareaRef: React.RefObject + emptyContent?: React.ReactNode + loadingContent?: React.ReactNode + isLoading?: boolean +} + +function SuggestionMenu({ + show, + items, + selectedIndex, + onSelectedIndexChange, + onSelect, + onClose, + renderItem, + textareaRef, + emptyContent, + loadingContent, + isLoading = false, +}: SuggestionMenuProps) { + const menuRef = useRef(null) + const positionRef = useRef({ top: 0, left: 0, width: 0 }) + + // Update menu position + const updatePosition = useCallback(() => { + if (textareaRef.current) { + const rect = textareaRef.current.getBoundingClientRect() + positionRef.current = { + top: rect.top, + left: rect.left, + width: rect.width, + } + } + }, [textareaRef]) + + // Update position when shown + useEffect(() => { + if (show) { + updatePosition() + } + }, [show, updatePosition]) + + // Handle click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + textareaRef.current && + !textareaRef.current.contains(e.target as Node) + ) { + onClose() + } + } + + if (show) { + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("mousedown", handleClickOutside) + } + } + }, [show, onClose, textareaRef]) + + // Scroll selected item into view + useEffect(() => { + if (show && menuRef.current) { + const selectedItem = menuRef.current.querySelector(".chat-suggestion-item.selected") + if (selectedItem) { + selectedItem.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + } + }, [selectedIndex, show]) + + + if (!show) return null + + updatePosition() + + return ( +
+ {isLoading ? ( +
{loadingContent}
+ ) : items.length === 0 ? ( +
{emptyContent}
+ ) : ( + items.map((item, index) => ( +
onSelect(item)} + onMouseEnter={() => onSelectedIndexChange(index)} + > + {renderItem(item, index === selectedIndex)} +
+ )) + )} +
+ ) +} + +export default SuggestionMenu diff --git a/src/ipc/index.ts b/src/ipc/index.ts index d7fda6f4..a6933924 100644 --- a/src/ipc/index.ts +++ b/src/ipc/index.ts @@ -9,6 +9,7 @@ export * from "./host" export * from "./config" export * from "./llm" export * from "./lipc" +export * from "./path" export function listenIPC(event: string, listener: (...args: any[]) => void): () => void { if (isElectron) { diff --git a/src/ipc/path.ts b/src/ipc/path.ts new file mode 100644 index 00000000..3edec678 --- /dev/null +++ b/src/ipc/path.ts @@ -0,0 +1,21 @@ +import { invoke } from "@tauri-apps/api/core" +import { isElectron } from "./env" + +export interface PathEntry { + name: string + path: string + isDir: boolean +} + +export interface PathSearchResult { + entries: PathEntry[] + error?: string +} + +export async function searchPath(path: string): Promise { + if (isElectron) { + return window.ipcRenderer.invoke("path:search", path) + } + + return invoke("path_search", { path }) +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 8a5a7524..032d3faa 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -53,6 +53,8 @@ "enableToast": "Werkzeugaufrufe aktiviert", "enable": "Werkzeugaufrufe aktivieren" }, + "searchingPath": "Suche...", + "noPathResults": "Keine Dateien oder Ordner gefunden", "reAuthorize": { "title": "Autorisierungsfehler beim Aufrufen des Tools", "description": "Die Autorisierung für dieses Tool ist abgelaufen oder wurde widerrufen, daher kann das Tool nicht mehr verwendet werden. Eine neue Autorisierung ist erforderlich, um die volle Funktionalität wiederherzustellen.", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 7326454a..630f89ed 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -53,6 +53,8 @@ "enableToast": "Tool Calls enabled", "enable": "Enable Tool Calls" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", "reAuthorize": { "title": "Error occurred when trying to call the tool", "description": "The authorization for this tool has expired or been canceled, so the tool cannot be used. A new authorization is required to restore full functionality.", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 62d81a8d..6ca282cd 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -53,6 +53,8 @@ "enableToast": "Tool Calls activado", "enable": "Activar Tool Calls" }, + "searchingPath": "Buscando...", + "noPathResults": "No se encontraron archivos o carpetas", "reAuthorize": { "title": "Error de autorización al intentar llamar a la herramienta", "description": "La autorización de este módulo ha expirado o ha sido cancelada, por lo que no se puede continuar usando la herramienta. Se requiere una nueva autorización para restaurar la funcionalidad completa.", diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index 8fbd4693..e4a8d5ce 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -53,6 +53,8 @@ "enableToast": "Työkalukutsut käytössä", "enable": "Ota työkalukutsut käyttöön" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", "reAuthorize": { "title": "Valtuutusvirhe työkalua kutsuttaessa", "description": "Tämän työkalun valtuutus on vanhentunut tai peruutettu, joten työkalua ei voi käyttää. Uusi valtuutus vaaditaan täyden toiminnallisuuden palauttamiseksi.", diff --git a/src/locales/fil/translation.json b/src/locales/fil/translation.json index d60d6a03..8493e935 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -53,6 +53,8 @@ "enableToast": "Na-enable ang Tool Calls", "enable": "I-enable ang Tool Calls" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", "reAuthorize": { "title": "Naganap ang error sa authorization habang tinatawag ang tool", "description": "Nag-expire na o na-cancel ang authorization para sa tool na ito, kaya hindi na magagamit ang tool. Kailangan ng bagong authorization para maibalik ang buong functionality.", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 4c9a74fb..0735e317 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -53,6 +53,8 @@ "enableToast": "Appels d'outils activés", "enable": "Activer les appels d'outils" }, + "searchingPath": "Recherche...", + "noPathResults": "Aucun fichier ou dossier trouvé", "reAuthorize": { "title": "Erreur d'autorisation lors de l'appel de l'outil", "description": "L'autorisation de cet outil a expiré ou a été annulée, l'outil ne peut donc plus être utilisé. Une nouvelle autorisation est requise pour restaurer toutes les fonctionnalités.", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 644c8e10..5bc85aa7 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -53,6 +53,8 @@ "enableToast": "Panggilan Alat diaktifkan", "enable": "Aktifkan Panggilan Alat" }, + "searchingPath": "Mencari...", + "noPathResults": "Tidak ada file atau folder yang ditemukan", "reAuthorize": { "title": "Kesalahan otorisasi saat memanggil alat", "description": "Otorisasi untuk alat ini telah kedaluwarsa atau dibatalkan, sehingga alat tidak dapat digunakan. Otorisasi baru diperlukan untuk memulihkan fungsionalitas penuh.", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index e771fc64..20256387 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -53,6 +53,8 @@ "enableToast": "Chiamate agli strumenti abilitate", "enable": "Abilita chiamate agli strumenti" }, + "searchingPath": "Ricerca...", + "noPathResults": "Nessun file o cartella trovato", "reAuthorize": { "title": "Errore di autorizzazione durante la chiamata dello strumento", "description": "L'autorizzazione per questo strumento è scaduta o è stata annullata, quindi lo strumento non può essere utilizzato. È richiesta una nuova autorizzazione per ripristinare la funzionalità completa.", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index d59758cc..680f728c 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -53,6 +53,8 @@ "enableToast": "Tool Callsを有効化しました", "enable": "Tool Callsを有効化" }, + "searchingPath": "検索中...", + "noPathResults": "ファイルまたはフォルダが見つかりません", "reAuthorize": { "title": "ツール呼び出し時に認証エラーが発生しました", "description": "このツールの認証が期限切れまたは取り消されたため、ツールを継続して使用できません。完全な機能を復元するには、再認証が必要です。", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 6c9b4f3b..112d5a62 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -53,6 +53,8 @@ "enableToast": "도구 호출 활성화됨", "enable": "도구 호출 활성화" }, + "searchingPath": "검색 중...", + "noPathResults": "파일 또는 폴더를 찾을 수 없습니다", "reAuthorize": { "title": "도구 호출 시 권한 오류 발생", "description": "이 도구의 권한이 만료되었거나 취소되어 도구를 계속 사용할 수 없습니다. 전체 기능을 복구하려면 다시 권한을 부여해야 합니다.", diff --git a/src/locales/lo/translation.json b/src/locales/lo/translation.json index a1153299..5a5acf32 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -53,6 +53,8 @@ "enableToast": "ເປີດການເອີ້ນໃຊ້ເຄື່ອງມືແລ້ວ", "enable": "ເປີດການເອີ້ນໃຊ້ເຄື່ອງມື" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", "reAuthorize": { "title": "ເກີດຂໍ້ຜິດພາດການອະນຸຍາດເມື່ອເອີ້ນໃຊ້ເຄື່ອງມື", "description": "ການອະນຸຍາດສຳລັບເຄື່ອງມືນີ້ໝົດອາຍຸຫຼືຖືກຍົກເລີກແລ້ວ ດັ່ງນັ້ນເຄື່ອງມືຈຶ່ງບໍ່ສາມາດນຳໃຊ້ຕໍ່ໄດ້ ຕ້ອງການການອະນຸຍາດໃໝ່ເພື່ອຟື້ນຟູຟັງຊັນການເຮັດວຽກທັງໝົດ", diff --git a/src/locales/no/translation.json b/src/locales/no/translation.json index a88b5e0a..413ee043 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -53,6 +53,8 @@ "enableToast": "Verktøyanrop aktivert", "enable": "Aktiver verktøyanrop" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", "reAuthorize": { "title": "Autorisasjonsfeil ved anrop av verktøyet", "description": "Autorisasjonen for dette verktøyet har utløpt eller blitt kansellert, så verktøyet kan ikke brukes. Ny autorisasjon kreves for å gjenopprette full funksjonalitet.", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index a1c62c05..f3a725c9 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -53,6 +53,8 @@ "enableToast": "Wywołania narzędzi włączone", "enable": "Włącz wywołania narzędzi" }, + "searchingPath": "Wyszukiwanie...", + "noPathResults": "Nie znaleziono plików ani folderów", "reAuthorize": { "title": "Błąd autoryzacji podczas wywoływania narzędzia", "description": "Autoryzacja dla tego narzędzia wygasła lub została anulowana, więc narzędzie nie może być używane. Wymagana jest nowa autoryzacja, aby przywrócić pełną funkcjonalność.", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 245fbfbb..615a6597 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -53,6 +53,8 @@ "enableToast": "Chamadas de Ferramentas ativadas", "enable": "Ativar Chamadas de Ferramentas" }, + "searchingPath": "Pesquisando...", + "noPathResults": "Nenhum arquivo ou pasta encontrado", "reAuthorize": { "title": "Erro de autorização ao tentar chamar a ferramenta", "description": "A autorização desta ferramenta expirou ou foi cancelada, portanto a ferramenta não pode ser usada. Uma nova autorização é necessária para restaurar a funcionalidade completa.", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 3d0b0aae..d32a154a 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -53,6 +53,8 @@ "enableToast": "Вызовы инструментов включены", "enable": "Включить вызовы инструментов" }, + "searchingPath": "Поиск...", + "noPathResults": "Файлы или папки не найдены", "reAuthorize": { "title": "Ошибка авторизации при вызове инструмента", "description": "Авторизация для этого инструмента истекла или была отменена, поэтому инструмент не может быть использован. Требуется новая авторизация для восстановления полной функциональности.", diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index 07c8dfdb..ce4db0f9 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -53,6 +53,8 @@ "enableToast": "Verktygsanrop aktiverade", "enable": "Aktivera verktygsanrop" }, + "searchingPath": "Söker...", + "noPathResults": "Inga filer eller mappar hittades", "reAuthorize": { "title": "Auktoriseringsfel vid anrop av verktyget", "description": "Auktoriseringen för detta verktyg har gått ut eller har avbrutits, så verktyget kan inte användas. Ny auktorisering krävs för att återställa full funktionalitet.", diff --git a/src/locales/th/translation.json b/src/locales/th/translation.json index d5309830..dc475957 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -53,6 +53,8 @@ "enableToast": "เปิดการเรียกเครื่องมือแล้ว", "enable": "เปิดการเรียกเครื่องมือ" }, + "searchingPath": "กำลังค้นหา...", + "noPathResults": "ไม่พบไฟล์หรือโฟลเดอร์", "reAuthorize": { "title": "เกิดข้อผิดพลาดในการอนุญาตเมื่อเรียกใช้เครื่องมือ", "description": "การอนุญาตสำหรับเครื่องมือนี้หมดอายุหรือถูกยกเลิกแล้ว ดังนั้นจึงไม่สามารถใช้เครื่องมือต่อไปได้ จำเป็นต้องมีการอนุญาตใหม่เพื่อฟื้นฟูฟังก์ชันการทำงานทั้งหมด", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 97424ed2..efeea8fd 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -53,6 +53,8 @@ "enableToast": "Araç Çağrıları etkinleştirildi", "enable": "Araç Çağrılarını Etkinleştir" }, + "searchingPath": "Aranıyor...", + "noPathResults": "Dosya veya klasör bulunamadı", "reAuthorize": { "title": "Araç çağrılırken yetkilendirme hatası oluştu", "description": "Bu araç için yetkilendirme süresi dolmuş veya iptal edilmiş, bu nedenle araç kullanılamıyor. Tam işlevselliği geri yüklemek için yeni bir yetkilendirme gereklidir.", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 1c825962..97995ca9 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -53,6 +53,8 @@ "enableToast": "Виклики інструментів увімкнено", "enable": "Увімкнути виклики інструментів" }, + "searchingPath": "Пошук...", + "noPathResults": "Файли або папки не знайдено", "reAuthorize": { "title": "Помилка авторизації при виклику інструменту", "description": "Авторизація для цього інструменту закінчилася або була скасована, тому інструмент не може бути використаний. Потрібна нова авторизація для відновлення повної функціональності.", diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json index d8d3f9c6..8fb762fa 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -53,6 +53,8 @@ "enableToast": "Đã bật gọi công cụ", "enable": "Bật gọi công cụ" }, + "searchingPath": "Đang tìm kiếm...", + "noPathResults": "Không tìm thấy tệp hoặc thư mục", "reAuthorize": { "title": "Lỗi ủy quyền khi gọi công cụ", "description": "Ủy quyền cho công cụ này đã hết hạn hoặc bị hủy, vì vậy không thể tiếp tục sử dụng công cụ. Cần ủy quyền mới để khôi phục đầy đủ chức năng.", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index cb94cd74..93514b31 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -53,6 +53,8 @@ "enableToast": "已启用Tool Calls", "enable": "启用Tool Calls" }, + "searchingPath": "搜索中...", + "noPathResults": "找不到文件或文件夹", "reAuthorize": { "title": "尝试调用工具时,授权发生错误", "description": "此工具的授权已过期或已被取消,无法继续使用工具。需要重新授权以恢复完整功能。", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 719e2611..05f8cb94 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -53,6 +53,8 @@ "enableToast": "已啟用Tool Calls", "enable": "啟用Tool Calls" }, + "searchingPath": "搜尋中...", + "noPathResults": "找不到檔案或資料夾", "reAuthorize": { "title": "嘗試呼叫工具時,授權發生錯誤", "description": "此工具的授權已過期或已被取消,無法繼續使用工具。需要重新授權以恢復完整功能。", diff --git a/src/styles/components/_ChatInput.scss b/src/styles/components/_ChatInput.scss index df4a34be..b705cd27 100644 --- a/src/styles/components/_ChatInput.scss +++ b/src/styles/components/_ChatInput.scss @@ -417,60 +417,55 @@ color: var(--text-warning); } -// Tool mention menu styles -.tool-mention-menu { +// Suggestion menu styles (used for both tool mentions and path search) +.chat-suggestion-menu { position: fixed; background: var(--bg-gray-weak); border: 1px solid var(--stroke-gray-medium); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - max-height: 240px; + max-height: 150px; overflow-y: auto; - min-width: 280px; - max-width: 400px; z-index: 1000; - margin-top: 4px; + transform: translateY(-100%); + margin-top: -8px; - .tool-mention-item { - padding: 10px 14px; + @include scrollbar; + + .chat-suggestion-item { + padding: 4px 8px; cursor: pointer; transition: background-color 0.15s ease; - border-bottom: 1px solid var(--stroke-gray-weak); - text-align: left; - - &:last-child { - border-bottom: none; - } + display: flex; &:hover, &.selected { background: var(--bg-gray-strong); } - &.no-results { + &.no-results, + &.loading { cursor: default; color: var(--text-weak); - text-align: center; - padding: 16px 14px; + justify-content: center; &:hover { background: transparent; } } - .tool-mention-label { + .chat-suggestion-icon { + font-size: 16px; + flex-shrink: 0; + } + + .chat-suggestion-label { font-size: 13px; font-weight: 500; color: var(--text-strong); - margin-bottom: 2px; - text-align: left; - } - - .tool-mention-description { - font-size: 11px; - color: var(--text-medium); - margin-top: 4px; - line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } } -} \ No newline at end of file +} From 6c43dd0b98580043382a039da40070f34994e5a9 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 16 Jan 2026 13:46:45 +0800 Subject: [PATCH 2/8] feat: fuzzy search for file path search --- Cargo.lock | 19 +++++ electron/main/ipc/path.ts | 76 +++++++++++++++++ package-lock.json | 7 ++ package.json | 1 + src-tauri/Cargo.toml | 2 + src-tauri/src/command/path.rs | 113 ++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/components/ChatInput.tsx | 94 +++++++++++++++++++-- src/components/SuggestionMenu.tsx | 40 +++++---- src/ipc/path.ts | 8 ++ src/locales/de/translation.json | 3 + src/locales/en/translation.json | 3 + src/locales/es/translation.json | 3 + src/locales/fi/translation.json | 3 + src/locales/fil/translation.json | 3 + src/locales/fr/translation.json | 3 + src/locales/id/translation.json | 3 + src/locales/it/translation.json | 3 + src/locales/ja/translation.json | 3 + src/locales/ko/translation.json | 3 + src/locales/lo/translation.json | 3 + src/locales/no/translation.json | 3 + src/locales/pl/translation.json | 3 + src/locales/pt/translation.json | 3 + src/locales/ru/translation.json | 3 + src/locales/sv/translation.json | 3 + src/locales/th/translation.json | 3 + src/locales/tr/translation.json | 3 + src/locales/uk/translation.json | 3 + src/locales/vi/translation.json | 3 + src/locales/zh-CN/translation.json | 3 + src/locales/zh-TW/translation.json | 3 + src/styles/components/_ChatInput.scss | 17 +++- 33 files changed, 419 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c3ebc27..f1f1f1d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ dependencies = [ "fluent-uri", "futures", "futures-util", + "fuzzy-matcher", "hostname", "image", "libc", @@ -2099,6 +2100,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -6815,6 +6825,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.10.3" diff --git a/electron/main/ipc/path.ts b/electron/main/ipc/path.ts index 3f6ad6e4..ee820d68 100644 --- a/electron/main/ipc/path.ts +++ b/electron/main/ipc/path.ts @@ -2,6 +2,7 @@ import { ipcMain, BrowserWindow } from "electron" import * as fs from "fs" import * as path from "path" import * as os from "os" +import fuzzysort from "fuzzysort" interface PathEntry { name: string @@ -85,4 +86,79 @@ export function ipcPathHandler(_win: BrowserWindow) { return { entries: [], error: message } } }) + + ipcMain.handle("path:fuzzy-search", async (_event, basePath: string, query: string): Promise => { + try { + // Expand ~ to home directory + let normalizedPath = basePath + if (normalizedPath.startsWith("~")) { + normalizedPath = path.join(os.homedir(), normalizedPath.slice(1)) + } + + // Check if directory exists + if (!fs.existsSync(normalizedPath)) { + return { entries: [], error: "Directory not found" } + } + + const stat = fs.statSync(normalizedPath) + if (!stat.isDirectory()) { + return { entries: [], error: "Not a directory" } + } + + // Recursively collect all files (with depth limit) + const allPaths: { relativePath: string; fullPath: string; isDir: boolean }[] = [] + const maxDepth = 5 + + function walkDir(dir: string, depth: number, baseDir: string) { + if (depth > maxDepth) return + + try { + const items = fs.readdirSync(dir, { withFileTypes: true }) + for (const item of items) { + // Skip hidden files unless query starts with . + if (item.name.startsWith('.') && !query.startsWith('.')) { + continue + } + + const fullPath = path.join(dir, item.name) + const relativePath = path.relative(baseDir, fullPath) + + allPaths.push({ + relativePath, + fullPath, + isDir: item.isDirectory() + }) + + // Recurse into directories + if (item.isDirectory()) { + walkDir(fullPath, depth + 1, baseDir) + } + } + } catch { + // Ignore permission errors + } + } + + walkDir(normalizedPath, 0, normalizedPath) + + // Fuzzy search using fuzzysort + const results = fuzzysort.go(query, allPaths, { + key: "relativePath", + limit: 20, + threshold: -10000 + }) + + // Map results to PathEntry + const entries: PathEntry[] = results.map(result => ({ + name: result.obj.relativePath, + path: result.obj.fullPath, + isDir: result.obj.isDir + })) + + return { entries } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error" + return { entries: [], error: message } + } + }) } diff --git a/package-lock.json b/package-lock.json index ae0fdbc3..7818eafc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "electron-store": "^10.0.1", "electron-updater": "^6.6.2", "fs-extra": "^11.3.0", + "fuzzysort": "^3.1.0", "ollama": "^0.6.3", "openai": "^6.15.0", "react-chartjs-2": "^5.3.1", @@ -13850,6 +13851,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index 5d5494f2..4aec07ab 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "electron-store": "^10.0.1", "electron-updater": "^6.6.2", "fs-extra": "^11.3.0", + "fuzzysort": "^3.1.0", "ollama": "^0.6.3", "openai": "^6.15.0", "react-chartjs-2": "^5.3.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fa88c70d..ea9f5ffa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -54,6 +54,8 @@ which = "8.0.0" mime_guess = "2" fluent-uri = "0.4.1" fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", rev = "c4c45d503ea115a839aae718d02f79e7c7f0f673" } +fuzzy-matcher = "0.3" +walkdir = "2" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-autostart = "2" diff --git a/src-tauri/src/command/path.rs b/src-tauri/src/command/path.rs index 02e4929b..38e08d4b 100644 --- a/src-tauri/src/command/path.rs +++ b/src-tauri/src/command/path.rs @@ -1,5 +1,8 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use walkdir::WalkDir; #[derive(Debug, Serialize, Deserialize)] pub struct PathEntry { @@ -98,3 +101,113 @@ fn search_path_impl(search_path: &str) -> Result, Box PathSearchResult { + match fuzzy_search_impl(&base_path, &query) { + Ok(entries) => PathSearchResult { + entries, + error: None, + }, + Err(e) => PathSearchResult { + entries: vec![], + error: Some(e.to_string()), + }, + } +} + +fn fuzzy_search_impl( + base_path: &str, + query: &str, +) -> Result, Box> { + // Expand ~ to home directory + let normalized_path = if base_path.starts_with('~') { + let home = dirs::home_dir().ok_or("Could not find home directory")?; + home.join(&base_path[1..]) + } else { + PathBuf::from(base_path) + }; + + // Check if directory exists + if !normalized_path.exists() || !normalized_path.is_dir() { + return Ok(vec![]); + } + + // Collect all files recursively (limit depth to avoid too deep traversal) + let mut all_paths: Vec<(String, String, bool)> = Vec::new(); + for entry in WalkDir::new(&normalized_path) + .max_depth(5) + .follow_links(false) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + + // Skip hidden files unless query starts with . + if name.starts_with('.') && !query.starts_with('.') { + continue; + } + + // Skip the base directory itself + if path == normalized_path { + continue; + } + + // Get relative path from base for matching + let relative_path = path + .strip_prefix(&normalized_path) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + let is_dir = entry.file_type().is_dir(); + all_paths.push((relative_path, path.to_string_lossy().to_string(), is_dir)); + } + + // Create skim fuzzy matcher + let matcher = SkimMatcherV2::default(); + + // Score and filter paths + let mut scored_entries: Vec<(i64, PathEntry)> = all_paths + .iter() + .filter_map(|(relative_path, full_path, is_dir)| { + if query.is_empty() { + return Some(( + 0, + PathEntry { + name: relative_path.clone(), + path: full_path.clone(), + is_dir: *is_dir, + }, + )); + } + + matcher.fuzzy_match(relative_path, query).map(|score| { + ( + score, + PathEntry { + name: relative_path.clone(), + path: full_path.clone(), + is_dir: *is_dir, + }, + ) + }) + }) + .collect(); + + // Sort by score (highest first), then directories first, then by name + scored_entries.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| match (a.1.is_dir, b.1.is_dir) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.1.name.to_lowercase().cmp(&b.1.name.to_lowercase()), + }) + }); + + // Take top 20 results + let entries: Vec = scored_entries.into_iter().take(20).map(|(_, e)| e).collect(); + + Ok(entries) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 36d094ed..b3300e86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -208,6 +208,7 @@ pub fn run() { command::oap::oap_get_mcp_tags, // path command::path::path_search, + command::path::path_fuzzy_search, ]) .append_invoke_initialization_script(include_str!("../../shared/preload.js")) .build(tauri::generate_context!()); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 40b7c72c..6dfb68b2 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -19,7 +19,7 @@ import Button from "./Button" import { invokeIPC, isTauri } from "../ipc" import ToolDropDown from "./ToolDropDown" import { historiesAtom } from "../atoms/historyState" -import { searchPath, type PathEntry } from "../ipc/path" +import { searchPath, fuzzySearchPath, type PathEntry } from "../ipc/path" import SuggestionMenu from "./SuggestionMenu" interface Props { @@ -78,6 +78,11 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const [pathEntries, setPathEntries] = useState([]) const [isSearchingPath, setIsSearchingPath] = useState(false) + // Fuzzy search mode states + const [isFuzzyMode, setIsFuzzyMode] = useState(false) + const [fuzzyBasePath, setFuzzyBasePath] = useState("") + const [fuzzyQuery, setFuzzyQuery] = useState("") + // Calculate chat key for draft storage const chatKey = page === "welcome" ? "__new_chat__" : currentChatId || "__new_chat__" const getMessage = () => new Promise((resolve) => { @@ -448,6 +453,31 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } }, []) + // Debounced fuzzy path search + const fuzzySearchPathDebounced = useMemo(() => { + let timeoutId: ReturnType | null = null + return (basePath: string, query: string) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(async () => { + setIsSearchingPath(true) + try { + const result = await fuzzySearchPath(basePath, query) + if (!result.error) { + setPathEntries(result.entries) + } else { + setPathEntries([]) + } + } catch { + setPathEntries([]) + } finally { + setIsSearchingPath(false) + } + }, 150) + } + }, []) + const handleTextareaChange = (e: React.ChangeEvent) => { const newValue = e.target.value const cursorPos = e.target.selectionStart @@ -486,14 +516,28 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) setSelectedPathIndex(0) setShowToolMenu(false) - // Trigger path search (strip the @ prefix for the actual search) - searchPathDebounced(currentToken.substring(1)) + if (isFuzzyMode && fuzzyBasePath) { + // In fuzzy mode - extract query from current token + const pathPart = currentToken.substring(1) // Remove @ prefix + // Query is everything after the fuzzy base path + const query = pathPart.startsWith(fuzzyBasePath) + ? pathPart.substring(fuzzyBasePath.length) + : pathPart.substring(pathPart.lastIndexOf("/") + 1) + setFuzzyQuery(query) + fuzzySearchPathDebounced(fuzzyBasePath, query) + } else { + // Normal path search (strip the @ prefix for the actual search) + searchPathDebounced(currentToken.substring(1)) + } return } } // Hide path menu if no valid path pattern found setShowPathMenu(false) + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") // Check if @ symbol was just typed (original tool mention logic) const lastAtIndex = textBeforeCursor.lastIndexOf("@") @@ -560,6 +604,11 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) setMessage(newMessage) + // Reset fuzzy mode on selection + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + if (entry.isDir) { // Continue showing menu for directory navigation // pathSearchQuery includes @ prefix @@ -679,6 +728,31 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) if (e.key === "Escape") { e.preventDefault() setShowPathMenu(false) + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + return + } + // Ctrl+S to toggle fuzzy mode + if (e.ctrlKey && e.key === "s") { + e.preventDefault() + if (!isFuzzyMode) { + // Enter fuzzy mode - use current path as base + const currentPath = pathSearchQuery.substring(1) // Remove @ prefix + // Get the directory part of the current path + const basePath = currentPath.endsWith("/") ? currentPath : currentPath.substring(0, currentPath.lastIndexOf("/") + 1) || "/" + setIsFuzzyMode(true) + setFuzzyBasePath(basePath) + setFuzzyQuery("") + setPathEntries([]) + } else { + // Exit fuzzy mode + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + // Refresh with normal search + searchPathDebounced(pathSearchQuery.substring(1)) + } return } return @@ -915,11 +989,19 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) selectedIndex={selectedPathIndex} onSelectedIndexChange={setSelectedPathIndex} onSelect={(item) => selectPath({ name: item.name, path: item.path, isDir: item.isDir })} - onClose={() => setShowPathMenu(false)} + onClose={() => { + setShowPathMenu(false) + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + }} textareaRef={textareaRef} isLoading={isSearchingPath} - loadingContent={t("chat.searchingPath")} - emptyContent={t("chat.noPathResults")} + loadingContent={isFuzzyMode ? t("chat.fuzzySearching") : t("chat.searchingPath")} + emptyContent={isFuzzyMode ? t("chat.noFuzzyResults") : t("chat.noPathResults")} + headerContent={isFuzzyMode ? ( + <>🔍 {t("chat.fuzzyMode")} - {fuzzyBasePath} + ) : undefined} renderItem={(item) => ( <> diff --git a/src/components/SuggestionMenu.tsx b/src/components/SuggestionMenu.tsx index 90a5a2b6..7ba55a01 100644 --- a/src/components/SuggestionMenu.tsx +++ b/src/components/SuggestionMenu.tsx @@ -17,6 +17,7 @@ interface SuggestionMenuProps { emptyContent?: React.ReactNode loadingContent?: React.ReactNode isLoading?: boolean + headerContent?: React.ReactNode } function SuggestionMenu({ @@ -31,6 +32,7 @@ function SuggestionMenu({ emptyContent, loadingContent, isLoading = false, + headerContent, }: SuggestionMenuProps) { const menuRef = useRef(null) const positionRef = useRef({ top: 0, left: 0, width: 0 }) @@ -78,7 +80,8 @@ function SuggestionMenu({ // Scroll selected item into view useEffect(() => { if (show && menuRef.current) { - const selectedItem = menuRef.current.querySelector(".chat-suggestion-item.selected") + const itemsContainer = menuRef.current.querySelector(".chat-suggestion-items") + const selectedItem = itemsContainer?.querySelector(".chat-suggestion-item.selected") if (selectedItem) { selectedItem.scrollIntoView({ block: "nearest", @@ -104,22 +107,27 @@ function SuggestionMenu({ width: `${positionRef.current.width}px`, }} > - {isLoading ? ( -
{loadingContent}
- ) : items.length === 0 ? ( -
{emptyContent}
- ) : ( - items.map((item, index) => ( -
onSelect(item)} - onMouseEnter={() => onSelectedIndexChange(index)} - > - {renderItem(item, index === selectedIndex)} -
- )) + {headerContent && ( +
{headerContent}
)} +
+ {isLoading ? ( +
{loadingContent}
+ ) : items.length === 0 ? ( +
{emptyContent}
+ ) : ( + items.map((item, index) => ( +
onSelect(item)} + onMouseEnter={() => onSelectedIndexChange(index)} + > + {renderItem(item, index === selectedIndex)} +
+ )) + )} +
) } diff --git a/src/ipc/path.ts b/src/ipc/path.ts index 3edec678..fc695d9b 100644 --- a/src/ipc/path.ts +++ b/src/ipc/path.ts @@ -19,3 +19,11 @@ export async function searchPath(path: string): Promise { return invoke("path_search", { path }) } + +export async function fuzzySearchPath(basePath: string, query: string): Promise { + if (isElectron) { + return window.ipcRenderer.invoke("path:fuzzy-search", basePath, query) + } + + return invoke("path_fuzzy_search", { basePath, query }) +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 032d3faa..fdefcd2f 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Suche...", "noPathResults": "Keine Dateien oder Ordner gefunden", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Autorisierungsfehler beim Aufrufen des Tools", "description": "Die Autorisierung für dieses Tool ist abgelaufen oder wurde widerrufen, daher kann das Tool nicht mehr verwendet werden. Eine neue Autorisierung ist erforderlich, um die volle Funktionalität wiederherzustellen.", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 630f89ed..034c750f 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Searching...", "noPathResults": "No files or folders found", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Error occurred when trying to call the tool", "description": "The authorization for this tool has expired or been canceled, so the tool cannot be used. A new authorization is required to restore full functionality.", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 6ca282cd..f6b7cd09 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Buscando...", "noPathResults": "No se encontraron archivos o carpetas", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Error de autorización al intentar llamar a la herramienta", "description": "La autorización de este módulo ha expirado o ha sido cancelada, por lo que no se puede continuar usando la herramienta. Se requiere una nueva autorización para restaurar la funcionalidad completa.", diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index e4a8d5ce..adbac0bb 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Searching...", "noPathResults": "No files or folders found", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Valtuutusvirhe työkalua kutsuttaessa", "description": "Tämän työkalun valtuutus on vanhentunut tai peruutettu, joten työkalua ei voi käyttää. Uusi valtuutus vaaditaan täyden toiminnallisuuden palauttamiseksi.", diff --git a/src/locales/fil/translation.json b/src/locales/fil/translation.json index 8493e935..e26890eb 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Searching...", "noPathResults": "No files or folders found", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Naganap ang error sa authorization habang tinatawag ang tool", "description": "Nag-expire na o na-cancel ang authorization para sa tool na ito, kaya hindi na magagamit ang tool. Kailangan ng bagong authorization para maibalik ang buong functionality.", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 0735e317..b6aa4c0d 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Recherche...", "noPathResults": "Aucun fichier ou dossier trouvé", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Erreur d'autorisation lors de l'appel de l'outil", "description": "L'autorisation de cet outil a expiré ou a été annulée, l'outil ne peut donc plus être utilisé. Une nouvelle autorisation est requise pour restaurer toutes les fonctionnalités.", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index 5bc85aa7..c41bac5f 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Mencari...", "noPathResults": "Tidak ada file atau folder yang ditemukan", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Kesalahan otorisasi saat memanggil alat", "description": "Otorisasi untuk alat ini telah kedaluwarsa atau dibatalkan, sehingga alat tidak dapat digunakan. Otorisasi baru diperlukan untuk memulihkan fungsionalitas penuh.", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 20256387..64c9dc0c 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Ricerca...", "noPathResults": "Nessun file o cartella trovato", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Errore di autorizzazione durante la chiamata dello strumento", "description": "L'autorizzazione per questo strumento è scaduta o è stata annullata, quindi lo strumento non può essere utilizzato. È richiesta una nuova autorizzazione per ripristinare la funzionalità completa.", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 680f728c..46855223 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "検索中...", "noPathResults": "ファイルまたはフォルダが見つかりません", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "ツール呼び出し時に認証エラーが発生しました", "description": "このツールの認証が期限切れまたは取り消されたため、ツールを継続して使用できません。完全な機能を復元するには、再認証が必要です。", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 112d5a62..8b06881f 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "검색 중...", "noPathResults": "파일 또는 폴더를 찾을 수 없습니다", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "도구 호출 시 권한 오류 발생", "description": "이 도구의 권한이 만료되었거나 취소되어 도구를 계속 사용할 수 없습니다. 전체 기능을 복구하려면 다시 권한을 부여해야 합니다.", diff --git a/src/locales/lo/translation.json b/src/locales/lo/translation.json index 5a5acf32..5f0d75f9 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Searching...", "noPathResults": "No files or folders found", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "ເກີດຂໍ້ຜິດພາດການອະນຸຍາດເມື່ອເອີ້ນໃຊ້ເຄື່ອງມື", "description": "ການອະນຸຍາດສຳລັບເຄື່ອງມືນີ້ໝົດອາຍຸຫຼືຖືກຍົກເລີກແລ້ວ ດັ່ງນັ້ນເຄື່ອງມືຈຶ່ງບໍ່ສາມາດນຳໃຊ້ຕໍ່ໄດ້ ຕ້ອງການການອະນຸຍາດໃໝ່ເພື່ອຟື້ນຟູຟັງຊັນການເຮັດວຽກທັງໝົດ", diff --git a/src/locales/no/translation.json b/src/locales/no/translation.json index 413ee043..93e22f48 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Searching...", "noPathResults": "No files or folders found", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Autorisasjonsfeil ved anrop av verktøyet", "description": "Autorisasjonen for dette verktøyet har utløpt eller blitt kansellert, så verktøyet kan ikke brukes. Ny autorisasjon kreves for å gjenopprette full funksjonalitet.", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index f3a725c9..81525aea 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Wyszukiwanie...", "noPathResults": "Nie znaleziono plików ani folderów", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Błąd autoryzacji podczas wywoływania narzędzia", "description": "Autoryzacja dla tego narzędzia wygasła lub została anulowana, więc narzędzie nie może być używane. Wymagana jest nowa autoryzacja, aby przywrócić pełną funkcjonalność.", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 615a6597..5b2eb361 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Pesquisando...", "noPathResults": "Nenhum arquivo ou pasta encontrado", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Erro de autorização ao tentar chamar a ferramenta", "description": "A autorização desta ferramenta expirou ou foi cancelada, portanto a ferramenta não pode ser usada. Uma nova autorização é necessária para restaurar a funcionalidade completa.", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index d32a154a..af3d58d5 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Поиск...", "noPathResults": "Файлы или папки не найдены", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Ошибка авторизации при вызове инструмента", "description": "Авторизация для этого инструмента истекла или была отменена, поэтому инструмент не может быть использован. Требуется новая авторизация для восстановления полной функциональности.", diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index ce4db0f9..2b21e532 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Söker...", "noPathResults": "Inga filer eller mappar hittades", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Auktoriseringsfel vid anrop av verktyget", "description": "Auktoriseringen för detta verktyg har gått ut eller har avbrutits, så verktyget kan inte användas. Ny auktorisering krävs för att återställa full funktionalitet.", diff --git a/src/locales/th/translation.json b/src/locales/th/translation.json index dc475957..cf5f21e9 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "กำลังค้นหา...", "noPathResults": "ไม่พบไฟล์หรือโฟลเดอร์", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "เกิดข้อผิดพลาดในการอนุญาตเมื่อเรียกใช้เครื่องมือ", "description": "การอนุญาตสำหรับเครื่องมือนี้หมดอายุหรือถูกยกเลิกแล้ว ดังนั้นจึงไม่สามารถใช้เครื่องมือต่อไปได้ จำเป็นต้องมีการอนุญาตใหม่เพื่อฟื้นฟูฟังก์ชันการทำงานทั้งหมด", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index efeea8fd..5b53524e 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Aranıyor...", "noPathResults": "Dosya veya klasör bulunamadı", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Araç çağrılırken yetkilendirme hatası oluştu", "description": "Bu araç için yetkilendirme süresi dolmuş veya iptal edilmiş, bu nedenle araç kullanılamıyor. Tam işlevselliği geri yüklemek için yeni bir yetkilendirme gereklidir.", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 97995ca9..4e109a9c 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Пошук...", "noPathResults": "Файли або папки не знайдено", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Помилка авторизації при виклику інструменту", "description": "Авторизація для цього інструменту закінчилася або була скасована, тому інструмент не може бути використаний. Потрібна нова авторизація для відновлення повної функціональності.", diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json index 8fb762fa..f3154ade 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "Đang tìm kiếm...", "noPathResults": "Không tìm thấy tệp hoặc thư mục", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "Lỗi ủy quyền khi gọi công cụ", "description": "Ủy quyền cho công cụ này đã hết hạn hoặc bị hủy, vì vậy không thể tiếp tục sử dụng công cụ. Cần ủy quyền mới để khôi phục đầy đủ chức năng.", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 93514b31..fda7dd22 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "搜索中...", "noPathResults": "找不到文件或文件夹", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "尝试调用工具时,授权发生错误", "description": "此工具的授权已过期或已被取消,无法继续使用工具。需要重新授权以恢复完整功能。", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 05f8cb94..8f6bec00 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -55,6 +55,9 @@ }, "searchingPath": "搜尋中...", "noPathResults": "找不到檔案或資料夾", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", "reAuthorize": { "title": "嘗試呼叫工具時,授權發生錯誤", "description": "此工具的授權已過期或已被取消,無法繼續使用工具。需要重新授權以恢復完整功能。", diff --git a/src/styles/components/_ChatInput.scss b/src/styles/components/_ChatInput.scss index b705cd27..f794c2e7 100644 --- a/src/styles/components/_ChatInput.scss +++ b/src/styles/components/_ChatInput.scss @@ -424,13 +424,24 @@ border: 1px solid var(--stroke-gray-medium); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - max-height: 150px; - overflow-y: auto; z-index: 1000; transform: translateY(-100%); margin-top: -8px; - @include scrollbar; + .chat-suggestion-header { + padding: 4px 8px; + font-size: 11px; + color: var(--text-pri); + background: var(--bg-pri-weak); + border-bottom: 1px solid var(--stroke-gray-weak); + } + + .chat-suggestion-items { + max-height: 150px; + overflow-y: auto; + + @include scrollbar; + } .chat-suggestion-item { padding: 4px 8px; From 697b0bfd9566aab24a2b1e30301d52f87994f480 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 16 Jan 2026 16:41:51 +0800 Subject: [PATCH 3/8] feat: added search history for @ keyword suggestion --- src/components/ChatInput.tsx | 79 +++++++++++++++++++++++------- src/locales/de/translation.json | 1 + src/locales/en/translation.json | 1 + src/locales/es/translation.json | 1 + src/locales/fi/translation.json | 1 + src/locales/fil/translation.json | 1 + src/locales/fr/translation.json | 1 + src/locales/id/translation.json | 1 + src/locales/it/translation.json | 1 + src/locales/ja/translation.json | 1 + src/locales/ko/translation.json | 1 + src/locales/lo/translation.json | 1 + src/locales/no/translation.json | 1 + src/locales/pl/translation.json | 1 + src/locales/pt/translation.json | 1 + src/locales/ru/translation.json | 1 + src/locales/sv/translation.json | 1 + src/locales/th/translation.json | 1 + src/locales/tr/translation.json | 1 + src/locales/uk/translation.json | 1 + src/locales/vi/translation.json | 1 + src/locales/zh-CN/translation.json | 1 + src/locales/zh-TW/translation.json | 1 + 23 files changed, 84 insertions(+), 17 deletions(-) diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 6dfb68b2..5bff6ed7 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -33,6 +33,8 @@ const ACCEPTED_FILE_TYPES = "*" const MESSAGE_HISTORY_KEY = "chat-input-message-history" const MAX_HISTORY_SIZE = 50 +const RECENT_PATHS_KEY = "chat-input-recent-paths" +const MAX_RECENT_PATHS = 5 const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) => { const { t } = useTranslation() @@ -83,6 +85,16 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const [fuzzyBasePath, setFuzzyBasePath] = useState("") const [fuzzyQuery, setFuzzyQuery] = useState("") + // Recent paths for quick access + const [recentPaths, setRecentPaths] = useState(() => { + try { + const saved = localStorage.getItem(RECENT_PATHS_KEY) + return saved ? JSON.parse(saved) : [] + } catch { + return [] + } + }) + // Calculate chat key for draft storage const chatKey = page === "welcome" ? "__new_chat__" : currentChatId || "__new_chat__" const getMessage = () => new Promise((resolve) => { @@ -428,6 +440,41 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) ) }, [toolSearchQuery, getToolOptions]) + // Determine if we should show recent paths (initial state when entering path search mode) + const isInitialPathSearch = useMemo(() => { + // Check if path query is just "@/" or "@X:/" (initial trigger) + const query = pathSearchQuery + const isUnixInitial = query === "@/" + const isWindowsInitial = /^@[A-Za-z]:[\\/]$/.test(query) + // Show recent paths when query is just the initial trigger and we have recent paths + return (isUnixInitial || isWindowsInitial) && recentPaths.length > 0 + }, [pathSearchQuery, recentPaths.length]) + + // Get path menu items (recent paths or search results) + const getPathMenuItems = useMemo(() => { + if (isInitialPathSearch) { + return recentPaths + } + return pathEntries + }, [isInitialPathSearch, recentPaths, pathEntries]) + + // Save path to recent paths + const saveRecentPath = useCallback((entry: PathEntry) => { + setRecentPaths(prev => { + // Remove if already exists + const filtered = prev.filter(p => p.path !== entry.path) + // Add to beginning + const updated = [entry, ...filtered].slice(0, MAX_RECENT_PATHS) + // Save to localStorage + try { + localStorage.setItem(RECENT_PATHS_KEY, JSON.stringify(updated)) + } catch { + // Ignore localStorage errors + } + return updated + }) + }, []) + // Debounced path search const searchPathDebounced = useMemo(() => { let timeoutId: ReturnType | null = null @@ -604,6 +651,9 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) setMessage(newMessage) + // Save to recent paths + saveRecentPath(entry) + // Reset fuzzy mode on selection setIsFuzzyMode(false) setFuzzyBasePath("") @@ -629,7 +679,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) } }, 0) - }, [message, pathStartPos, pathSearchQuery, searchPathDebounced]) + }, [message, pathStartPos, pathSearchQuery, searchPathDebounced, saveRecentPath]) const saveMessageToHistory = (msg: string) => { if (!msg.trim()) { @@ -708,9 +758,10 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const onKeydown = (e: React.KeyboardEvent) => { // Handle path menu keyboard navigation if (showPathMenu) { + const menuItems = getPathMenuItems if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault() - setSelectedPathIndex(prev => Math.min(prev + 1, pathEntries.length - 1)) + setSelectedPathIndex(prev => Math.min(prev + 1, menuItems.length - 1)) return } if (e.key === "ArrowUp" || (e.ctrlKey && e.key === "p")) { @@ -720,8 +771,8 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey && !e.altKey)) { e.preventDefault() - if (pathEntries[selectedPathIndex]) { - selectPath({ name: pathEntries[selectedPathIndex].name, path: pathEntries[selectedPathIndex].path, isDir: pathEntries[selectedPathIndex].isDir }) + if (menuItems[selectedPathIndex]) { + selectPath({ name: menuItems[selectedPathIndex].name, path: menuItems[selectedPathIndex].path, isDir: menuItems[selectedPathIndex].isDir }) } return } @@ -835,11 +886,11 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) } } - if ((e.key !== "Enter" && e.key !== "Escape") || e.shiftKey || isComposing.current) { + if (e.key !== "Enter" || e.shiftKey || isComposing.current) { return } - if (e.key === "Enter" && e.altKey) { + if (e.altKey) { e.preventDefault() const textarea = e.currentTarget const start = textarea.selectionStart @@ -852,15 +903,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return } - if (e.key === "Enter" && (messageDisabled || disabled)) { - return - } - - if (e.key === "Escape" && disabled) { - e.stopPropagation() - e.preventDefault() - setIsAborting(true) - onAbort() + if (messageDisabled || disabled) { return } @@ -985,7 +1028,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) /> ({ key: entry.path, ...entry }))} + items={getPathMenuItems.map(entry => ({ key: entry.path, ...entry }))} selectedIndex={selectedPathIndex} onSelectedIndexChange={setSelectedPathIndex} onSelect={(item) => selectPath({ name: item.name, path: item.path, isDir: item.isDir })} @@ -1001,13 +1044,15 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) emptyContent={isFuzzyMode ? t("chat.noFuzzyResults") : t("chat.noPathResults")} headerContent={isFuzzyMode ? ( <>🔍 {t("chat.fuzzyMode")} - {fuzzyBasePath} + ) : isInitialPathSearch ? ( + <>🕐 {t("chat.recentPaths")} ) : undefined} renderItem={(item) => ( <> {item.isDir ? "📁" : "📄"} - {item.name} + {isInitialPathSearch ? item.path : item.name} )} /> diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index fdefcd2f..1f379471 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Zuletzt verwendet", "reAuthorize": { "title": "Autorisierungsfehler beim Aufrufen des Tools", "description": "Die Autorisierung für dieses Tool ist abgelaufen oder wurde widerrufen, daher kann das Tool nicht mehr verwendet werden. Eine neue Autorisierung ist erforderlich, um die volle Funktionalität wiederherzustellen.", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 034c750f..8c9aa2c6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Recent Paths", "reAuthorize": { "title": "Error occurred when trying to call the tool", "description": "The authorization for this tool has expired or been canceled, so the tool cannot be used. A new authorization is required to restore full functionality.", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index f6b7cd09..73201561 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Rutas recientes", "reAuthorize": { "title": "Error de autorización al intentar llamar a la herramienta", "description": "La autorización de este módulo ha expirado o ha sido cancelada, por lo que no se puede continuar usando la herramienta. Se requiere una nueva autorización para restaurar la funcionalidad completa.", diff --git a/src/locales/fi/translation.json b/src/locales/fi/translation.json index adbac0bb..60dc6fe0 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Viimeaikaiset polut", "reAuthorize": { "title": "Valtuutusvirhe työkalua kutsuttaessa", "description": "Tämän työkalun valtuutus on vanhentunut tai peruutettu, joten työkalua ei voi käyttää. Uusi valtuutus vaaditaan täyden toiminnallisuuden palauttamiseksi.", diff --git a/src/locales/fil/translation.json b/src/locales/fil/translation.json index e26890eb..65918047 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Kamakailang mga path", "reAuthorize": { "title": "Naganap ang error sa authorization habang tinatawag ang tool", "description": "Nag-expire na o na-cancel ang authorization para sa tool na ito, kaya hindi na magagamit ang tool. Kailangan ng bagong authorization para maibalik ang buong functionality.", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index b6aa4c0d..f35f9d68 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Chemins récents", "reAuthorize": { "title": "Erreur d'autorisation lors de l'appel de l'outil", "description": "L'autorisation de cet outil a expiré ou a été annulée, l'outil ne peut donc plus être utilisé. Une nouvelle autorisation est requise pour restaurer toutes les fonctionnalités.", diff --git a/src/locales/id/translation.json b/src/locales/id/translation.json index c41bac5f..7b33f524 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Jalur terbaru", "reAuthorize": { "title": "Kesalahan otorisasi saat memanggil alat", "description": "Otorisasi untuk alat ini telah kedaluwarsa atau dibatalkan, sehingga alat tidak dapat digunakan. Otorisasi baru diperlukan untuk memulihkan fungsionalitas penuh.", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 64c9dc0c..d199f6a1 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Percorsi recenti", "reAuthorize": { "title": "Errore di autorizzazione durante la chiamata dello strumento", "description": "L'autorizzazione per questo strumento è scaduta o è stata annullata, quindi lo strumento non può essere utilizzato. È richiesta una nuova autorizzazione per ripristinare la funzionalità completa.", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 46855223..47bff132 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "最近のパス", "reAuthorize": { "title": "ツール呼び出し時に認証エラーが発生しました", "description": "このツールの認証が期限切れまたは取り消されたため、ツールを継続して使用できません。完全な機能を復元するには、再認証が必要です。", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index 8b06881f..dae517b1 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "최근 경로", "reAuthorize": { "title": "도구 호출 시 권한 오류 발생", "description": "이 도구의 권한이 만료되었거나 취소되어 도구를 계속 사용할 수 없습니다. 전체 기능을 복구하려면 다시 권한을 부여해야 합니다.", diff --git a/src/locales/lo/translation.json b/src/locales/lo/translation.json index 5f0d75f9..e1228bc3 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "ເສັ້ນທາງຫຼ້າສຸດ", "reAuthorize": { "title": "ເກີດຂໍ້ຜິດພາດການອະນຸຍາດເມື່ອເອີ້ນໃຊ້ເຄື່ອງມື", "description": "ການອະນຸຍາດສຳລັບເຄື່ອງມືນີ້ໝົດອາຍຸຫຼືຖືກຍົກເລີກແລ້ວ ດັ່ງນັ້ນເຄື່ອງມືຈຶ່ງບໍ່ສາມາດນຳໃຊ້ຕໍ່ໄດ້ ຕ້ອງການການອະນຸຍາດໃໝ່ເພື່ອຟື້ນຟູຟັງຊັນການເຮັດວຽກທັງໝົດ", diff --git a/src/locales/no/translation.json b/src/locales/no/translation.json index 93e22f48..8685336d 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Nylige stier", "reAuthorize": { "title": "Autorisasjonsfeil ved anrop av verktøyet", "description": "Autorisasjonen for dette verktøyet har utløpt eller blitt kansellert, så verktøyet kan ikke brukes. Ny autorisasjon kreves for å gjenopprette full funksjonalitet.", diff --git a/src/locales/pl/translation.json b/src/locales/pl/translation.json index 81525aea..baefbe61 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Ostatnie ścieżki", "reAuthorize": { "title": "Błąd autoryzacji podczas wywoływania narzędzia", "description": "Autoryzacja dla tego narzędzia wygasła lub została anulowana, więc narzędzie nie może być używane. Wymagana jest nowa autoryzacja, aby przywrócić pełną funkcjonalność.", diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 5b2eb361..90ea80b3 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Caminhos recentes", "reAuthorize": { "title": "Erro de autorização ao tentar chamar a ferramenta", "description": "A autorização desta ferramenta expirou ou foi cancelada, portanto a ferramenta não pode ser usada. Uma nova autorização é necessária para restaurar a funcionalidade completa.", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index af3d58d5..7e652b3c 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Недавние пути", "reAuthorize": { "title": "Ошибка авторизации при вызове инструмента", "description": "Авторизация для этого инструмента истекла или была отменена, поэтому инструмент не может быть использован. Требуется новая авторизация для восстановления полной функциональности.", diff --git a/src/locales/sv/translation.json b/src/locales/sv/translation.json index 2b21e532..a7a836de 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Senaste sökvägar", "reAuthorize": { "title": "Auktoriseringsfel vid anrop av verktyget", "description": "Auktoriseringen för detta verktyg har gått ut eller har avbrutits, så verktyget kan inte användas. Ny auktorisering krävs för att återställa full funktionalitet.", diff --git a/src/locales/th/translation.json b/src/locales/th/translation.json index cf5f21e9..46a4459c 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "เส้นทางล่าสุด", "reAuthorize": { "title": "เกิดข้อผิดพลาดในการอนุญาตเมื่อเรียกใช้เครื่องมือ", "description": "การอนุญาตสำหรับเครื่องมือนี้หมดอายุหรือถูกยกเลิกแล้ว ดังนั้นจึงไม่สามารถใช้เครื่องมือต่อไปได้ จำเป็นต้องมีการอนุญาตใหม่เพื่อฟื้นฟูฟังก์ชันการทำงานทั้งหมด", diff --git a/src/locales/tr/translation.json b/src/locales/tr/translation.json index 5b53524e..eed287da 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Son yollar", "reAuthorize": { "title": "Araç çağrılırken yetkilendirme hatası oluştu", "description": "Bu araç için yetkilendirme süresi dolmuş veya iptal edilmiş, bu nedenle araç kullanılamıyor. Tam işlevselliği geri yüklemek için yeni bir yetkilendirme gereklidir.", diff --git a/src/locales/uk/translation.json b/src/locales/uk/translation.json index 4e109a9c..ddd6fa62 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Нещодавні шляхи", "reAuthorize": { "title": "Помилка авторизації при виклику інструменту", "description": "Авторизація для цього інструменту закінчилася або була скасована, тому інструмент не може бути використаний. Потрібна нова авторизація для відновлення повної функціональності.", diff --git a/src/locales/vi/translation.json b/src/locales/vi/translation.json index f3154ade..5c53c2ff 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "Đường dẫn gần đây", "reAuthorize": { "title": "Lỗi ủy quyền khi gọi công cụ", "description": "Ủy quyền cho công cụ này đã hết hạn hoặc bị hủy, vì vậy không thể tiếp tục sử dụng công cụ. Cần ủy quyền mới để khôi phục đầy đủ chức năng.", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index fda7dd22..7a8df2ad 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "最近路径", "reAuthorize": { "title": "尝试调用工具时,授权发生错误", "description": "此工具的授权已过期或已被取消,无法继续使用工具。需要重新授权以恢复完整功能。", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 8f6bec00..f03c98f5 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -58,6 +58,7 @@ "fuzzySearching": "Fuzzy searching...", "noFuzzyResults": "No matches found", "fuzzyMode": "Fuzzy Mode", + "recentPaths": "最近路徑", "reAuthorize": { "title": "嘗試呼叫工具時,授權發生錯誤", "description": "此工具的授權已過期或已被取消,無法繼續使用工具。需要重新授權以恢復完整功能。", From 78235222c6c493ce025d0a2a08c302233fb12170 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 21 Jan 2026 11:34:52 +0800 Subject: [PATCH 4/8] feat: hilight at mentions --- src/styles/pages/_Chat.scss | 7 +++++++ src/views/Chat/Message.tsx | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/styles/pages/_Chat.scss b/src/styles/pages/_Chat.scss index f87effcf..d38a7cfb 100644 --- a/src/styles/pages/_Chat.scss +++ b/src/styles/pages/_Chat.scss @@ -208,6 +208,13 @@ left: 100%; transform: translate(-100%, 100%); } + + .at-mention { + background: rgba(255, 255, 255, 0.2); + padding: 1px 4px; + border-radius: 4px; + font-weight: 500; + } } &.received { diff --git a/src/views/Chat/Message.tsx b/src/views/Chat/Message.tsx index 3a53453d..17023414 100644 --- a/src/views/Chat/Message.tsx +++ b/src/views/Chat/Message.tsx @@ -230,13 +230,31 @@ const Message = ({ messageId, text, isSent, files, isError, isLoading, isRateLim ) }, [editedText]) + // Helper function to highlight @mentions in sent messages + const highlightAtMentions = (text: string) => { + // Match @ followed by a file path or word (e.g., @file.txt, @path/to/file, @mention) + const atMentionRegex = /(@[\w./\\-]+)/g + const parts = text.split(atMentionRegex) + + return parts.map((part, index) => { + if (part.match(atMentionRegex)) { + return ( + + {part} + + ) + } + return part + }) + } + const formattedText = useMemo(() => { const _text = isSent ? content : text if (isSent) { const splitText = _text.split("\n") return splitText.map((line, i) => ( - {line} + {highlightAtMentions(line)} {i < splitText.length - 1 &&
}
)) From 949bc27452af0fc7d1532c3cf9789f88bcd2a3d9 Mon Sep 17 00:00:00 2001 From: Johnny Date: Wed, 21 Jan 2026 13:43:43 +0800 Subject: [PATCH 5/8] fix: mouse hover cause infinitely scroll --- src/components/SuggestionMenu.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/SuggestionMenu.tsx b/src/components/SuggestionMenu.tsx index 7ba55a01..47dbc7f1 100644 --- a/src/components/SuggestionMenu.tsx +++ b/src/components/SuggestionMenu.tsx @@ -36,6 +36,7 @@ function SuggestionMenu({ }: SuggestionMenuProps) { const menuRef = useRef(null) const positionRef = useRef({ top: 0, left: 0, width: 0 }) + const isMouseNavigation = useRef(false) // Update menu position const updatePosition = useCallback(() => { @@ -77,9 +78,13 @@ function SuggestionMenu({ } }, [show, onClose, textareaRef]) - // Scroll selected item into view + // Scroll selected item into view (only for keyboard navigation, not mouse hover) useEffect(() => { if (show && menuRef.current) { + if (isMouseNavigation.current) { + isMouseNavigation.current = false + return + } const itemsContainer = menuRef.current.querySelector(".chat-suggestion-items") const selectedItem = itemsContainer?.querySelector(".chat-suggestion-item.selected") if (selectedItem) { @@ -121,7 +126,10 @@ function SuggestionMenu({ key={item.key} className={`chat-suggestion-item ${index === selectedIndex ? "selected" : ""}`} onClick={() => onSelect(item)} - onMouseEnter={() => onSelectedIndexChange(index)} + onMouseEnter={() => { + isMouseNavigation.current = true + onSelectedIndexChange(index) + }} > {renderItem(item, index === selectedIndex)} From 9f7e892f6b237bd92c242f7789357149a84f8f69 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 22 Jan 2026 10:10:46 +0800 Subject: [PATCH 6/8] feat: add env for builtln tools --- electron/main/constant.ts | 1 + electron/main/service.ts | 118 +++++++++++++++++++++++++++++++++++ scripts/download-node.ts | 4 +- src-tauri/src/dependency.rs | 55 ++++++++++++---- src-tauri/src/host.rs | 33 ++++++++-- src/components/ChatInput.tsx | 25 ++++++-- 6 files changed, 212 insertions(+), 24 deletions(-) diff --git a/electron/main/constant.ts b/electron/main/constant.ts index 2b0a03db..ffd692d7 100644 --- a/electron/main/constant.ts +++ b/electron/main/constant.ts @@ -30,6 +30,7 @@ export const envPath = envPaths(app.getName(), {suffix: ""}) export const cacheDir = envPath.cache export const homeDir = os.homedir() export const appDir = path.join(homeDir, ".dive") +export const binDir = path.join(appDir, "bin") export const scriptsDir = path.join(appDir, "scripts") export const configDir = app.isPackaged ? path.join(appDir, "config") : path.join(process.cwd(), ".config") export const hostCacheDir = path.join(appDir, "host_cache") diff --git a/electron/main/service.ts b/electron/main/service.ts index 85c7ef69..641529c3 100644 --- a/electron/main/service.ts +++ b/electron/main/service.ts @@ -2,6 +2,7 @@ import packageJson from "../../package.json" import { app, BrowserWindow } from "electron" import path from "node:path" import fse, { mkdirp } from "fs-extra" +import { pipeline } from "node:stream/promises" import { configDir, DEF_MCP_SERVER_CONFIG, @@ -15,6 +16,7 @@ import { DEF_PLUGIN_CONFIG, DEF_MCP_SERVER_NAME, getDefMcpBinPath, + binDir, } from "./constant.js" import spawn from "cross-spawn" import { ChildProcess, SpawnOptions, StdioOptions } from "node:child_process" @@ -25,6 +27,12 @@ import { hostCache } from "./store.js" const baseConfigDir = app.isPackaged ? configDir : path.join(__dirname, "..", "..", ".config") +// Node.js version for Linux +const NODEJS_VERSION = "22.22.0" +const NODEJS_FILE_NAME = `node-v${NODEJS_VERSION}-linux-x64` +const NODEJS_FILE = `${NODEJS_FILE_NAME}.tar.gz` +const NODEJS_URL = `https://nodejs.org/dist/v${NODEJS_VERSION}/${NODEJS_FILE}` + const onServiceUpCallbacks: ((ip: string, port: number) => Promise)[] = [] export const clearServiceUpCallbacks = () => onServiceUpCallbacks.length = 0 export const setServiceUpCallback = (callback: (ip: string, port: number) => Promise) => onServiceUpCallbacks.push(callback) @@ -180,6 +188,30 @@ async function startHostService() { DIVE_USER_AGENT: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Dive/${packageJson.version} (+https://github.com/OpenAgentPlatform/Dive)`, } + // Set tool paths for packaged app + if (app.isPackaged) { + // NPX path: Windows and Mac use resourcesPath, Linux uses downloaded path (~/.dive/bin/nodejs) + if (isWindows) { + httpdEnv.TOOL_NPX_PATH = path.join(resourcePath, "node", "npx.cmd") + } else if (process.platform === "darwin") { + httpdEnv.TOOL_NPX_PATH = path.join(resourcePath, "node", "bin", "npx") + } else { + // Linux - uses downloaded nodejs in ~/.dive/bin/nodejs + httpdEnv.TOOL_NPX_PATH = path.join(binDir, "nodejs", "bin", "npx") + } + + if (isWindows) { + httpdEnv.TOOL_UVX_PATH = path.join(resourcePath, "uv", "uvx.exe") + } else if (process.platform === "darwin") { + httpdEnv.TOOL_UVX_PATH = path.join(resourcePath, "uv", "uvx") + } else { + httpdEnv.TOOL_UVX_PATH = path.join(resourcePath, "uv", "uvx") + } + + console.log(`npx for builtin tool: ${httpdEnv.TOOL_NPX_PATH}`) + console.log(`uvx for builtin tool: ${httpdEnv.TOOL_UVX_PATH}`) + } + console.log("httpd executing path: ", httpdExec) const busPath = path.join(hostCacheDir, "bus") @@ -278,6 +310,79 @@ async function startHostService() { }) } +async function needToDownloadNodejs(): Promise { + if (process.platform !== "linux") { + return false + } + + const nodejsDir = path.join(binDir, "nodejs") + const nodePath = path.join(nodejsDir, "bin", "node") + + if (!(await fse.pathExists(nodePath))) { + return true + } + + try { + const result = spawn.sync(nodePath, ["-v"]) + const version = result.stdout?.toString().trim() + return version !== `v${NODEJS_VERSION}` + } catch { + return true + } +} + +async function downloadNodejs(win: BrowserWindow): Promise { + // Ensure binDir exists + await mkdirp(binDir) + + const nodejsDir = path.join(binDir, "nodejs") + const tmpDir = path.join(binDir, "nodejs_tmp") + + // Clean up existing directories + if (await fse.pathExists(nodejsDir)) { + await fse.remove(nodejsDir) + } + await mkdirp(nodejsDir) + await mkdirp(tmpDir) + + const nodejsFilePath = path.join(tmpDir, NODEJS_FILE) + + console.log(`downloading nodejs from ${NODEJS_URL}`) + installHostDependenciesLog.push(`downloading nodejs from ${NODEJS_URL}`) + win.webContents.send("install-host-dependencies-log", `downloading nodejs from ${NODEJS_URL}`) + + // Download the file + const response = await fetch(NODEJS_URL) + if (!response.ok) { + throw new Error(`Failed to download nodejs: ${response.statusText}`) + } + + const fileStream = fse.createWriteStream(nodejsFilePath) + // @ts-ignore - response.body is a ReadableStream + await pipeline(response.body, fileStream) + + console.log(`extracting nodejs to ${tmpDir}`) + installHostDependenciesLog.push(`extracting nodejs to ${tmpDir}`) + win.webContents.send("install-host-dependencies-log", `extracting nodejs to ${tmpDir}`) + + // Extract the tar.gz file using system tar command + await promiseSpawn("tar", ["-xzf", nodejsFilePath, "-C", tmpDir], tmpDir, "pipe") + + // Move extracted files to nodejs dir + const extractedDir = path.join(tmpDir, NODEJS_FILE_NAME) + const files = await fse.readdir(extractedDir) + for (const file of files) { + await fse.move(path.join(extractedDir, file), path.join(nodejsDir, file), { overwrite: true }) + } + + // Clean up + await fse.remove(tmpDir) + + console.log("download nodejs done") + installHostDependenciesLog.push("download nodejs done") + win.webContents.send("install-host-dependencies-log", "download nodejs done") +} + async function installHostDependencies(win: BrowserWindow) { const done = () => { win.webContents.send("install-host-dependencies-log", "finish") @@ -289,6 +394,19 @@ async function installHostDependencies(win: BrowserWindow) { } console.log("installing host dependencies") + + // Download Node.js for Linux if needed + if (process.platform === "linux") { + try { + if (await needToDownloadNodejs()) { + await downloadNodejs(win) + } + } catch (error) { + console.error("Failed to download nodejs:", error) + installHostDependenciesLog.push(`Failed to download nodejs: ${error}`) + win.webContents.send("install-host-dependencies-log", `Failed to download nodejs: ${error}`) + } + } const isWindows = process.platform === "win32" const pyBinPath = path.join(process.resourcesPath, "python", "bin") const pyPath = isWindows ? path.join(process.resourcesPath, "python", "python.exe") : path.join(pyBinPath, "python3") diff --git a/scripts/download-node.ts b/scripts/download-node.ts index 641f8eae..abfe408a 100755 --- a/scripts/download-node.ts +++ b/scripts/download-node.ts @@ -5,7 +5,7 @@ import { Extract as unzipper } from "unzipper" import tar from "tar" import { rimraf } from "rimraf" -const NODE_VERSION = "20.19.0" +const NODE_VERSION = "22.22.0" const PLATFORM = process.argv[2] || "win-x64" async function downloadFile(url: string, dest: string): Promise { @@ -85,4 +85,4 @@ async function main() { } } -main() \ No newline at end of file +main() diff --git a/src-tauri/src/dependency.rs b/src-tauri/src/dependency.rs index a1638d5f..54ae8fdb 100644 --- a/src-tauri/src/dependency.rs +++ b/src-tauri/src/dependency.rs @@ -1,7 +1,7 @@ use std::{ future::Future, path::{Path, PathBuf}, - process::{Child, Stdio}, + process::{Child, Stdio}, sync::LazyLock, }; use anyhow::Result; @@ -64,15 +64,28 @@ static UV_HASHES: phf::Map<&'static str, &'static str> = phf_map! { const PYTHON_VERSION: &str = "3.12.10"; -#[cfg(target_os = "windows")] -const NODEJS_VERSION: &str = "22.17.0"; +const NODEJS_VERSION: &str = "22.22.0"; + #[cfg(target_os = "windows")] const NODEJS_FILE_NAME: &str = formatcp!("node-v{NODEJS_VERSION}-win-x64"); #[cfg(target_os = "windows")] const NODEJS_FILE: &str = formatcp!("{NODEJS_FILE_NAME}.zip"); -#[cfg(target_os = "windows")] + +#[cfg(target_os = "linux")] +const NODEJS_FILE_NAME: &str = formatcp!("node-v{NODEJS_VERSION}-linux-x64"); +#[cfg(target_os = "linux")] +const NODEJS_FILE: &str = formatcp!("{NODEJS_FILE_NAME}.tar.gz"); + const NODEJS_URL: &str = formatcp!("https://nodejs.org/dist/v{NODEJS_VERSION}/{NODEJS_FILE}"); +pub static UV_BIN_DIR: LazyLock = LazyLock::new(|| { + PROJECT_DIRS.bin.join("uv") +}); + +pub static NODEJS_BIN_DIR: LazyLock = LazyLock::new(|| { + PROJECT_DIRS.bin.join("nodejs") +}); + pub struct DependencyDownloader { tx: mpsc::Sender, client: reqwest::Client, @@ -115,7 +128,6 @@ impl DependencyDownloader { Ok(()) }, async { - #[cfg(target_os = "windows")] if self.need_to_download_nodejs().await { self.download_nodejs().await?; } @@ -154,7 +166,7 @@ impl DependencyDownloader { .await; }; - let uv_dir = self.bin_dir.join("uv"); + let uv_dir = UV_BIN_DIR.clone(); let uv_archive_file_path = uv_dir.join(UV_FILE); let client = self.client.clone(); @@ -389,7 +401,7 @@ impl DependencyDownloader { .stderr(Stdio::piped()) .spawn()?; - if let Err(_) = self.handle_stdout("uv", &mut process).await { + if self.handle_stdout("uv", &mut process).await.is_err() { return self .on_error("Failed to install host dependencies".to_string()) .await; @@ -436,13 +448,13 @@ impl DependencyDownloader { uv_lock_file_md5 != UV_LOCK_MD5 } - #[cfg(target_os = "windows")] pub async fn download_nodejs(&self) -> Result<()> { - let nodejs_dir = self.bin_dir.join("nodejs"); + let nodejs_dir = NODEJS_BIN_DIR.clone(); let tmp_dir = self.bin_dir.join("nodejs_tmp"); let nodejs_file_path = tmp_dir.join(NODEJS_FILE); let client = self.client.clone(); + remove_dir_all(&nodejs_dir).await?; create_dir_all(&nodejs_dir).await?; create_dir_all(&tmp_dir).await?; @@ -469,9 +481,15 @@ impl DependencyDownloader { log::info!("extract nodejs to {}", nodejs_dir.display()); let extract_src = nodejs_file_path.clone(); let extract_dst = tmp_dir.clone(); + + #[cfg(target_os = "windows")] tauri::async_runtime::spawn_blocking(move || unzip_file(&extract_src, &extract_dst)) .await??; + #[cfg(target_os = "linux")] + tauri::async_runtime::spawn_blocking(move || extract_tar_gz(&extract_src, &extract_dst)) + .await??; + log::info!("move tmp file to nodejs dir"); rename(tmp_dir.join(NODEJS_FILE_NAME), nodejs_dir).await?; log::info!("remove nodejs archive file"); @@ -488,10 +506,25 @@ impl DependencyDownloader { } #[inline] - #[cfg(target_os = "windows")] pub async fn need_to_download_nodejs(&self) -> bool { let nodejs_dir = self.bin_dir.join("nodejs"); - !nodejs_dir.join("node.exe").exists() + let bin_path = if cfg!(windows) { + "node.exe" + } else { + "bin/node" + }; + + let execute_path = nodejs_dir.join(bin_path); + if !execute_path.exists() { + return true; + } + + log::info!("try to get exists nodejs version"); + let Ok(version) = Command::new(execute_path).arg("-v").output() else { + return true + }; + + String::from_utf8_lossy(&version.stdout).trim() == format!("v{NODEJS_VERSION}") } async fn handle_stdout(&self, logtag: &str, child: &mut Child) -> Result<()> { diff --git a/src-tauri/src/host.rs b/src-tauri/src/host.rs index 7c936b0e..d4dfc61a 100644 --- a/src-tauri/src/host.rs +++ b/src-tauri/src/host.rs @@ -11,7 +11,7 @@ use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}, }; -use crate::{process::command::Command, shared::{DEF_MCP_BIN_NAME, VERSION}}; +use crate::{dependency::{NODEJS_BIN_DIR, UV_BIN_DIR}, process::command::Command, shared::{DEF_MCP_BIN_NAME, VERSION}}; pub const COMMAND_ALIAS_FILE: &str = "command_alias.json"; pub const CUSTOM_RULES_FILE: &str = "customrules"; @@ -139,6 +139,31 @@ impl HostProcess { .stderr(Stdio::piped()) .stdout(Stdio::piped()); + // set bin path for builtin tools + // #[cfg(not(debug_assertions))] + { + let uvx_path = if cfg!(windows) { + UV_BIN_DIR.join("uvx.exe") + } else { + UV_BIN_DIR.join("uvx") + }; + + let npx_path = if cfg!(windows) { + NODEJS_BIN_DIR.join("npx.cmd") + } else { + NODEJS_BIN_DIR.join("bin/npx") + }; + + let npx_path = dunce::simplified(&npx_path).to_string_lossy().to_string(); + let uvx_path = dunce::simplified(&uvx_path).to_string_lossy().to_string(); + + log::info!("npx path for builtin tools: {npx_path}"); + log::info!("uvx path for builtin tools: {uvx_path}"); + + cmd.env("TOOL_NPX_PATH", npx_path); + cmd.env("TOOL_UVX_PATH", uvx_path); + } + log::info!("dived execute: {:?}", cmd.get_args()); let mut process = cmd.spawn()?; @@ -182,12 +207,10 @@ impl HostProcess { async fn init_host_config(&self, config_dir: &Path, db_dir: &Path) -> Result<()> { // alias file - let bin_dir = crate::shared::PROJECT_DIRS.bin.clone(); - let nodejs_bin = bin_dir.join("nodejs"); let alias_content = if cfg!(target_os = "windows") { json!({ - "npx": dunce::simplified(&nodejs_bin.join("npx.cmd")).to_string_lossy(), - "npm": dunce::simplified(&nodejs_bin.join("npm.cmd")).to_string_lossy() + "npx": dunce::simplified(&NODEJS_BIN_DIR.join("npx.cmd")).to_string_lossy(), + "npm": dunce::simplified(&NODEJS_BIN_DIR.join("npm.cmd")).to_string_lossy() }).to_string() } else { "{}".to_string() diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 5bff6ed7..5766540d 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -427,18 +427,31 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) return options }, [tools]) + // Built-in special options that show at the top when query is empty + const builtInOptions = useMemo(() => [ + { label: "@search", value: "search", isBuiltIn: true }, + ], []) + // Filter tool options based on search query const getFilteredToolOptions = useCallback(() => { const options = getToolOptions() + const query = toolSearchQuery.toLowerCase() + if (!toolSearchQuery) { - return options + // Show built-in options at top when query is empty + return [...builtInOptions, ...options] } - const query = toolSearchQuery.toLowerCase() - return options.filter((option) => + // Filter both built-in and tool options + const filteredBuiltIn = builtInOptions.filter((option) => option.label.toLowerCase().includes(query) ) - }, [toolSearchQuery, getToolOptions]) + const filteredTools = options.filter((option) => + option.label.toLowerCase().includes(query) + ) + + return [...filteredBuiltIn, ...filteredTools] + }, [toolSearchQuery, getToolOptions, builtInOptions]) // Determine if we should show recent paths (initial state when entering path search mode) const isInitialPathSearch = useMemo(() => { @@ -623,7 +636,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) const selectTool = useCallback((toolValue: string) => { const beforeMention = message.substring(0, mentionStartPos) const afterMention = message.substring(mentionStartPos + toolSearchQuery.length + 1) // +1 for @ - const newMessage = beforeMention + toolValue + " " + afterMention + const newMessage = beforeMention + "@" + toolValue + " " + afterMention setMessage(newMessage) setShowToolMenu(false) @@ -632,7 +645,7 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) // Focus back to textarea and set cursor position setTimeout(() => { if (textareaRef.current) { - const newCursorPos = beforeMention.length + toolValue.length + 1 + const newCursorPos = beforeMention.length + toolValue.length + 2 // +2 for @ and space textareaRef.current.focus() textareaRef.current.setSelectionRange(newCursorPos, newCursorPos) } From 93dc306488352d4ef45573549d2699a3580dfc3a Mon Sep 17 00:00:00 2001 From: john Date: Thu, 22 Jan 2026 10:34:26 +0800 Subject: [PATCH 7/8] fix: cfg condition --- src-tauri/src/host.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/host.rs b/src-tauri/src/host.rs index d4dfc61a..e6f13e7c 100644 --- a/src-tauri/src/host.rs +++ b/src-tauri/src/host.rs @@ -140,7 +140,7 @@ impl HostProcess { .stdout(Stdio::piped()); // set bin path for builtin tools - // #[cfg(not(debug_assertions))] + #[cfg(not(debug_assertions))] { let uvx_path = if cfg!(windows) { UV_BIN_DIR.join("uvx.exe") From af70bbe3b8037921fd0d40b9a656198eed0f5e80 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 22 Jan 2026 10:41:19 +0800 Subject: [PATCH 8/8] fix: need download nodejs logic for tauri --- src-tauri/src/dependency.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/dependency.rs b/src-tauri/src/dependency.rs index 54ae8fdb..9da8e79b 100644 --- a/src-tauri/src/dependency.rs +++ b/src-tauri/src/dependency.rs @@ -524,7 +524,9 @@ impl DependencyDownloader { return true }; - String::from_utf8_lossy(&version.stdout).trim() == format!("v{NODEJS_VERSION}") + let local_version = String::from_utf8_lossy(&version.stdout); + log::info!("local nodejs version: {}, expected nodejs version: v{NODEJS_VERSION}", &local_version.trim()); + local_version.trim() != format!("v{NODEJS_VERSION}") } async fn handle_stdout(&self, logtag: &str, child: &mut Child) -> Result<()> {