diff --git a/docs/roadmap.md b/docs/roadmap.md index 70376ff..95aa9db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -411,16 +411,16 @@ **ゴール**: リポジトリ内の情報を素早く見つけられる -- [ ] ファイル内容検索 - - [ ] リポジトリ全体のテキスト検索(git grep) - - [ ] 正規表現対応 +- [x] ファイル内容検索 + - [x] リポジトリ全体のテキスト検索(git grep) + - [x] 正規表現対応 - [ ] 検索結果からファイルを開く -- [ ] コミット検索 - - [ ] コミットメッセージ検索 - - [ ] 差分内容から検索(pickaxe / -S オプション相当) -- [ ] ファイル名検索 - - [ ] パターンでファイルを検索 - - [ ] ファジーマッチ対応 +- [x] コミット検索 + - [x] コミットメッセージ検索 + - [x] 差分内容から検索(pickaxe / -S オプション相当) +- [x] ファイル名検索 + - [x] パターンでファイルを検索 + - [x] ファジーマッチ対応 **完動品としての価値**: 「あの変更どこだっけ?」を素早く解決できる diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ee4e1c1..9295939 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,5 +10,6 @@ pub mod rebase; pub mod remote; pub mod reset; pub mod revert; +pub mod search; pub mod stash; pub mod tag; diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs new file mode 100644 index 0000000..e7c7866 --- /dev/null +++ b/src-tauri/src/commands/search.rs @@ -0,0 +1,49 @@ +use tauri::State; + +use crate::git::search::{CodeSearchResult, CommitSearchResult, FilenameSearchResult}; +use crate::state::AppState; + +#[tauri::command] +pub fn search_code( + query: String, + is_regex: bool, + state: State<'_, AppState>, +) -> Result, String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend + .search_code(&query, is_regex) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn search_commits( + query: String, + search_diff: bool, + state: State<'_, AppState>, +) -> Result, String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend + .search_commits(&query, search_diff) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn search_filenames( + query: String, + state: State<'_, AppState>, +) -> Result, String> { + let repo_lock = state + .repo + .lock() + .map_err(|e| format!("Lock poisoned: {e}"))?; + let backend = repo_lock.as_ref().ok_or("No repository opened")?; + backend.search_filenames(&query).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index d5e87dc..3f11ea3 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -1,6 +1,7 @@ use std::path::Path; use crate::git::error::GitResult; +use crate::git::search::{CodeSearchResult, CommitSearchResult, FilenameSearchResult}; use crate::git::types::{ BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, CommitInfo, CommitLogResult, CommitResult, ConflictFile, ConflictResolution, DiffOptions, FetchResult, @@ -108,4 +109,9 @@ pub trait GitBackend: Send + Sync { // Reflog operations fn get_reflog(&self, ref_name: &str, limit: usize) -> GitResult>; + + // Search operations + fn search_code(&self, query: &str, is_regex: bool) -> GitResult>; + fn search_commits(&self, query: &str, search_diff: bool) -> GitResult>; + fn search_filenames(&self, query: &str) -> GitResult>; } diff --git a/src-tauri/src/git/error.rs b/src-tauri/src/git/error.rs index 56de440..9bb9e0d 100644 --- a/src-tauri/src/git/error.rs +++ b/src-tauri/src/git/error.rs @@ -103,6 +103,9 @@ pub enum GitError { #[error("failed to read reflog: {0}")] ReflogFailed(#[source] Box), + + #[error("search failed: {0}")] + SearchFailed(#[source] Box), } pub type GitResult = Result; diff --git a/src-tauri/src/git/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index d39b01d..abba855 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -8,6 +8,7 @@ use git2::{ use crate::git::auth::create_credentials_callback; use crate::git::backend::GitBackend; use crate::git::error::{GitError, GitResult}; +use crate::git::search::{self, CodeSearchResult, CommitSearchResult, FilenameSearchResult}; use crate::git::types::{ BlameLine, BlameResult, BranchInfo, CherryPickMode, CherryPickResult, CommitDetail, CommitFileChange, CommitFileStatus, CommitGraphRow, CommitInfo, CommitLogResult, CommitRef, @@ -2028,6 +2029,18 @@ impl GitBackend for Git2Backend { Ok(entries) } + + fn search_code(&self, query: &str, is_regex: bool) -> GitResult> { + search::search_code(&self.workdir, query, is_regex) + } + + fn search_commits(&self, query: &str, search_diff: bool) -> GitResult> { + search::search_commits(&self.workdir, query, search_diff) + } + + fn search_filenames(&self, query: &str) -> GitResult> { + search::search_filenames(&self.workdir, query) + } } impl Git2Backend { diff --git a/src-tauri/src/git/mod.rs b/src-tauri/src/git/mod.rs index ab496ff..948b329 100644 --- a/src-tauri/src/git/mod.rs +++ b/src-tauri/src/git/mod.rs @@ -3,4 +3,5 @@ pub mod backend; pub mod dispatcher; pub mod error; pub mod git2_backend; +pub mod search; pub mod types; diff --git a/src-tauri/src/git/search.rs b/src-tauri/src/git/search.rs new file mode 100644 index 0000000..287bd7c --- /dev/null +++ b/src-tauri/src/git/search.rs @@ -0,0 +1,363 @@ +use std::path::Path; +use std::process::Command; + +use serde::{Deserialize, Serialize}; + +use crate::git::error::{GitError, GitResult}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeSearchResult { + pub file: String, + pub line_number: u32, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommitSearchResult { + pub oid: String, + pub short_oid: String, + pub message: String, + pub author_name: String, + pub author_date: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilenameSearchResult { + pub path: String, +} + +pub fn search_code( + workdir: &Path, + query: &str, + is_regex: bool, +) -> GitResult> { + if query.is_empty() { + return Ok(Vec::new()); + } + + let mut cmd = Command::new("git"); + cmd.current_dir(workdir).arg("grep").arg("-n"); + + if is_regex { + cmd.arg("-E"); + } else { + cmd.arg("-F"); + } + + cmd.arg("--").arg(query); + + let output = cmd + .output() + .map_err(|e| GitError::SearchFailed(Box::new(e)))?; + + if !output.status.success() { + if output.status.code() == Some(1) { + return Ok(Vec::new()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SearchFailed(stderr.to_string().into())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_grep_output(&stdout)) +} + +pub fn search_commits( + workdir: &Path, + query: &str, + search_diff: bool, +) -> GitResult> { + if query.is_empty() { + return Ok(Vec::new()); + } + + let format = "%H%n%h%n%s%n%an%n%at"; + + let mut cmd = Command::new("git"); + cmd.current_dir(workdir).arg("log"); + + if search_diff { + cmd.arg(format!("-S{query}")); + } else { + cmd.arg(format!("--grep={query}")); + } + + cmd.arg(format!("--format={format}")).arg("-100"); + + let output = cmd + .output() + .map_err(|e| GitError::SearchFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SearchFailed(stderr.to_string().into())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_commit_log_output(&stdout)) +} + +pub fn search_filenames(workdir: &Path, query: &str) -> GitResult> { + if query.is_empty() { + return Ok(Vec::new()); + } + + let output = Command::new("git") + .current_dir(workdir) + .args(["ls-files"]) + .output() + .map_err(|e| GitError::SearchFailed(Box::new(e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitError::SearchFailed(stderr.to_string().into())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let query_lower = query.to_lowercase(); + + let results: Vec = stdout + .lines() + .filter(|line| fuzzy_match(line, &query_lower)) + .take(100) + .map(|line| FilenameSearchResult { + path: line.to_string(), + }) + .collect(); + + Ok(results) +} + +fn parse_grep_output(output: &str) -> Vec { + output + .lines() + .filter_map(|line| { + // format: file:line_number:content + let first_colon = line.find(':')?; + let rest = &line[first_colon + 1..]; + let second_colon = rest.find(':')?; + + let file = line[..first_colon].to_string(); + let line_number = rest[..second_colon].parse::().ok()?; + let content = rest[second_colon + 1..].to_string(); + + Some(CodeSearchResult { + file, + line_number, + content, + }) + }) + .collect() +} + +fn parse_commit_log_output(output: &str) -> Vec { + let lines: Vec<&str> = output.lines().collect(); + let mut results = Vec::new(); + + // Each commit occupies 5 lines: oid, short_oid, message, author_name, author_date + let mut i = 0; + while i + 4 < lines.len() { + let author_date = match lines[i + 4].parse::() { + Ok(v) => v, + Err(_) => { + i += 5; + continue; + } + }; + + results.push(CommitSearchResult { + oid: lines[i].to_string(), + short_oid: lines[i + 1].to_string(), + message: lines[i + 2].to_string(), + author_name: lines[i + 3].to_string(), + author_date, + }); + + i += 5; + } + + results +} + +fn fuzzy_match(path: &str, query: &str) -> bool { + let path_lower = path.to_lowercase(); + + // Substring match first + if path_lower.contains(query) { + return true; + } + + // Character-by-character fuzzy match + let mut query_chars = query.chars().peekable(); + for ch in path_lower.chars() { + if query_chars.peek() == Some(&ch) { + query_chars.next(); + } + if query_chars.peek().is_none() { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_grep_output_returns_results_for_valid_output() { + let output = "src/main.rs:10:fn main() {\nsrc/lib.rs:5:pub mod search;\n"; + let results = parse_grep_output(output); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].file, "src/main.rs"); + assert_eq!(results[0].line_number, 10); + assert_eq!(results[0].content, "fn main() {"); + assert_eq!(results[1].file, "src/lib.rs"); + assert_eq!(results[1].line_number, 5); + assert_eq!(results[1].content, "pub mod search;"); + } + + #[test] + fn parse_grep_output_returns_empty_for_empty_input() { + let results = parse_grep_output(""); + assert!(results.is_empty()); + } + + #[test] + fn parse_grep_output_handles_colons_in_content() { + let output = "config.toml:3:key = \"value:with:colons\"\n"; + let results = parse_grep_output(output); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].file, "config.toml"); + assert_eq!(results[0].line_number, 3); + assert_eq!(results[0].content, "key = \"value:with:colons\""); + } + + #[test] + fn parse_grep_output_skips_malformed_lines() { + let output = "no-colon-line\nsrc/main.rs:10:valid line\n"; + let results = parse_grep_output(output); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].file, "src/main.rs"); + } + + #[test] + fn parse_commit_log_output_returns_results_for_valid_output() { + let output = "abc123def456\nabc123d\nfeat: add search\nAlice\n1700000000\n"; + let results = parse_commit_log_output(output); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].oid, "abc123def456"); + assert_eq!(results[0].short_oid, "abc123d"); + assert_eq!(results[0].message, "feat: add search"); + assert_eq!(results[0].author_name, "Alice"); + assert_eq!(results[0].author_date, 1700000000); + } + + #[test] + fn parse_commit_log_output_returns_empty_for_empty_input() { + let results = parse_commit_log_output(""); + assert!(results.is_empty()); + } + + #[test] + fn parse_commit_log_output_parses_multiple_commits() { + let output = + "aaa\na\nmsg1\nBob\n1000\nbbb\nb\nmsg2\nCarol\n2000\nccc\nc\nmsg3\nDave\n3000\n"; + let results = parse_commit_log_output(output); + + assert_eq!(results.len(), 3); + assert_eq!(results[0].oid, "aaa"); + assert_eq!(results[1].oid, "bbb"); + assert_eq!(results[2].oid, "ccc"); + } + + #[test] + fn parse_commit_log_output_ignores_incomplete_trailing_entry() { + let output = "aaa\na\nmsg1\nBob\n1000\nincomplete\n"; + let results = parse_commit_log_output(output); + + assert_eq!(results.len(), 1); + } + + #[test] + fn parse_commit_log_output_skips_entry_with_invalid_date() { + let output = "aaa\na\nmsg1\nBob\nnot_a_number\nbbb\nb\nmsg2\nCarol\n2000\n"; + let results = parse_commit_log_output(output); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].oid, "bbb"); + assert_eq!(results[0].author_date, 2000); + } + + #[test] + fn fuzzy_match_matches_substring() { + assert!(fuzzy_match("src/auth/handler.go", "auth")); + } + + #[test] + fn fuzzy_match_matches_case_insensitive() { + assert!(fuzzy_match("src/Auth/Handler.go", "auth")); + } + + #[test] + fn fuzzy_match_matches_fuzzy_characters() { + assert!(fuzzy_match("src/auth/handler.go", "sah")); + } + + #[test] + fn fuzzy_match_returns_false_for_no_match() { + assert!(!fuzzy_match("src/main.rs", "xyz")); + } + + #[test] + fn fuzzy_match_returns_false_for_out_of_order_chars() { + assert!(!fuzzy_match("abc", "cba")); + } + + #[test] + fn fuzzy_match_with_take_limit_caps_at_100() { + // Verify the take(100) logic works by testing the underlying function + let mut lines = Vec::new(); + for i in 0..200 { + lines.push(format!("src/file_{i}.rs")); + } + let query_lower = "src".to_lowercase(); + let results: Vec = lines + .iter() + .map(|l| l.as_str()) + .filter(|line| fuzzy_match(line, &query_lower)) + .take(100) + .map(|line| FilenameSearchResult { + path: line.to_string(), + }) + .collect(); + + assert_eq!(results.len(), 100); + } + + #[test] + fn search_code_returns_empty_for_empty_query() { + let result = search_code(Path::new("/tmp"), "", false); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn search_commits_returns_empty_for_empty_query() { + let result = search_commits(Path::new("/tmp"), "", false); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } + + #[test] + fn search_filenames_returns_empty_for_empty_query() { + let result = search_filenames(Path::new("/tmp"), ""); + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d1958b..755cc6c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -170,6 +170,9 @@ pub fn run() { commands::hosting::get_default_branch, commands::hosting::create_pull_request_url, commands::hosting::open_in_browser, + commands::search::search_code, + commands::search::search_commits, + commands::search::search_filenames, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 28bda2d..74b6cc5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from "react"; import { PullDialog } from "./components/organisms/PullDialog"; import { RemoteModal } from "./components/organisms/RemoteModal"; +import { SearchModal } from "./components/organisms/SearchModal"; import { SettingsModal } from "./components/organisms/SettingsModal"; import { TagsModal } from "./components/organisms/TagsModal"; import { ToastContainer } from "./components/organisms/ToastContainer"; @@ -203,6 +204,7 @@ export function App() { )} {activeModal === "conflict" && } {activeModal === "settings" && } + {activeModal === "search" && } ); } diff --git a/src/components/organisms/SearchModal.tsx b/src/components/organisms/SearchModal.tsx new file mode 100644 index 0000000..ab2d38b --- /dev/null +++ b/src/components/organisms/SearchModal.tsx @@ -0,0 +1,319 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + CodeSearchResult, + CommitSearchResult, + FilenameSearchResult, +} from "../../services/search"; +import { + searchCode, + searchCommits, + searchFilenames, +} from "../../services/search"; +import { Modal } from "./Modal"; + +type SearchTab = "code" | "commits" | "filenames"; + +interface SearchModalProps { + onClose: () => void; +} + +function highlightMatch(text: string, query: string): React.ReactNode[] { + if (!query) return [text]; + + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`(${escaped})`, "gi"); + const parts = text.split(regex); + + return parts.map((part, i) => { + const key = `${i}-${part}`; + regex.lastIndex = 0; + if (regex.test(part)) { + return {part}; + } + return {part}; + }); +} + +function formatCommitDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(); +} + +export function SearchModal({ onClose }: SearchModalProps) { + const [query, setQuery] = useState(""); + const [activeTab, setActiveTab] = useState("code"); + const [codeResults, setCodeResults] = useState([]); + const [commitResults, setCommitResults] = useState([]); + const [filenameResults, setFilenameResults] = useState< + FilenameSearchResult[] + >([]); + const [loading, setLoading] = useState(false); + const [isRegex, setIsRegex] = useState(false); + const [searchDiff, setSearchDiff] = useState(false); + const [error, setError] = useState(null); + + const debounceRef = useRef | null>(null); + const inputRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const performSearch = useCallback( + (q: string, tab: SearchTab, regex: boolean, diff: boolean) => { + if (!q.trim()) { + setCodeResults([]); + setCommitResults([]); + setFilenameResults([]); + setError(null); + return; + } + + requestIdRef.current += 1; + const thisRequestId = requestIdRef.current; + + setLoading(true); + setError(null); + + const searchPromise = (() => { + switch (tab) { + case "code": + return searchCode(q, regex).then((results) => { + if (requestIdRef.current === thisRequestId) { + setCodeResults(results); + } + }); + case "commits": + return searchCommits(q, diff).then((results) => { + if (requestIdRef.current === thisRequestId) { + setCommitResults(results); + } + }); + case "filenames": + return searchFilenames(q).then((results) => { + if (requestIdRef.current === thisRequestId) { + setFilenameResults(results); + } + }); + } + })(); + + searchPromise + .catch((e: unknown) => { + if (requestIdRef.current === thisRequestId) { + setError(String(e)); + } + }) + .finally(() => { + if (requestIdRef.current === thisRequestId) { + setLoading(false); + } + }); + }, + [], + ); + + const scheduleSearch = useCallback( + (q: string, tab: SearchTab, regex: boolean, diff: boolean) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + performSearch(q, tab, regex, diff); + }, 300); + }, + [performSearch], + ); + + const handleQueryChange = useCallback( + (e: React.ChangeEvent) => { + const newQuery = e.target.value; + setQuery(newQuery); + scheduleSearch(newQuery, activeTab, isRegex, searchDiff); + }, + [activeTab, isRegex, searchDiff, scheduleSearch], + ); + + const handleTabChange = useCallback( + (tab: SearchTab) => { + setActiveTab(tab); + setError(null); + if (query.trim()) { + performSearch(query, tab, isRegex, searchDiff); + } + }, + [query, isRegex, searchDiff, performSearch], + ); + + const handleRegexToggle = useCallback( + (e: React.ChangeEvent) => { + const newRegex = e.target.checked; + setIsRegex(newRegex); + if (query.trim()) { + performSearch(query, activeTab, newRegex, searchDiff); + } + }, + [query, activeTab, searchDiff, performSearch], + ); + + const handleSearchDiffToggle = useCallback( + (e: React.ChangeEvent) => { + const newSearchDiff = e.target.checked; + setSearchDiff(newSearchDiff); + if (query.trim()) { + performSearch(query, activeTab, isRegex, newSearchDiff); + } + }, + [query, activeTab, isRegex, performSearch], + ); + + const renderResults = () => { + if (loading) { + return
Searching...
; + } + + if (error) { + return
{error}
; + } + + if (!query.trim()) { + return
Type to search the repository
; + } + + switch (activeTab) { + case "code": + return renderCodeResults(); + case "commits": + return renderCommitResults(); + case "filenames": + return renderFilenameResults(); + } + }; + + const renderCodeResults = () => { + if (codeResults.length === 0) { + return
No code matches found
; + } + + return ( +
+ {codeResults.map((result, i) => ( +
+
+ {result.file} + :{result.line_number} +
+
+ {highlightMatch(result.content, query)} +
+
+ ))} +
+ ); + }; + + const renderCommitResults = () => { + if (commitResults.length === 0) { + return
No commit matches found
; + } + + return ( +
+ {commitResults.map((result) => ( +
+
{result.short_oid}
+
+ {highlightMatch(result.message, query)} +
+
+ {result.author_name} ·{" "} + {formatCommitDate(result.author_date)} +
+
+ ))} +
+ ); + }; + + const renderFilenameResults = () => { + if (filenameResults.length === 0) { + return
No filename matches found
; + } + + return ( +
+ {filenameResults.map((result) => ( +
+
+ {highlightMatch(result.path, query)} +
+
+ ))} +
+ ); + }; + + return ( + +
+ +
+
+ + + +
+
+ {activeTab === "code" && ( + + )} + {activeTab === "commits" && ( + + )} +
+ {renderResults()} +
+ ); +} diff --git a/src/components/organisms/Sidebar.tsx b/src/components/organisms/Sidebar.tsx index d71343e..ba9c4eb 100644 --- a/src/components/organisms/Sidebar.tsx +++ b/src/components/organisms/Sidebar.tsx @@ -8,6 +8,7 @@ interface SidebarProps { export function Sidebar({ changesCount }: SidebarProps) { const activePage = useUIStore((s) => s.activePage); const setActivePage = useUIStore((s) => s.setActivePage); + const openModal = useUIStore((s) => s.openModal); const stashCount = useGitStore((s) => s.stashes.length); return ( @@ -183,6 +184,24 @@ export function Sidebar({ changesCount }: SidebarProps) { GitHub +
+
Tools
+ +
); } diff --git a/src/main.tsx b/src/main.tsx index 0b30a62..a4133bd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -22,6 +22,7 @@ import "./styles/cherry-pick.css"; import "./styles/revert.css"; import "./styles/reset.css"; import "./styles/reflog.css"; +import "./styles/search.css"; const root = document.getElementById("root"); if (!root) throw new Error("Root element not found"); diff --git a/src/services/search.ts b/src/services/search.ts new file mode 100644 index 0000000..f0c9bc1 --- /dev/null +++ b/src/services/search.ts @@ -0,0 +1,39 @@ +import { invoke } from "@tauri-apps/api/core"; + +export interface CodeSearchResult { + file: string; + line_number: number; + content: string; +} + +export interface CommitSearchResult { + oid: string; + short_oid: string; + message: string; + author_name: string; + author_date: number; +} + +export interface FilenameSearchResult { + path: string; +} + +export function searchCode( + query: string, + isRegex: boolean, +): Promise { + return invoke("search_code", { query, isRegex }); +} + +export function searchCommits( + query: string, + searchDiff: boolean, +): Promise { + return invoke("search_commits", { query, searchDiff }); +} + +export function searchFilenames( + query: string, +): Promise { + return invoke("search_filenames", { query }); +} diff --git a/src/styles/search.css b/src/styles/search.css new file mode 100644 index 0000000..5de8468 --- /dev/null +++ b/src/styles/search.css @@ -0,0 +1,139 @@ +/* ===== Search Modal ===== */ +.search-input-group { + margin-bottom: 16px; +} + +.search-main { + width: 100%; + padding: 14px 16px 14px 44px; + background: var(--bg-secondary); + border: 1px solid transparent; + border-radius: 10px; + color: var(--text-primary); + font-family: "JetBrains Mono", monospace; + font-size: 14px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236b7280'%3E%3Cpath d='M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: 14px center; + background-size: 18px; + border-color: var(--accent); + box-shadow: 0 0 0 4px var(--accent-dim); + box-sizing: border-box; +} + +.search-main::placeholder { + color: var(--text-muted); + opacity: 0.7; +} + +.search-filters { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.filter-btn { + padding: 6px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); + font-family: inherit; + font-size: 12px; + cursor: pointer; +} + +.filter-btn.active { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.search-options { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.search-option-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; +} + +.search-option-label input[type="checkbox"] { + appearance: none; + width: 14px; + height: 14px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg-tertiary); + cursor: pointer; +} + +.search-option-label input[type="checkbox"]:checked { + background: var(--accent); + border-color: var(--accent); +} + +.search-results { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.search-result { + padding: 12px; + background: var(--bg-tertiary); + border-radius: 6px; +} + +.result-file { + font-size: 12px; + color: var(--accent); + margin-bottom: 6px; +} + +.result-line { + font-family: "JetBrains Mono", monospace; + font-size: 12px; + color: var(--text-secondary); +} + +.result-line mark { + background: var(--warning-dim); + color: var(--warning); + padding: 1px 4px; + border-radius: 3px; +} + +.result-line-number { + color: var(--text-muted); + margin-right: 8px; + user-select: none; +} + +.result-commit-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.search-empty { + text-align: center; + color: var(--text-muted); + padding: 24px 0; + font-size: 13px; +} + +.search-loading { + text-align: center; + color: var(--text-muted); + padding: 24px 0; + font-size: 13px; +}