diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index fb9c475..2491350 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2130,4 +2130,8 @@ add_executable(step358_test tests/step358_test.cpp) target_include_directories(step358_test PRIVATE src) target_link_libraries(step358_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step359_test tests/step359_test.cpp) +target_include_directories(step359_test PRIVATE src) +target_link_libraries(step359_test PRIVATE nlohmann_json::nlohmann_json) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/panels/FindReplaceBar.h b/editor/src/panels/FindReplaceBar.h new file mode 100644 index 0000000..d3595d1 --- /dev/null +++ b/editor/src/panels/FindReplaceBar.h @@ -0,0 +1,162 @@ +#pragma once +// Step 359: Find/replace bar model with in-file and project search. + +#include +#include +#include + +struct FindMatch { + int start = -1; + int end = -1; + int line = 1; + int column = 1; +}; + +struct ProjectFindResult { + std::string filePath; + int line = 1; + int column = 1; + std::string preview; +}; + +class FindReplaceBar { +public: + void openFind() { + visible_ = true; + replaceMode_ = false; + } + + void openReplace() { + visible_ = true; + replaceMode_ = true; + } + + void close() { + visible_ = false; + } + + bool isVisible() const { return visible_; } + bool isReplaceMode() const { return replaceMode_; } + + void setQuery(const std::string& query, const std::string& text) { + query_ = query; + rebuildMatches(text); + } + + void setReplaceText(const std::string& repl) { replaceText_ = repl; } + + const std::string& query() const { return query_; } + const std::string& replaceText() const { return replaceText_; } + + const std::vector& matches() const { return matches_; } + + int matchCount() const { return static_cast(matches_.size()); } + int currentIndex() const { return currentIndex_; } + + bool hasHighlights() const { return !matches_.empty(); } + + std::string matchCounterText() const { + if (matches_.empty()) return "0 of 0"; + return std::to_string(currentIndex_ + 1) + " of " + std::to_string(matches_.size()); + } + + void nextMatch() { + if (matches_.empty()) return; + currentIndex_ = (currentIndex_ + 1) % static_cast(matches_.size()); + } + + void prevMatch() { + if (matches_.empty()) return; + currentIndex_ = (currentIndex_ - 1 + static_cast(matches_.size())) % + static_cast(matches_.size()); + } + + bool replaceCurrent(std::string& text) { + if (matches_.empty() || currentIndex_ < 0 || currentIndex_ >= (int)matches_.size()) return false; + const auto m = matches_[currentIndex_]; + text.replace(m.start, m.end - m.start, replaceText_); + rebuildMatches(text); + return true; + } + + int replaceAll(std::string& text) { + if (query_.empty()) return 0; + int replaced = 0; + size_t pos = 0; + while (true) { + size_t found = text.find(query_, pos); + if (found == std::string::npos) break; + text.replace(found, query_.size(), replaceText_); + pos = found + replaceText_.size(); + replaced++; + } + rebuildMatches(text); + return replaced; + } + + std::vector searchProject( + const std::vector>& files) const { + std::vector out; + if (query_.empty()) return out; + for (const auto& [path, content] : files) { + int line = 1; + int col = 1; + for (size_t i = 0; i < content.size(); ++i) { + if (i + query_.size() <= content.size() && + content.compare(i, query_.size(), query_) == 0) { + ProjectFindResult r; + r.filePath = path; + r.line = line; + r.column = col; + size_t previewStart = i > 10 ? i - 10 : 0; + size_t previewLen = std::min(40, content.size() - previewStart); + r.preview = content.substr(previewStart, previewLen); + out.push_back(r); + } + if (content[i] == '\n') { + line++; + col = 1; + } else { + col++; + } + } + } + return out; + } + +private: + void rebuildMatches(const std::string& text) { + matches_.clear(); + currentIndex_ = 0; + if (query_.empty()) { + currentIndex_ = -1; + return; + } + int line = 1; + int col = 1; + for (size_t i = 0; i + query_.size() <= text.size(); ++i) { + if (text.compare(i, query_.size(), query_) == 0) { + FindMatch m; + m.start = static_cast(i); + m.end = static_cast(i + query_.size()); + m.line = line; + m.column = col; + matches_.push_back(m); + } + if (text[i] == '\n') { + line++; + col = 1; + } else { + col++; + } + } + if (matches_.empty()) currentIndex_ = -1; + } + + bool visible_ = false; + bool replaceMode_ = false; + std::string query_; + std::string replaceText_; + std::vector matches_; + int currentIndex_ = -1; +}; diff --git a/editor/tests/step359_test.cpp b/editor/tests/step359_test.cpp new file mode 100644 index 0000000..ce43d90 --- /dev/null +++ b/editor/tests/step359_test.cpp @@ -0,0 +1,157 @@ +// Step 359: Find and Replace Improvements (12 tests) + +#include +#include +#include +#include +#include "panels/FindReplaceBar.h" + +int main() { + int passed = 0; + const std::string text = "alpha beta\nbeta gamma\nbeta\n"; + + // Test 1: Ctrl+F opens find bar + { + FindReplaceBar bar; + bar.openFind(); + assert(bar.isVisible()); + assert(!bar.isReplaceMode()); + std::cout << "Test 1 PASSED: open find bar\n"; + passed++; + } + + // Test 2: typing filters matches + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + assert(bar.matchCount() == 3); + std::cout << "Test 2 PASSED: typing filters matches\n"; + passed++; + } + + // Test 3: match highlighting visible + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + assert(bar.hasHighlights()); + std::cout << "Test 3 PASSED: highlights visible\n"; + passed++; + } + + // Test 4: match count accurate + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + assert(bar.matchCounterText() == "1 of 3"); + std::cout << "Test 4 PASSED: match count text\n"; + passed++; + } + + // Test 5: Enter cycles through matches (next) + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + int start = bar.currentIndex(); + bar.nextMatch(); + assert(bar.currentIndex() != start); + std::cout << "Test 5 PASSED: next match cycle\n"; + passed++; + } + + // Test 6: Ctrl+H shows replace field + { + FindReplaceBar bar; + bar.openReplace(); + assert(bar.isVisible()); + assert(bar.isReplaceMode()); + std::cout << "Test 6 PASSED: replace mode open\n"; + passed++; + } + + // Test 7: replace works + { + FindReplaceBar bar; + std::string mutableText = text; + bar.openReplace(); + bar.setQuery("gamma", mutableText); + bar.setReplaceText("delta"); + bool ok = bar.replaceCurrent(mutableText); + assert(ok); + assert(mutableText.find("delta") != std::string::npos); + std::cout << "Test 7 PASSED: replace current\n"; + passed++; + } + + // Test 8: project-wide search returns results + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + std::vector> files = { + {"a.txt", text}, + {"b.txt", "zzz\nbeta\n"} + }; + auto results = bar.searchProject(files); + assert(results.size() == 4); + std::cout << "Test 8 PASSED: project search results\n"; + passed++; + } + + // Test 9: Escape closes find bar + { + FindReplaceBar bar; + bar.openFind(); + bar.close(); + assert(!bar.isVisible()); + std::cout << "Test 9 PASSED: close find bar\n"; + passed++; + } + + // Test 10: empty search clears highlights + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + assert(bar.hasHighlights()); + bar.setQuery("", text); + assert(!bar.hasHighlights()); + assert(bar.matchCount() == 0); + std::cout << "Test 10 PASSED: empty query clears highlights\n"; + passed++; + } + + // Test 11: Shift+Enter cycles previous match + { + FindReplaceBar bar; + bar.openFind(); + bar.setQuery("beta", text); + bar.nextMatch(); // index 1 + int idx = bar.currentIndex(); + bar.prevMatch(); + assert(bar.currentIndex() != idx); + std::cout << "Test 11 PASSED: previous match cycle\n"; + passed++; + } + + // Test 12: replace-all updates count and clears matches when fully replaced + { + FindReplaceBar bar; + std::string mutableText = text; + bar.openReplace(); + bar.setQuery("beta", mutableText); + bar.setReplaceText("B"); + int replaced = bar.replaceAll(mutableText); + assert(replaced == 3); + assert(mutableText.find("beta") == std::string::npos); + std::cout << "Test 12 PASSED: replace all\n"; + passed++; + } + + std::cout << "\nResults: " << passed << "/12\n"; + assert(passed == 12); + return 0; +} diff --git a/progress.md b/progress.md index d5d4d3d..01e6c34 100644 --- a/progress.md +++ b/progress.md @@ -1971,6 +1971,42 @@ and go-to-definition lookup with current-file preference and cross-file fallback - `step357_test` — PASS (12/12) regression coverage - `step358_test` — PASS (12/12) new step coverage +### Step 359: Find and Replace Improvements +**Status:** PASS (12/12 tests) + +Implemented a find/replace bar model with in-file match tracking, current-match +navigation, replace-current/replace-all flows, and project-wide search results. +Model behavior includes open/close modes, highlight state, and match counter text. + +**Files created:** +- `editor/src/panels/FindReplaceBar.h` — find/replace model: + - find/replace visibility + mode toggles + - query + match indexing + counter text + - next/previous match navigation + - replace-current and replace-all operations + - project-wide search result generation +- `editor/tests/step359_test.cpp` — 12 tests covering: + 1. open find + 2. query-to-match filtering + 3. highlight visibility + 4. match counter formatting + 5. next-match cycling + 6. replace mode open + 7. replace-current operation + 8. project search results + 9. close behavior + 10. empty-query clears highlights + 11. previous-match cycling + 12. replace-all behavior + +**Files modified:** +- `editor/CMakeLists.txt` — `step359_test` target + +**Verification run:** +- `step357_test` — PASS (12/12) regression coverage +- `step358_test` — PASS (12/12) regression coverage +- `step359_test` — PASS (12/12) new step coverage + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)