From a8dbea1398270a5ae4a2a355ca907155b2ae6d3b Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 24 Feb 2026 09:03:30 +0800 Subject: [PATCH] feat(workspace): add full-text search for daily memory files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add backend search command that performs case-insensitive matching across all daily memory files, supporting both date and content queries. Frontend includes animated search bar (⌘F), debounced input, snippet display with match count badge, and search state preservation across edits. --- src-tauri/src/commands/workspace.rs | 135 ++++++++ src-tauri/src/lib.rs | 1 + src/components/workspace/DailyMemoryPanel.tsx | 291 ++++++++++++++++-- src/i18n/locales/en.json | 9 +- src/i18n/locales/ja.json | 9 +- src/i18n/locales/zh.json | 9 +- src/lib/api/workspace.ts | 17 + 7 files changed, 447 insertions(+), 24 deletions(-) diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index fe5dd5b29..c8405419a 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -147,6 +147,141 @@ pub async fn write_daily_memory_file(filename: String, content: String) -> Resul .map_err(|e| format!("Failed to write daily memory file {filename}: {e}")) } +/// Find the largest index `<= i` that is a valid UTF-8 char boundary. +/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91). +fn floor_char_boundary(s: &str, mut i: usize) -> usize { + if i >= s.len() { + return s.len(); + } + while !s.is_char_boundary(i) { + i -= 1; + } + i +} + +/// Find the smallest index `>= i` that is a valid UTF-8 char boundary. +/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91). +fn ceil_char_boundary(s: &str, mut i: usize) -> usize { + if i >= s.len() { + return s.len(); + } + while !s.is_char_boundary(i) { + i += 1; + } + i +} + +/// Search result for daily memory full-text search. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyMemorySearchResult { + pub filename: String, + pub date: String, + pub size_bytes: u64, + pub modified_at: u64, + pub snippet: String, + pub match_count: usize, +} + +/// Full-text search across all daily memory files. +/// +/// Performs case-insensitive search on both the date field and file content. +/// Returns results sorted by filename descending (newest first), each with a +/// snippet showing ~120 characters of context around the first match. +#[tauri::command] +pub async fn search_daily_memory_files( + query: String, +) -> Result, String> { + let memory_dir = get_openclaw_dir().join("workspace").join("memory"); + + if !memory_dir.exists() || query.is_empty() { + return Ok(Vec::new()); + } + + let query_lower = query.to_lowercase(); + let mut results: Vec = Vec::new(); + + let entries = std::fs::read_dir(&memory_dir) + .map_err(|e| format!("Failed to read memory directory: {e}"))?; + + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if !name.ends_with(".md") { + continue; + } + let meta = match entry.metadata() { + Ok(m) if m.is_file() => m, + _ => continue, + }; + + let date = name.trim_end_matches(".md").to_string(); + let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); + let content_lower = content.to_lowercase(); + + // Count matches in content + let content_matches: Vec = content_lower + .match_indices(&query_lower) + .map(|(i, _)| i) + .collect(); + + // Also check date field + let date_matches = date.to_lowercase().contains(&query_lower); + + if content_matches.is_empty() && !date_matches { + continue; + } + + // Build snippet around first content match (~120 chars of context) + let snippet = if let Some(&first_pos) = content_matches.first() { + let start = if first_pos > 50 { + floor_char_boundary(&content, first_pos - 50) + } else { + 0 + }; + let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len())); + let mut s = String::new(); + if start > 0 { + s.push_str("..."); + } + s.push_str(&content[start..end]); + if end < content.len() { + s.push_str("..."); + } + s + } else { + // Date-only match — use beginning of file as preview + let end = ceil_char_boundary(&content, 120.min(content.len())); + let mut s = content[..end].to_string(); + if end < content.len() { + s.push_str("..."); + } + s + }; + + let size_bytes = meta.len(); + let modified_at = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + + results.push(DailyMemorySearchResult { + filename: name, + date, + size_bytes, + modified_at, + snippet, + match_count: content_matches.len(), + }); + } + + // Sort by filename descending (newest date first) + results.sort_by(|a, b| b.filename.cmp(&a.filename)); + + Ok(results) +} + /// Delete a daily memory file (idempotent). #[tauri::command] pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7e550fa04..0fbb1bb93 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1051,6 +1051,7 @@ pub fn run() { commands::read_daily_memory_file, commands::write_daily_memory_file, commands::delete_daily_memory_file, + commands::search_daily_memory_files, ]); let app = builder diff --git a/src/components/workspace/DailyMemoryPanel.tsx b/src/components/workspace/DailyMemoryPanel.tsx index 1ab21078f..f81295b77 100644 --- a/src/components/workspace/DailyMemoryPanel.tsx +++ b/src/components/workspace/DailyMemoryPanel.tsx @@ -1,12 +1,24 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { + useState, + useEffect, + useCallback, + useRef, + useMemo, +} from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import { Calendar, Trash2, Plus } from "lucide-react"; +import { Calendar, Trash2, Plus, Search, X } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import MarkdownEditor from "@/components/MarkdownEditor"; -import { workspaceApi, type DailyMemoryFileInfo } from "@/lib/api/workspace"; +import { + workspaceApi, + type DailyMemoryFileInfo, + type DailyMemorySearchResult, +} from "@/lib/api/workspace"; interface DailyMemoryPanelProps { isOpen: boolean; @@ -46,6 +58,16 @@ const DailyMemoryPanel: React.FC = ({ // Delete state const [deletingFile, setDeletingFile] = useState(null); + // Search state + const [searchTerm, setSearchTerm] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchResults, setSearchResults] = useState( + [], + ); + const [searching, setSearching] = useState(false); + const searchInputRef = useRef(null); + const debounceTimerRef = useRef | null>(null); + // Dark mode const [isDarkMode, setIsDarkMode] = useState(false); @@ -61,6 +83,100 @@ const DailyMemoryPanel: React.FC = ({ return () => observer.disconnect(); }, []); + // Whether we are in active search mode (search open with a non-empty term) + const isActiveSearch = useMemo( + () => isSearchOpen && searchTerm.trim().length > 0, + [isSearchOpen, searchTerm], + ); + + // Debounced search execution + const executeSearch = useCallback( + async (query: string) => { + if (!query.trim()) { + setSearchResults([]); + setSearching(false); + return; + } + setSearching(true); + try { + const results = await workspaceApi.searchDailyMemoryFiles(query.trim()); + setSearchResults(results); + } catch (err) { + console.error("Failed to search daily memory files:", err); + toast.error(t("workspace.dailyMemory.searchFailed")); + } finally { + setSearching(false); + } + }, + [t], + ); + + // Handle search input change with debounce + const handleSearchChange = useCallback( + (value: string) => { + setSearchTerm(value); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + void executeSearch(value); + }, 300); + }, + [executeSearch], + ); + + // Open search bar + const openSearch = useCallback(() => { + setIsSearchOpen(true); + // Focus input on next frame + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + }, []); + + // Close search bar and clear state + const closeSearch = useCallback(() => { + setIsSearchOpen(false); + setSearchTerm(""); + setSearchResults([]); + setSearching(false); + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }, []); + + // Keyboard shortcut: Cmd/Ctrl+F to open search, Escape to close + useEffect(() => { + if (!isOpen || editingFile) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "f") { + e.preventDefault(); + if (!isSearchOpen) { + openSearch(); + } else { + searchInputRef.current?.focus(); + } + } + if (e.key === "Escape" && isSearchOpen) { + e.preventDefault(); + closeSearch(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, editingFile, isSearchOpen, openSearch, closeSearch]); + + // Clean up debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + // Load file list const loadFiles = useCallback(async () => { setLoadingList(true); @@ -142,24 +258,44 @@ const DailyMemoryPanel: React.FC = ({ setEditingFile(null); } await loadFiles(); + // Re-trigger search if active + if (isSearchOpen && searchTerm.trim()) { + void executeSearch(searchTerm); + } } catch (err) { console.error("Failed to delete daily memory file:", err); toast.error(t("workspace.dailyMemory.deleteFailed")); setDeletingFile(null); } - }, [deletingFile, editingFile, loadFiles, t]); + }, [ + deletingFile, + editingFile, + loadFiles, + t, + isSearchOpen, + searchTerm, + executeSearch, + ]); - // Back from edit mode to list mode + // Back from edit mode to list mode — preserve search state const handleBackToList = useCallback(() => { setEditingFile(null); setContent(""); void loadFiles(); - }, [loadFiles]); + // Re-trigger search if active (file content may have changed) + if (isSearchOpen && searchTerm.trim()) { + void executeSearch(searchTerm); + } + }, [loadFiles, isSearchOpen, searchTerm, executeSearch]); - // Close panel entirely + // Close panel entirely — clear search state const handleClose = useCallback(() => { setEditingFile(null); setContent(""); + setIsSearchOpen(false); + setSearchTerm(""); + setSearchResults([]); + setSearching(false); onClose(); }, [onClose]); @@ -214,24 +350,137 @@ const DailyMemoryPanel: React.FC = ({ onClose={handleClose} >
- {/* Header with path and create button */} -
-

+ {/* Header with path, search, and create button */} +

+

~/.openclaw/workspace/memory/

- +
+ + +
- {/* File list */} - {loadingList ? ( + {/* Search bar */} + + {isSearchOpen && ( + +
+
+ + handleSearchChange(e.target.value)} + placeholder={t("workspace.dailyMemory.searchPlaceholder")} + className="pl-8 pr-8 h-8 text-sm" + /> + {searchTerm && ( + + )} +
+ +
+
+ )} +
+ + {/* Content: search results or normal file list */} + {isActiveSearch ? ( + // --- Search results --- + searching ? ( +
+ {t("workspace.dailyMemory.searching")} +
+ ) : searchResults.length === 0 ? ( +
+ +

+ {t("workspace.dailyMemory.noSearchResults")} +

+
+ ) : ( +
+ {searchResults.map((result) => ( + + ))} +
+ ) + ) : // --- Normal file list --- + loadingList ? (
{t("prompts.loading")}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2602f0419..1d86bab56 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1205,7 +1205,14 @@ "deleteSuccess": "Daily memory file deleted", "deleteFailed": "Failed to delete daily memory file", "confirmDeleteTitle": "Delete Daily Memory", - "confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone." + "confirmDeleteMessage": "Delete the daily memory for {{date}}? This cannot be undone.", + "searchPlaceholder": "Search full content...", + "searchScopeHint": "Full-text search across all daily memories. ⌘F", + "searchCloseHint": "Esc to close", + "noSearchResults": "No daily memories match your search.", + "searching": "Searching...", + "searchFailed": "Search failed", + "matchCount": "{{count}} match(es)" } }, "openclaw": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f002b1aa2..48af4e631 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1205,7 +1205,14 @@ "deleteSuccess": "デイリーメモリーファイルを削除しました", "deleteFailed": "デイリーメモリーファイルの削除に失敗しました", "confirmDeleteTitle": "デイリーメモリーを削除", - "confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。" + "confirmDeleteMessage": "{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。", + "searchPlaceholder": "全文検索...", + "searchScopeHint": "すべてのデイリーメモリーを全文検索 ⌘F", + "searchCloseHint": "Escで閉じる", + "noSearchResults": "検索に一致するデイリーメモリーはありません。", + "searching": "検索中...", + "searchFailed": "検索に失敗しました", + "matchCount": "{{count}}件一致" } }, "openclaw": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 93176e5f3..ec7e3f03d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1205,7 +1205,14 @@ "deleteSuccess": "每日记忆文件已删除", "deleteFailed": "删除每日记忆文件失败", "confirmDeleteTitle": "删除每日记忆", - "confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。" + "confirmDeleteMessage": "确定删除 {{date}} 的每日记忆吗?此操作不可撤销。", + "searchPlaceholder": "搜索全文内容...", + "searchScopeHint": "全文搜索所有每日记忆 ⌘F", + "searchCloseHint": "Esc 关闭", + "noSearchResults": "没有找到匹配的每日记忆。", + "searching": "搜索中...", + "searchFailed": "搜索失败", + "matchCount": "{{count}} 处匹配" } }, "openclaw": { diff --git a/src/lib/api/workspace.ts b/src/lib/api/workspace.ts index bc4e2f642..c0ffe95b0 100644 --- a/src/lib/api/workspace.ts +++ b/src/lib/api/workspace.ts @@ -8,6 +8,15 @@ export interface DailyMemoryFileInfo { preview: string; } +export interface DailyMemorySearchResult { + filename: string; + date: string; + sizeBytes: number; + modifiedAt: number; + snippet: string; + matchCount: number; +} + export const workspaceApi = { async readFile(filename: string): Promise { return invoke("read_workspace_file", { filename }); @@ -32,4 +41,12 @@ export const workspaceApi = { async deleteDailyMemoryFile(filename: string): Promise { return invoke("delete_daily_memory_file", { filename }); }, + + async searchDailyMemoryFiles( + query: string, + ): Promise { + return invoke("search_daily_memory_files", { + query, + }); + }, };