Step 177: enhanced find/replace

This commit is contained in:
Bill
2026-02-09 22:24:57 -07:00
parent 1693fd08d6
commit 00910a0798
12 changed files with 626 additions and 42 deletions

View File

@@ -524,4 +524,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step
| 2026-02-10 | Codex | Step 174: Added IconSet system with theme-aware, zoom-scaled icons + tests. 2/2 tests pass (step174_test, step174_integration_test). file_limits_test 4/4 passes. |
| 2026-02-10 | Codex | Step 175: Typography controls (fonts, line height, letter spacing) + theme layout spacing. 2/2 tests pass (step175_test, step175_integration_test). file_limits_test 4/4 passes. |
| 2026-02-10 | Codex | Step 176: Smooth UI transitions (panel slides, tab fade, tooltip fade, toast animations, search pulse, cursor blink) + reduce-motion/cursor blink settings. 2/2 tests pass (step176_test, step176_integration_test). |
| 2026-02-10 | Codex | Step 177: Enhanced find/replace (match counts, regex preview, replace preview, selection scope, history, toggles, find next/prev) with SearchUtils. 2/2 tests pass (step177_test, step177_integration_test). |
| 2026-02-10 | Codex | Step 166: Extract main.cpp into panel headers marked complete (already implemented). step166_test passes; file_limits_test 4/4 passes. |

View File

@@ -1023,6 +1023,13 @@ add_executable(step176_integration_test tests/step176_integration_test.cpp)
target_include_directories(step176_integration_test PRIVATE src)
target_link_libraries(step176_integration_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step177_test tests/step177_test.cpp)
target_include_directories(step177_test PRIVATE src)
add_executable(step177_integration_test tests/step177_integration_test.cpp)
target_include_directories(step177_integration_test PRIVATE src)
target_link_libraries(step177_integration_test PRIVATE nlohmann_json::nlohmann_json)
find_package(SDL2 CONFIG REQUIRED)
find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED)

View File

@@ -569,6 +569,19 @@ public:
void setCursor(int pos) { cursor_ = pos; }
int getCursor() const { return cursor_; }
bool hasSelectionRange() const { return hasSelection(); }
void getSelectionRange(int& start, int& end) const {
if (!hasSelection()) {
start = end = -1;
return;
}
start = std::min(selStart_, selEnd_);
end = std::max(selStart_, selEnd_);
}
void setSelectionRange(int start, int end) {
selStart_ = start;
selEnd_ = end;
}
const std::vector<FoldRegion>& getFoldRegions() const { return folds_; }
std::vector<int> getFoldedLines() const {
std::vector<int> lines;

View File

@@ -51,6 +51,7 @@
#include "UpdateChecker.h"
#include "NotificationSystem.h"
#include "UIEventBus.h"
#include "SearchUtils.h"
#include "state/SearchState.h"
#include "state/AgentState.h"
#include "state/BuildState.h"
@@ -1614,7 +1615,22 @@ struct EditorState {
[this]() { doRedo(); });
registerCommand("search.find", "Search: Find/Replace",
keys.getBinding("search.find").toString(),
[this]() { search.showFind = !search.showFind; });
[this]() {
search.showFind = true;
search.showReplace = false;
});
registerCommand("search.replace", "Search: Replace",
keys.getBinding("search.replace").toString(),
[this]() {
search.showFind = true;
search.showReplace = true;
});
registerCommand("search.findNext", "Search: Find Next",
keys.getBinding("search.findNext").toString(),
[this]() { doFindNext(false); });
registerCommand("search.findPrev", "Search: Find Previous",
keys.getBinding("search.findPrev").toString(),
[this]() { doFindNext(true); });
registerCommand("search.findInFiles", "Search: Find in Files",
keys.getBinding("search.findInFiles").toString(),
[this]() { search.showProjectSearch = !search.showProjectSearch; });
@@ -1888,52 +1904,181 @@ struct EditorState {
active()->undoDepth = active()->orchestrator.getUndoDepth();
}
void doFind() {
SearchOptions currentSearchOptions() const {
SearchOptions opts;
opts.matchCase = search.matchCase;
opts.wholeWord = search.wholeWord;
opts.useRegex = search.useRegex;
return opts;
}
void addSearchHistory(const std::string& query) {
if (query.empty()) return;
auto it = std::find(search.findHistory.begin(), search.findHistory.end(), query);
if (it != search.findHistory.end()) {
search.findHistory.erase(it);
}
search.findHistory.insert(search.findHistory.begin(), query);
if (search.findHistory.size() > 10) {
search.findHistory.resize(10);
}
}
bool selectionRange(int& outStart, int& outEnd) const {
if (!active()) return false;
if (!active()->widget.hasSelectionRange()) return false;
active()->widget.getSelectionRange(outStart, outEnd);
return outStart >= 0 && outEnd > outStart;
}
void refreshSearchMatches() {
search.matches.clear();
search.currentMatchIndex = -1;
if (!active()) return;
std::string query = search.findBuf;
if (query.empty()) return;
int rangeStart = 0;
int rangeEnd = (int)active()->editBuf.size();
if (search.findInSelection) {
int selStart = -1;
int selEnd = -1;
if (selectionRange(selStart, selEnd)) {
rangeStart = selStart;
rangeEnd = selEnd;
}
}
search.matches = SearchUtils::collectMatches(active()->editBuf,
query,
currentSearchOptions(),
rangeStart,
rangeEnd);
}
bool moveToMatchIndex(int index) {
if (!active()) return false;
if (index < 0 || index >= (int)search.matches.size()) return false;
const auto& match = search.matches[index];
active()->widget.setSelectionRange(match.start, match.end);
active()->widget.setCursor(match.end);
updateCursorPos(match.start);
search.currentMatchIndex = index;
search.lastFindPos = match.end;
search.pulseLine = match.line;
search.pulseStart = ImGui::GetTime();
return true;
}
void doFindNext(bool backwards = false) {
if (!active()) return;
if (strlen(search.findBuf) == 0) return;
int pos = active()->editor.find(search.findBuf, search.lastFindPos);
if (pos >= 0) {
search.lastFindPos = pos + 1;
int line = 1, col = 1;
for (int i = 0; i < pos && i < (int)active()->editBuf.size(); ++i) {
if (active()->editBuf[i] == '\n') { ++line; col = 1; } else { ++col; }
}
active()->cursorLine = line;
active()->cursorCol = col;
NotificationTarget target;
target.path = active()->path;
target.line = std::max(0, line - 1);
target.col = std::max(0, col - 1);
search.pulseLine = target.line;
search.pulseStart = ImGui::GetTime();
notify(NotificationLevel::Info,
"Found \"" + std::string(search.findBuf) + "\" at line " +
std::to_string(line) + ", col " + std::to_string(col),
target);
} else {
refreshSearchMatches();
if (search.matches.empty()) {
search.lastFindPos = 0;
search.pulseLine = -1;
search.pulseStart = 0.0;
notify(NotificationLevel::Warning,
"\"" + std::string(search.findBuf) + "\" not found.");
return;
}
int cursorPos = active()->widget.getCursor();
int chosenIndex = -1;
if (backwards) {
for (int i = (int)search.matches.size() - 1; i >= 0; --i) {
if (search.matches[i].end < cursorPos) {
chosenIndex = i;
break;
}
}
if (chosenIndex < 0) chosenIndex = (int)search.matches.size() - 1;
} else {
for (int i = 0; i < (int)search.matches.size(); ++i) {
if (search.matches[i].start > cursorPos) {
chosenIndex = i;
break;
}
}
if (chosenIndex < 0) chosenIndex = 0;
}
if (moveToMatchIndex(chosenIndex)) {
addSearchHistory(search.findBuf);
const auto& m = search.matches[chosenIndex];
NotificationTarget target;
target.path = active()->path;
target.line = m.line;
target.col = m.col;
notify(NotificationLevel::Info,
"Found \"" + std::string(search.findBuf) + "\" at line " +
std::to_string(m.line + 1) + ", col " + std::to_string(m.col + 1),
target);
}
}
void doReplaceCurrent() {
if (!active()) return;
if (strlen(search.findBuf) == 0) return;
refreshSearchMatches();
if (search.matches.empty()) {
notify(NotificationLevel::Warning,
"\"" + std::string(search.findBuf) + "\" not found.");
return;
}
int index = search.currentMatchIndex;
if (index < 0 || index >= (int)search.matches.size()) {
int cursorPos = active()->widget.getCursor();
for (int i = 0; i < (int)search.matches.size(); ++i) {
if (search.matches[i].start >= cursorPos) {
index = i;
break;
}
}
if (index < 0) index = 0;
}
const auto& match = search.matches[index];
std::string replacement = SearchUtils::applyReplacement(match.matchText,
search.findBuf,
search.replaceBuf,
currentSearchOptions());
active()->editBuf.replace(match.start, match.end - match.start, replacement);
onTextChanged();
active()->widget.setCursor(match.start + (int)replacement.size());
updateCursorPos(match.start + (int)replacement.size());
addSearchHistory(search.findBuf);
}
void doFind() {
doFindNext(false);
}
void doReplaceAll() {
if (!active()) return;
if (strlen(search.findBuf) == 0) return;
int count = active()->editor.replaceAll(search.findBuf, search.replaceBuf);
if (count > 0) {
active()->editBuf = active()->editor.getContent();
if (active()->bufferMode == BufferManager::BufferMode::Structured) {
active()->sync.setText(active()->editBuf, active()->language);
active()->sync.syncNow();
active()->incrementalOptimizer.setRoot(active()->sync.getAST());
int rangeStart = 0;
int rangeEnd = (int)active()->editBuf.size();
if (search.findInSelection) {
int selStart = -1;
int selEnd = -1;
if (selectionRange(selStart, selEnd)) {
rangeStart = selStart;
rangeEnd = selEnd;
}
active()->highlightsDirty = true;
active()->generatedHighlightsDirty = true;
active()->modified = true;
}
int count = 0;
std::string replaced = SearchUtils::replaceAll(active()->editBuf,
search.findBuf,
search.replaceBuf,
currentSearchOptions(),
rangeStart,
rangeEnd,
count);
if (count > 0) {
active()->editBuf = replaced;
onTextChanged();
}
addSearchHistory(search.findBuf);
notify(NotificationLevel::Info,
"Replaced " + std::to_string(count) + " occurrence(s).");
}

211
editor/src/SearchUtils.h Normal file
View File

@@ -0,0 +1,211 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <regex>
#include <string>
#include <vector>
struct SearchOptions {
bool matchCase = false;
bool wholeWord = false;
bool useRegex = false;
};
struct SearchMatch {
int start = 0;
int end = 0;
int line = 0;
int col = 0;
std::string lineText;
std::string matchText;
};
namespace SearchUtils {
inline bool isWordChar(char c) {
return std::isalnum(static_cast<unsigned char>(c)) || c == '_';
}
inline bool isWholeWord(const std::string& text, int start, int end) {
bool leftOk = (start <= 0) || !isWordChar(text[start - 1]);
bool rightOk = (end >= (int)text.size()) || !isWordChar(text[end]);
return leftOk && rightOk;
}
inline std::vector<int> buildLineStarts(const std::string& text) {
std::vector<int> starts;
starts.reserve(128);
starts.push_back(0);
for (int i = 0; i < (int)text.size(); ++i) {
if (text[i] == '\n') starts.push_back(i + 1);
}
return starts;
}
inline void lineColFromPos(const std::vector<int>& lineStarts, int pos, int& outLine, int& outCol) {
auto it = std::upper_bound(lineStarts.begin(), lineStarts.end(), pos);
int line = (int)(it - lineStarts.begin()) - 1;
if (line < 0) line = 0;
outLine = line;
outCol = pos - lineStarts[line];
}
inline std::string lineTextAt(const std::string& text, const std::vector<int>& lineStarts, int line) {
if (line < 0 || line >= (int)lineStarts.size()) return {};
int start = lineStarts[line];
int end = (line + 1 < (int)lineStarts.size()) ? lineStarts[line + 1] - 1 : (int)text.size();
if (end < start) end = start;
return text.substr(start, end - start);
}
inline std::vector<SearchMatch> collectMatches(const std::string& text,
const std::string& query,
const SearchOptions& options,
int rangeStart,
int rangeEnd) {
std::vector<SearchMatch> matches;
if (query.empty()) return matches;
rangeStart = std::max(0, std::min(rangeStart, (int)text.size()));
rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size()));
const std::vector<int> lineStarts = buildLineStarts(text);
const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart);
if (options.useRegex) {
try {
auto flags = std::regex_constants::ECMAScript;
if (!options.matchCase) flags |= std::regex_constants::icase;
std::regex re(query, flags);
for (auto it = std::sregex_iterator(slice.begin(), slice.end(), re);
it != std::sregex_iterator(); ++it) {
int start = rangeStart + (int)it->position();
int end = start + (int)it->length();
if (options.wholeWord && !isWholeWord(text, start, end)) continue;
SearchMatch m;
m.start = start;
m.end = end;
lineColFromPos(lineStarts, start, m.line, m.col);
m.lineText = lineTextAt(text, lineStarts, m.line);
m.matchText = it->str();
matches.push_back(std::move(m));
}
} catch (...) {
return matches;
}
return matches;
}
std::string haystack = slice;
std::string needle = query;
if (!options.matchCase) {
std::transform(haystack.begin(), haystack.end(), haystack.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
std::transform(needle.begin(), needle.end(), needle.begin(),
[](unsigned char c) { return (char)std::tolower(c); });
}
size_t pos = 0;
while ((pos = haystack.find(needle, pos)) != std::string::npos) {
int start = rangeStart + (int)pos;
int end = start + (int)needle.size();
if (options.wholeWord && !isWholeWord(text, start, end)) {
pos += needle.size();
continue;
}
SearchMatch m;
m.start = start;
m.end = end;
lineColFromPos(lineStarts, start, m.line, m.col);
m.lineText = lineTextAt(text, lineStarts, m.line);
m.matchText = text.substr(start, end - start);
matches.push_back(std::move(m));
pos += needle.size();
}
return matches;
}
inline bool regexGroupsForFirstMatch(const std::string& text,
const std::string& query,
const SearchOptions& options,
int rangeStart,
int rangeEnd,
std::vector<std::string>& outGroups) {
outGroups.clear();
if (query.empty()) return false;
rangeStart = std::max(0, std::min(rangeStart, (int)text.size()));
rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size()));
const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart);
try {
auto flags = std::regex_constants::ECMAScript;
if (!options.matchCase) flags |= std::regex_constants::icase;
std::regex re(query, flags);
std::smatch match;
if (!std::regex_search(slice, match, re)) return false;
for (size_t i = 1; i < match.size(); ++i) {
outGroups.push_back(match.str(i));
}
return true;
} catch (...) {
return false;
}
}
inline std::string applyReplacement(const std::string& matchText,
const std::string& query,
const std::string& replacement,
const SearchOptions& options) {
if (!options.useRegex) return replacement;
try {
auto flags = std::regex_constants::ECMAScript;
if (!options.matchCase) flags |= std::regex_constants::icase;
std::regex re(query, flags);
return std::regex_replace(matchText, re, replacement,
std::regex_constants::format_first_only);
} catch (...) {
return replacement;
}
}
inline std::string replaceAll(const std::string& text,
const std::string& query,
const std::string& replacement,
const SearchOptions& options,
int rangeStart,
int rangeEnd,
int& outCount) {
outCount = 0;
if (query.empty()) return text;
rangeStart = std::max(0, std::min(rangeStart, (int)text.size()));
rangeEnd = std::max(rangeStart, std::min(rangeEnd, (int)text.size()));
std::string result = text;
if (options.useRegex) {
try {
auto flags = std::regex_constants::ECMAScript;
if (!options.matchCase) flags |= std::regex_constants::icase;
std::regex re(query, flags);
const std::string slice = text.substr(rangeStart, rangeEnd - rangeStart);
for (auto it = std::sregex_iterator(slice.begin(), slice.end(), re);
it != std::sregex_iterator(); ++it) {
++outCount;
}
std::string replaced = std::regex_replace(slice, re, replacement);
result.replace(rangeStart, rangeEnd - rangeStart, replaced);
return result;
} catch (...) {
return text;
}
}
SearchOptions opts = options;
opts.useRegex = false;
std::vector<SearchMatch> matches = collectMatches(text, query, opts, rangeStart, rangeEnd);
if (matches.empty()) return text;
for (int i = (int)matches.size() - 1; i >= 0; --i) {
const auto& m = matches[i];
result.replace(m.start, m.end - m.start, replacement);
++outCount;
}
return result;
}
} // namespace SearchUtils

View File

@@ -272,7 +272,16 @@ int main(int, char**) {
std::string action = state.keys.getAction(combo);
if (action == "edit.undo") state.doUndo();
else if (action == "edit.redo") state.doRedo();
else if (action == "search.find") state.search.showFind = !state.search.showFind;
else if (action == "search.find") {
state.search.showFind = true;
state.search.showReplace = false;
}
else if (action == "search.replace") {
state.search.showFind = true;
state.search.showReplace = true;
}
else if (action == "search.findNext") state.doFindNext(false);
else if (action == "search.findPrev") state.doFindNext(true);
else if (action == "search.findInFiles") state.search.showProjectSearch = !state.search.showProjectSearch;
else if (action == "nav.goToLine") {
state.search.showGoToLine = true;

View File

@@ -79,8 +79,10 @@ static void renderMenuBar(EditorState& state) {
false, state.active() ? state.active()->orchestrator.getRedoDepth() > 0 : false))
state.doRedo();
ImGui::Separator();
if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str()))
state.search.showFind = !state.search.showFind;
if (ImGui::MenuItem("Find/Replace", state.keys.getBinding("search.find").toString().c_str())) {
state.search.showFind = true;
state.search.showReplace = true;
}
if (ImGui::MenuItem("Find in Files", state.keys.getBinding("search.findInFiles").toString().c_str()))
state.search.showProjectSearch = !state.search.showProjectSearch;
ImGui::EndMenu();

View File

@@ -3,6 +3,17 @@
#include "../EditorUtils.h"
#include "../AnimationUtils.h"
static bool renderToggleButton(const char* label, bool value) {
ImVec4 active = ImVec4(0.25f, 0.55f, 0.85f, 0.9f);
ImVec4 inactive = ImVec4(0.20f, 0.20f, 0.20f, 0.9f);
ImGui::PushStyleColor(ImGuiCol_Button, value ? active : inactive);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, value ? active : inactive);
ImGui::PushStyleColor(ImGuiCol_ButtonActive, value ? active : inactive);
bool pressed = ImGui::Button(label);
ImGui::PopStyleColor(3);
return pressed;
}
static void renderFindReplaceBar(EditorState& state) {
const double now = ImGui::GetTime();
float alpha = 1.0f;
@@ -22,18 +33,120 @@ static void renderFindReplaceBar(EditorState& state) {
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, ImGui::GetStyle().Alpha * alpha);
ImGui::PushFont(state.uiFont);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset);
state.refreshSearchMatches();
bool hasSelection = state.active() && state.active()->widget.hasSelectionRange();
if (state.search.findInSelection && !hasSelection) {
ImGui::TextDisabled("Find in selection: no selection (searching full file)");
}
ImGui::SetNextItemWidth(300);
if (ImGui::InputText("Find", state.search.findBuf, sizeof(state.search.findBuf),
ImGuiInputTextFlags_EnterReturnsTrue)) {
state.doFind();
state.search.lastFindPos = 0;
state.doFindNext(false);
}
ImGui::SameLine();
if (ImGui::Button("Find Next")) state.doFind();
ImGui::SetNextItemWidth(300);
ImGui::InputText("Replace", state.search.replaceBuf, sizeof(state.search.replaceBuf));
if (ImGui::Button("Find Next")) state.doFindNext(false);
ImGui::SameLine();
if (ImGui::Button("Replace All")) state.doReplaceAll();
if (ImGui::Button("Find Prev")) state.doFindNext(true);
if (state.search.showReplace) {
ImGui::SetNextItemWidth(300);
ImGui::InputText("Replace", state.search.replaceBuf, sizeof(state.search.replaceBuf));
ImGui::SameLine();
if (ImGui::Button("Replace")) state.doReplaceCurrent();
ImGui::SameLine();
if (ImGui::Button("Replace All")) state.doReplaceAll();
}
ImGui::Separator();
if (renderToggleButton("Match Case", state.search.matchCase)) {
state.search.matchCase = !state.search.matchCase;
}
ImGui::SameLine();
if (renderToggleButton("Whole Word", state.search.wholeWord)) {
state.search.wholeWord = !state.search.wholeWord;
}
ImGui::SameLine();
if (renderToggleButton("Regex", state.search.useRegex)) {
state.search.useRegex = !state.search.useRegex;
}
ImGui::SameLine();
if (renderToggleButton("In Selection", state.search.findInSelection)) {
state.search.findInSelection = !state.search.findInSelection;
}
if (!state.search.findHistory.empty()) {
ImGui::SameLine();
if (ImGui::BeginCombo("History", "")) {
for (const auto& item : state.search.findHistory) {
if (ImGui::Selectable(item.c_str())) {
std::snprintf(state.search.findBuf, sizeof(state.search.findBuf), "%s", item.c_str());
state.search.lastFindPos = 0;
}
}
ImGui::EndCombo();
}
}
if (!state.search.matches.empty()) {
int current = state.search.currentMatchIndex >= 0
? state.search.currentMatchIndex + 1
: 0;
ImGui::TextDisabled("%d of %d matches", current, (int)state.search.matches.size());
if (state.search.currentMatchIndex >= 0 &&
state.search.currentMatchIndex < (int)state.search.matches.size()) {
const auto& m = state.search.matches[state.search.currentMatchIndex];
ImGui::SameLine();
ImGui::TextDisabled("Ln %d, Col %d", m.line + 1, m.col + 1);
}
} else if (state.search.findBuf[0] != '\0') {
ImGui::TextDisabled("0 matches");
}
if (state.search.useRegex && state.search.findBuf[0] != '\0' && state.active()) {
std::vector<std::string> groups;
int rangeStart = 0;
int rangeEnd = (int)state.active()->editBuf.size();
if (state.search.findInSelection && hasSelection) {
state.active()->widget.getSelectionRange(rangeStart, rangeEnd);
}
bool ok = SearchUtils::regexGroupsForFirstMatch(state.active()->editBuf,
state.search.findBuf,
state.currentSearchOptions(),
rangeStart,
rangeEnd,
groups);
if (!ok) {
ImGui::TextDisabled("Regex preview: no match");
} else if (!groups.empty()) {
ImGui::TextDisabled("Regex groups:");
for (size_t i = 0; i < groups.size(); ++i) {
ImGui::Text(" $%d = %s", (int)i + 1, groups[i].c_str());
}
}
}
if (state.search.showReplace && !state.search.matches.empty()) {
int idx = state.search.currentMatchIndex;
if (idx < 0 || idx >= (int)state.search.matches.size()) idx = 0;
const auto& m = state.search.matches[idx];
std::string replacement = SearchUtils::applyReplacement(m.matchText,
state.search.findBuf,
state.search.replaceBuf,
state.currentSearchOptions());
std::string preview = m.lineText;
if (m.col >= 0 && m.col <= (int)preview.size()) {
int replaceLen = m.end - m.start;
if (m.col + replaceLen <= (int)preview.size()) {
preview.replace(m.col, replaceLen, replacement);
}
}
ImGui::Separator();
ImGui::TextDisabled("Replace preview:");
ImGui::Text("Before: %s", m.lineText.c_str());
ImGui::Text("After : %s", preview.c_str());
}
ImGui::PopFont();
ImGui::PopStyleVar();
ImGui::End();

View File

@@ -1,11 +1,20 @@
#pragma once
#include "ProjectSearch.h"
#include "../SearchUtils.h"
struct SearchState {
bool showFind = false;
bool showReplace = false;
char findBuf[256] = {};
char replaceBuf[256] = {};
int lastFindPos = 0;
bool matchCase = false;
bool wholeWord = false;
bool useRegex = false;
bool findInSelection = false;
int currentMatchIndex = -1;
std::vector<SearchMatch> matches;
std::vector<std::string> findHistory;
int pulseLine = -1;
double pulseStart = 0.0;

View File

@@ -0,0 +1,35 @@
// Step 177: Search utils integration checks.
#include <cassert>
#include <string>
#include <vector>
#include "SearchUtils.h"
int main() {
const std::string text = "foo bar foo\nFoo bar\nfoobar\n";
SearchOptions opts;
opts.matchCase = false;
opts.wholeWord = true;
opts.useRegex = false;
auto matches = SearchUtils::collectMatches(text, "foo", opts, 0, (int)text.size());
assert(matches.size() == 3);
std::vector<std::string> groups;
opts.useRegex = true;
bool ok = SearchUtils::regexGroupsForFirstMatch("foobar", "(foo)(bar)", opts, 0, 6, groups);
assert(ok);
assert(groups.size() == 2);
assert(groups[0] == "foo");
assert(groups[1] == "bar");
int count = 0;
opts.useRegex = false;
std::string replaced = SearchUtils::replaceAll(text, "bar", "baz", opts, 0, (int)text.size(), count);
assert(count == 2);
assert(replaced.find("baz") != std::string::npos);
printf("step177_integration_test: all assertions passed\n");
return 0;
}

View File

@@ -0,0 +1,39 @@
// Step 177: Enhanced find/replace UI + wiring checks.
#include <cassert>
#include <fstream>
#include <string>
static std::string readFile(const std::string& path) {
std::ifstream f(path);
if (!f.is_open()) return {};
return std::string((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
static void assertContains(const std::string& text, const std::string& needle) {
assert(text.find(needle) != std::string::npos);
}
int main() {
const std::string searchUtils = readFile("src/SearchUtils.h");
assertContains(searchUtils, "collectMatches");
assertContains(searchUtils, "replaceAll");
assertContains(searchUtils, "regexGroupsForFirstMatch");
const std::string searchPanel = readFile("src/panels/SearchPanels.h");
assertContains(searchPanel, "Find Next");
assertContains(searchPanel, "Find Prev");
assertContains(searchPanel, "Replace preview");
assertContains(searchPanel, "Regex");
assertContains(searchPanel, "Match Case");
assertContains(searchPanel, "Whole Word");
assertContains(searchPanel, "In Selection");
const std::string editorState = readFile("src/EditorState.h");
assertContains(editorState, "doFindNext");
assertContains(editorState, "doReplaceCurrent");
printf("step177_test: all assertions passed\n");
return 0;
}

View File

@@ -159,7 +159,7 @@ look professional and feel personal.
Core editing improvements that make daily use smooth.
- [ ] **Step 177: Enhanced find/replace**
- [x] **Step 177: Enhanced find/replace**
Upgrade the find/replace bar:
- Match count display ("3 of 17 results")
- Live regex preview with captured groups highlighted