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/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/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..ee820d68 --- /dev/null +++ b/electron/main/ipc/path.ts @@ -0,0 +1,164 @@ +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 + 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 } + } + }) + + 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/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/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/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/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/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..38e08d4b --- /dev/null +++ b/src-tauri/src/command/path.rs @@ -0,0 +1,213 @@ +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 { + 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) +} + +#[tauri::command] +pub async fn path_fuzzy_search(base_path: String, query: String) -> 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/dependency.rs b/src-tauri/src/dependency.rs index a1638d5f..9da8e79b 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,27 @@ 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 + }; + + 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<()> { diff --git a/src-tauri/src/host.rs b/src-tauri/src/host.rs index 7c936b0e..e6f13e7c 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-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80373662..b3300e86 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -206,6 +206,9 @@ pub fn run() { // lipc command::lipc::response_mcp_elicitation, 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 1d30ac33..5766540d 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, fuzzySearchPath, type PathEntry } from "../ipc/path" +import SuggestionMenu from "./SuggestionMenu" interface Props { page: "welcome" | "chat" @@ -31,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() @@ -64,11 +68,32 @@ 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) + + // Fuzzy search mode states + const [isFuzzyMode, setIsFuzzyMode] = useState(false) + 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__" @@ -286,102 +311,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() @@ -498,73 +427,115 @@ 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) + ) + const filteredTools = options.filter((option) => option.label.toLowerCase().includes(query) ) - }, [toolSearchQuery, getToolOptions]) - // Calculate cursor position in pixels - const getCursorPosition = useCallback(() => { - if (!textareaRef.current) { - return { top: 0, left: 0 } + return [...filteredBuiltIn, ...filteredTools] + }, [toolSearchQuery, getToolOptions, builtInOptions]) + + // 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 } - - 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] + 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 }) + }, []) - 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) + // 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) + } + }, []) - return { top, left } + // 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) => { @@ -573,8 +544,62 @@ const ChatInput: React.FC = ({ page, onSendMessage, disabled, onAbort }) setMessage(newValue) - // Check if @ symbol was just typed const textBeforeCursor = newValue.substring(0, cursorPos) + + // 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 + } + } + + if (tokenStart !== -1) { + const currentToken = textBeforeCursor.substring(tokenStart) + + // 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) + + if (isUnixPath || isWindowsPath) { + // Store the full token including @ for replacement later + setPathSearchQuery(currentToken) + setPathStartPos(tokenStart) + setShowPathMenu(true) + setSelectedPathIndex(0) + setShowToolMenu(false) + + 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("@") if (lastAtIndex !== -1) { @@ -598,25 +623,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 } } @@ -630,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) @@ -639,13 +645,55 @@ 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) } }, 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) + + // Save to recent paths + saveRecentPath(entry) + + // Reset fuzzy mode on selection + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + + 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, saveRecentPath]) + const saveMessageToHistory = (msg: string) => { if (!msg.trim()) { return @@ -721,50 +769,90 @@ 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) { + const menuItems = getPathMenuItems + 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, menuItems.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 (menuItems[selectedPathIndex]) { + selectPath({ name: menuItems[selectedPathIndex].name, path: menuItems[selectedPathIndex].path, isDir: menuItems[selectedPathIndex].isDir }) + } + return + } + 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 + } - 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 +877,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 @@ -811,11 +899,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 @@ -828,15 +916,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 } @@ -947,34 +1027,48 @@ 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) + setIsFuzzyMode(false) + setFuzzyBasePath("") + setFuzzyQuery("") + }} + textareaRef={textareaRef} + isLoading={isSearchingPath} + loadingContent={isFuzzyMode ? t("chat.fuzzySearching") : t("chat.searchingPath")} + emptyContent={isFuzzyMode ? t("chat.noFuzzyResults") : t("chat.noPathResults")} + headerContent={isFuzzyMode ? ( + <>🔍 {t("chat.fuzzyMode")} - {fuzzyBasePath} + ) : isInitialPathSearch ? ( + <>🕐 {t("chat.recentPaths")} + ) : undefined} + renderItem={(item) => ( + <> + + {item.isDir ? "📁" : "📄"} + + {isInitialPathSearch ? item.path : item.name} + + )} + /> {previews.length > 0 && (
diff --git a/src/components/SuggestionMenu.tsx b/src/components/SuggestionMenu.tsx new file mode 100644 index 00000000..47dbc7f1 --- /dev/null +++ b/src/components/SuggestionMenu.tsx @@ -0,0 +1,143 @@ +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 + headerContent?: React.ReactNode +} + +function SuggestionMenu({ + show, + items, + selectedIndex, + onSelectedIndexChange, + onSelect, + onClose, + renderItem, + textareaRef, + emptyContent, + loadingContent, + isLoading = false, + headerContent, +}: SuggestionMenuProps) { + const menuRef = useRef(null) + const positionRef = useRef({ top: 0, left: 0, width: 0 }) + const isMouseNavigation = useRef(false) + + // 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 (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) { + selectedItem.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }) + } + } + }, [selectedIndex, show]) + + + if (!show) return null + + updatePosition() + + return ( +
+ {headerContent && ( +
{headerContent}
+ )} +
+ {isLoading ? ( +
{loadingContent}
+ ) : items.length === 0 ? ( +
{emptyContent}
+ ) : ( + items.map((item, index) => ( +
onSelect(item)} + onMouseEnter={() => { + isMouseNavigation.current = true + 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..fc695d9b --- /dev/null +++ b/src/ipc/path.ts @@ -0,0 +1,29 @@ +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 }) +} + +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 8a5a7524..1f379471 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -53,6 +53,12 @@ "enableToast": "Werkzeugaufrufe aktiviert", "enable": "Werkzeugaufrufe aktivieren" }, + "searchingPath": "Suche...", + "noPathResults": "Keine Dateien oder Ordner gefunden", + "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 7326454a..8c9aa2c6 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -53,6 +53,12 @@ "enableToast": "Tool Calls enabled", "enable": "Enable Tool Calls" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", + "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 62d81a8d..73201561 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -53,6 +53,12 @@ "enableToast": "Tool Calls activado", "enable": "Activar Tool Calls" }, + "searchingPath": "Buscando...", + "noPathResults": "No se encontraron archivos o carpetas", + "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 8fbd4693..60dc6fe0 100644 --- a/src/locales/fi/translation.json +++ b/src/locales/fi/translation.json @@ -53,6 +53,12 @@ "enableToast": "Työkalukutsut käytössä", "enable": "Ota työkalukutsut käyttöön" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", + "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 d60d6a03..65918047 100644 --- a/src/locales/fil/translation.json +++ b/src/locales/fil/translation.json @@ -53,6 +53,12 @@ "enableToast": "Na-enable ang Tool Calls", "enable": "I-enable ang Tool Calls" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", + "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 4c9a74fb..f35f9d68 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -53,6 +53,12 @@ "enableToast": "Appels d'outils activés", "enable": "Activer les appels d'outils" }, + "searchingPath": "Recherche...", + "noPathResults": "Aucun fichier ou dossier trouvé", + "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 644c8e10..7b33f524 100644 --- a/src/locales/id/translation.json +++ b/src/locales/id/translation.json @@ -53,6 +53,12 @@ "enableToast": "Panggilan Alat diaktifkan", "enable": "Aktifkan Panggilan Alat" }, + "searchingPath": "Mencari...", + "noPathResults": "Tidak ada file atau folder yang ditemukan", + "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 e771fc64..d199f6a1 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -53,6 +53,12 @@ "enableToast": "Chiamate agli strumenti abilitate", "enable": "Abilita chiamate agli strumenti" }, + "searchingPath": "Ricerca...", + "noPathResults": "Nessun file o cartella trovato", + "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 d59758cc..47bff132 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -53,6 +53,12 @@ "enableToast": "Tool Callsを有効化しました", "enable": "Tool Callsを有効化" }, + "searchingPath": "検索中...", + "noPathResults": "ファイルまたはフォルダが見つかりません", + "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 6c9b4f3b..dae517b1 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -53,6 +53,12 @@ "enableToast": "도구 호출 활성화됨", "enable": "도구 호출 활성화" }, + "searchingPath": "검색 중...", + "noPathResults": "파일 또는 폴더를 찾을 수 없습니다", + "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 a1153299..e1228bc3 100644 --- a/src/locales/lo/translation.json +++ b/src/locales/lo/translation.json @@ -53,6 +53,12 @@ "enableToast": "ເປີດການເອີ້ນໃຊ້ເຄື່ອງມືແລ້ວ", "enable": "ເປີດການເອີ້ນໃຊ້ເຄື່ອງມື" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", + "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 a88b5e0a..8685336d 100644 --- a/src/locales/no/translation.json +++ b/src/locales/no/translation.json @@ -53,6 +53,12 @@ "enableToast": "Verktøyanrop aktivert", "enable": "Aktiver verktøyanrop" }, + "searchingPath": "Searching...", + "noPathResults": "No files or folders found", + "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 a1c62c05..baefbe61 100644 --- a/src/locales/pl/translation.json +++ b/src/locales/pl/translation.json @@ -53,6 +53,12 @@ "enableToast": "Wywołania narzędzi włączone", "enable": "Włącz wywołania narzędzi" }, + "searchingPath": "Wyszukiwanie...", + "noPathResults": "Nie znaleziono plików ani folderów", + "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 245fbfbb..90ea80b3 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -53,6 +53,12 @@ "enableToast": "Chamadas de Ferramentas ativadas", "enable": "Ativar Chamadas de Ferramentas" }, + "searchingPath": "Pesquisando...", + "noPathResults": "Nenhum arquivo ou pasta encontrado", + "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 3d0b0aae..7e652b3c 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -53,6 +53,12 @@ "enableToast": "Вызовы инструментов включены", "enable": "Включить вызовы инструментов" }, + "searchingPath": "Поиск...", + "noPathResults": "Файлы или папки не найдены", + "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 07c8dfdb..a7a836de 100644 --- a/src/locales/sv/translation.json +++ b/src/locales/sv/translation.json @@ -53,6 +53,12 @@ "enableToast": "Verktygsanrop aktiverade", "enable": "Aktivera verktygsanrop" }, + "searchingPath": "Söker...", + "noPathResults": "Inga filer eller mappar hittades", + "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 d5309830..46a4459c 100644 --- a/src/locales/th/translation.json +++ b/src/locales/th/translation.json @@ -53,6 +53,12 @@ "enableToast": "เปิดการเรียกเครื่องมือแล้ว", "enable": "เปิดการเรียกเครื่องมือ" }, + "searchingPath": "กำลังค้นหา...", + "noPathResults": "ไม่พบไฟล์หรือโฟลเดอร์", + "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 97424ed2..eed287da 100644 --- a/src/locales/tr/translation.json +++ b/src/locales/tr/translation.json @@ -53,6 +53,12 @@ "enableToast": "Araç Çağrıları etkinleştirildi", "enable": "Araç Çağrılarını Etkinleştir" }, + "searchingPath": "Aranıyor...", + "noPathResults": "Dosya veya klasör bulunamadı", + "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 1c825962..ddd6fa62 100644 --- a/src/locales/uk/translation.json +++ b/src/locales/uk/translation.json @@ -53,6 +53,12 @@ "enableToast": "Виклики інструментів увімкнено", "enable": "Увімкнути виклики інструментів" }, + "searchingPath": "Пошук...", + "noPathResults": "Файли або папки не знайдено", + "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 d8d3f9c6..5c53c2ff 100644 --- a/src/locales/vi/translation.json +++ b/src/locales/vi/translation.json @@ -53,6 +53,12 @@ "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", + "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 cb94cd74..7a8df2ad 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -53,6 +53,12 @@ "enableToast": "已启用Tool Calls", "enable": "启用Tool Calls" }, + "searchingPath": "搜索中...", + "noPathResults": "找不到文件或文件夹", + "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 719e2611..f03c98f5 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -53,6 +53,12 @@ "enableToast": "已啟用Tool Calls", "enable": "啟用Tool Calls" }, + "searchingPath": "搜尋中...", + "noPathResults": "找不到檔案或資料夾", + "fuzzySearching": "Fuzzy searching...", + "noFuzzyResults": "No matches found", + "fuzzyMode": "Fuzzy Mode", + "recentPaths": "最近路徑", "reAuthorize": { "title": "嘗試呼叫工具時,授權發生錯誤", "description": "此工具的授權已過期或已被取消,無法繼續使用工具。需要重新授權以恢復完整功能。", diff --git a/src/styles/components/_ChatInput.scss b/src/styles/components/_ChatInput.scss index df4a34be..f794c2e7 100644 --- a/src/styles/components/_ChatInput.scss +++ b/src/styles/components/_ChatInput.scss @@ -417,60 +417,66 @@ 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; - overflow-y: auto; - min-width: 280px; - max-width: 400px; z-index: 1000; - margin-top: 4px; + transform: translateY(-100%); + margin-top: -8px; + + .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; - .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 +} 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 &&
}
))