From c505af07f202753deae78c2e728b9fecc4dfc6e2 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:50:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(git):=20=E6=A4=9C=E7=B4=A2=E6=A9=9F?= =?UTF-8?q?=E8=83=BD=E3=81=AE=E5=9E=8B=E5=AE=9A=E7=BE=A9=E3=83=BB=E3=83=88?= =?UTF-8?q?=E3=83=AC=E3=82=A4=E3=83=88=E3=83=BBgit2=E3=83=90=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E5=AE=9F=E8=A3=85=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git CLIベースの検索ロジック(git grep / git log / git ls-files)と GitBackendトレイトへのsearch_code/search_commits/search_filenamesメソッド追加。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/git/backend.rs | 7 + src-tauri/src/git/error.rs | 3 + src-tauri/src/git/git2_backend.rs | 17 ++ src-tauri/src/git/mod.rs | 1 + src-tauri/src/git/search.rs | 366 ++++++++++++++++++++++++++++++ 5 files changed, 394 insertions(+) create mode 100644 src-tauri/src/git/search.rs diff --git a/src-tauri/src/git/backend.rs b/src-tauri/src/git/backend.rs index d5e87dc..8b75e3e 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,10 @@ 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..c4d7c37 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,22 @@ 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..d0eafbd --- /dev/null +++ b/src-tauri/src/git/search.rs @@ -0,0 +1,366 @@ +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()); + } +} From 06f5a59a78a67046998eb420850aca55a0d6f5de Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:50:49 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(commands):=20=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=81=AETauri=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_code / search_commits / search_filenamesの3コマンドを登録。 Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/search.rs | 51 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 3 ++ 3 files changed, 55 insertions(+) create mode 100644 src-tauri/src/commands/search.rs 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..f1fa40e --- /dev/null +++ b/src-tauri/src/commands/search.rs @@ -0,0 +1,51 @@ +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/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"); From d9cf5a6f33a96e1eeb022b47565144638a6aa9a5 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:50:58 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat(frontend):=20=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=81=AEIPC=E3=82=B5=E3=83=BC=E3=83=93=E3=82=B9=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchCode / searchCommits / searchFilenamesのinvokeラッパーとTypeScript型定義。 Co-Authored-By: Claude Opus 4.6 --- src/services/search.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/services/search.ts 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 }); +} From 2897ec6f797131a7fbb68507e283f621752b5af4 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:51:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(frontend):=20=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=80=E3=83=ABUI=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code/Commits/Filenamesの3タブ切り替え、300msデバウンス、 マッチハイライト、レースコンディション防止を実装。 Co-Authored-By: Claude Opus 4.6 --- src/components/organisms/SearchModal.tsx | 319 +++++++++++++++++++++++ src/main.tsx | 1 + src/styles/search.css | 124 +++++++++ 3 files changed, 444 insertions(+) create mode 100644 src/components/organisms/SearchModal.tsx create mode 100644 src/styles/search.css 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/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/styles/search.css b/src/styles/search.css new file mode 100644 index 0000000..0711ee6 --- /dev/null +++ b/src/styles/search.css @@ -0,0 +1,124 @@ +/* ===== 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-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; +} From 68b867d43ef0e983ec3e774a63adeb52623733f6 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:51:14 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat(routing):=20=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E3=81=AE=E3=82=B5=E3=82=A4=E3=83=89=E3=83=90=E3=83=BC=E3=83=BB?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=86=E3=82=A3=E3=83=B3=E3=82=B0=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolsセクションに検索ボタン、App.tsxにSearchModalのレンダリングを追加。 Co-Authored-By: Claude Opus 4.6 --- src/App.tsx | 2 ++ src/components/organisms/Sidebar.tsx | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) 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/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
+ +
); } From 00285bfaf9745a370e0c3073514d83d89333e5b9 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:51:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs(roadmap):=20v1.3=E6=A4=9C=E7=B4=A2?= =?UTF-8?q?=E5=BC=B7=E5=8C=96=E3=81=AE=E3=82=BF=E3=82=B9=E3=82=AF=E3=82=92?= =?UTF-8?q?=E5=AE=8C=E4=BA=86=E6=B8=88=E3=81=BF=E3=81=AB=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/roadmap.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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] ファジーマッチ対応 **完動品としての価値**: 「あの変更どこだっけ?」を素早く解決できる From fac9f4dd6a707d9d7c02a513af9d862ca4ad4e9f Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:52:18 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix(css):=20=E6=A4=9C=E7=B4=A2=E3=82=AA?= =?UTF-8?q?=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3=E3=81=AE=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=83=9C=E3=83=83=E3=82=AF=E3=82=B9=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E8=89=B2=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit デフォルトのcheckboxをappearance: noneで置き換え、 テーマ変数に合わせた背景色を設定。 Co-Authored-By: Claude Opus 4.6 --- src/styles/search.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/styles/search.css b/src/styles/search.css index 0711ee6..5de8468 100644 --- a/src/styles/search.css +++ b/src/styles/search.css @@ -64,6 +64,21 @@ 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; From 23d75a3d3fff4f7d45637b26630d4ca6f252ba94 Mon Sep 17 00:00:00 2001 From: HMasataka Date: Fri, 27 Feb 2026 21:55:40 +0900 Subject: [PATCH 8/8] fmt --- src-tauri/src/commands/search.rs | 4 +--- src-tauri/src/git/backend.rs | 3 +-- src-tauri/src/git/git2_backend.rs | 6 +----- src-tauri/src/git/search.rs | 5 +---- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index f1fa40e..e7c7866 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -45,7 +45,5 @@ pub fn search_filenames( .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()) + 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 8b75e3e..3f11ea3 100644 --- a/src-tauri/src/git/backend.rs +++ b/src-tauri/src/git/backend.rs @@ -112,7 +112,6 @@ pub trait GitBackend: Send + Sync { // Search operations fn search_code(&self, query: &str, is_regex: bool) -> GitResult>; - fn search_commits(&self, query: &str, search_diff: 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/git2_backend.rs b/src-tauri/src/git/git2_backend.rs index c4d7c37..abba855 100644 --- a/src-tauri/src/git/git2_backend.rs +++ b/src-tauri/src/git/git2_backend.rs @@ -2034,11 +2034,7 @@ impl GitBackend for Git2Backend { search::search_code(&self.workdir, query, is_regex) } - fn search_commits( - &self, - query: &str, - search_diff: bool, - ) -> GitResult> { + fn search_commits(&self, query: &str, search_diff: bool) -> GitResult> { search::search_commits(&self.workdir, query, search_diff) } diff --git a/src-tauri/src/git/search.rs b/src-tauri/src/git/search.rs index d0eafbd..287bd7c 100644 --- a/src-tauri/src/git/search.rs +++ b/src-tauri/src/git/search.rs @@ -97,10 +97,7 @@ pub fn search_commits( Ok(parse_commit_log_output(&stdout)) } -pub fn search_filenames( - workdir: &Path, - query: &str, -) -> GitResult> { +pub fn search_filenames(workdir: &Path, query: &str) -> GitResult> { if query.is_empty() { return Ok(Vec::new()); }