diff --git a/src/ui/windows/console_command_executor.cpp b/src/ui/windows/console_command_executor.cpp index fcff020..13465d0 100644 --- a/src/ui/windows/console_command_executor.cpp +++ b/src/ui/windows/console_command_executor.cpp @@ -99,10 +99,10 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd) is_error = true; } scrubParamStrings(params); - return [this, result_str, is_error]() { + return [this, result_str = std::move(result_str), is_error]() mutable { if (app_) app_->noteWalletMutation(); std::lock_guard lk(results_mutex_); - results_.push_back({result_str, is_error}); + results_.push_back({std::move(result_str), is_error}); }; }); if (refused) why = "A key import or rescan is still running. Try again when it finishes."; @@ -142,7 +142,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd) { // Publish the result before the gate drops, so a non-busy console always has it queued. std::lock_guard lk(results_mutex_); - results_.push_back({result_str, is_error}); + results_.push_back({std::move(result_str), is_error}); } return [this]() { // A Console command may have changed the wallet (a send, an import, a new address): the @@ -162,7 +162,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd) } if (app_) app_->noteWalletMutation(); std::lock_guard lk(results_mutex_); - results_.push_back({result_str, is_error}); + results_.push_back({std::move(result_str), is_error}); } } diff --git a/src/ui/windows/console_input_model.cpp b/src/ui/windows/console_input_model.cpp index a705310..299fc6b 100644 --- a/src/ui/windows/console_input_model.cpp +++ b/src/ui/windows/console_input_model.cpp @@ -229,32 +229,53 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command) return call; } -std::vector FormatConsoleRpcResultLines(const std::string& result, - bool isError) +std::vector FormatConsoleRpcResultLines(std::string_view result, + bool isError, + std::size_t maxLines) { if (isError) { - return {{"Error: " + result, ConsoleResultLineRole::Error}}; + std::string text; + text.reserve(7 + result.size()); + text.append("Error: ").append(result); + return {{std::move(text), ConsoleResultLineRole::Error}}; } std::vector lines; - std::string normalized = (result == "null") ? "(no result)" : result; - bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '['); - std::istringstream stream(normalized); - std::string line; - while (std::getline(stream, line)) { + const std::string_view normalized = (result == "null") ? std::string_view("(no result)") : result; + if (normalized.empty()) return lines; // getline yields no lines for an empty string + + const bool isJson = normalized[0] == '{' || normalized[0] == '['; + + // getline parity: split on '\n'; one trailing '\n' terminates the last line (no empty extra). + std::size_t end = normalized.size(); + if (normalized[end - 1] == '\n') --end; + + // Tail cap: walk back until maxLines lines are covered; start just after the boundary newline. + std::size_t begin = 0; + if (maxLines > 0) { + std::size_t seen = 0; + for (std::size_t i = end; i-- > 0; ) { + if (normalized[i] == '\n' && ++seen == maxLines) { begin = i + 1; break; } + } + } + + std::size_t pos = begin; + while (true) { + std::size_t nl = normalized.find('\n', pos); + if (nl == std::string_view::npos || nl > end) nl = end; + const std::string_view line = normalized.substr(pos, nl - pos); + ConsoleResultLineRole role = ConsoleResultLineRole::Result; if (isJson && !line.empty()) { - std::string trimmed = line; - std::size_t first = trimmed.find_first_not_of(" \t"); - if (first != std::string::npos) trimmed = trimmed.substr(first); - if (!trimmed.empty()) { + std::size_t first = line.find_first_not_of(" \t"); + if (first != std::string_view::npos) { + const std::string_view trimmed = line.substr(first); unsigned char firstChar = static_cast(trimmed[0]); if (trimmed[0] == '{' || trimmed[0] == '}' || trimmed[0] == '[' || trimmed[0] == ']') { role = ConsoleResultLineRole::JsonBrace; } else if (trimmed[0] == '"') { - if (trimmed.find("\": ") != std::string::npos || - trimmed.find("\":") != std::string::npos) { + if (trimmed.find("\":") != std::string_view::npos) { role = ConsoleResultLineRole::JsonKey; } else { role = ConsoleResultLineRole::JsonString; @@ -268,12 +289,17 @@ std::vector FormatConsoleRpcResultLines(const std::string& re } } } - lines.push_back({line, role}); + lines.push_back({std::string(line), role}); + if (nl >= end) break; + pos = nl + 1; } return lines; } -std::vector ComputeConsoleFoldSpans(const std::vector& lines) +namespace { + +template +std::vector computeFoldSpans(const Lines& lines, TextOf textOf) { std::vector spans(lines.size(), 0); @@ -294,14 +320,16 @@ std::vector ComputeConsoleFoldSpans(const std::vector& lines) }; for (std::size_t i = 0; i < lines.size(); ++i) { - const char last = lastNonSpace(lines[i]); + const std::string& li = textOf(lines[i]); + const char last = lastNonSpace(li); if (last != '{' && last != '[') continue; // not a block opener - const std::size_t indent = leadingSpaces(lines[i]); + const std::size_t indent = leadingSpaces(li); const char wantClose = (last == '{') ? '}' : ']'; // Matching closer = the next line at the same indentation whose first non-space char // is the corresponding closing bracket (pretty-printed JSON has aligned brackets). for (std::size_t j = i + 1; j < lines.size(); ++j) { - if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) { + const std::string& lj = textOf(lines[j]); + if (leadingSpaces(lj) == indent && firstNonSpace(lj) == wantClose) { if (j - i >= 2) spans[i] = static_cast(j - i); // >=1 inner line break; } @@ -310,5 +338,17 @@ std::vector ComputeConsoleFoldSpans(const std::vector& lines) return spans; } +} // namespace + +std::vector ComputeConsoleFoldSpans(const std::vector& lines) +{ + return computeFoldSpans(lines, [](const std::string& s) -> const std::string& { return s; }); +} + +std::vector ComputeConsoleResultFoldSpans(const std::vector& lines) +{ + return computeFoldSpans(lines, [](const ConsoleResultLine& l) -> const std::string& { return l.text; }); +} + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_input_model.h b/src/ui/windows/console_input_model.h index 969f67c..4367e10 100644 --- a/src/ui/windows/console_input_model.h +++ b/src/ui/windows/console_input_model.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace dragonx { @@ -55,8 +56,13 @@ struct ConsoleArg { std::vector ParseConsoleCommandArgsTagged(const std::string& command); std::vector ParseConsoleCommandArgs(const std::string& command); ConsoleRpcCall BuildConsoleRpcCall(const std::string& command); -std::vector FormatConsoleRpcResultLines(const std::string& result, - bool isError); +// Splits an RPC result into classified lines (std::getline semantics: a trailing '\n' adds no +// empty line, an empty result yields none). `maxLines` > 0 keeps only the LAST maxLines lines, +// found by a backward newline scan so a huge result is never fully split/copied. The JSON +// classification still keys off the first char of the whole result. 0 = no cap. +std::vector FormatConsoleRpcResultLines(std::string_view result, + bool isError, + std::size_t maxLines = 0); // For a block of pretty-printed JSON lines, return a per-line fold span: for a line that // opens a collapsible block (its last non-space char is '{' or '[') the value is the offset @@ -64,6 +70,8 @@ std::vector FormatConsoleRpcResultLines(const std::string& re // for empty/one-line blocks not worth folding (span < 2). Pure; used to drive console // JSON folding. std::vector ComputeConsoleFoldSpans(const std::vector& lines); +// Same as ComputeConsoleFoldSpans, over formatted result lines directly (no text copies). +std::vector ComputeConsoleResultFoldSpans(const std::vector& lines); } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index fc8981d..b00fffe 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -2052,15 +2052,15 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec) void ConsoleTab::addFormattedResult(const std::string& result, bool is_error) { - const std::vector resultLines = FormatConsoleRpcResultLines(result, is_error); + // Only the last maxLines lines can survive the model's cap, so a huge result is tail-capped + // up front instead of being fully split and copied. + std::vector resultLines = + FormatConsoleRpcResultLines(result, is_error, model_.maxLines()); // Precompute JSON fold spans over the whole block so each opener line carries the offset // to its matching closer (the spans are relative, so they stay valid as the model's line // cap evicts from the front). The block is ingested under one lock (ingestBatch). - std::vector texts; - texts.reserve(resultLines.size()); - for (const auto& rl : resultLines) texts.push_back(rl.text); - const std::vector foldSpans = ComputeConsoleFoldSpans(texts); + const std::vector foldSpans = ComputeConsoleResultFoldSpans(resultLines); std::vector block; block.reserve(resultLines.size()); @@ -2075,7 +2075,7 @@ void ConsoleTab::addFormattedResult(const std::string& result, bool is_error) case ConsoleResultLineRole::Result: break; } ConsoleModelLine ml; - ml.text = resultLines[i].text; + ml.text = std::move(resultLines[i].text); ml.channel = channel; ml.foldSpan = foldSpans[i]; block.push_back(std::move(ml)); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 8e339ab..1461e22 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -8134,6 +8134,31 @@ void testConsoleInputModel() auto errorLines = dragonx::ui::FormatConsoleRpcResultLines("bad rpc", true); EXPECT_EQ(errorLines[0].role, dragonx::ui::ConsoleResultLineRole::Error); EXPECT_EQ(errorLines[0].text, std::string("Error: bad rpc")); + + // C202: getline parity + backward tail cap. + { + using dragonx::ui::FormatConsoleRpcResultLines; + EXPECT_EQ(FormatConsoleRpcResultLines("", false).size(), static_cast(0)); + EXPECT_EQ(FormatConsoleRpcResultLines("\n", false).size(), static_cast(1)); + EXPECT_EQ(FormatConsoleRpcResultLines("a\n", false).size(), static_cast(1)); + EXPECT_EQ(FormatConsoleRpcResultLines("a\n\n", false).size(), static_cast(2)); + EXPECT_EQ(FormatConsoleRpcResultLines("a\n\nb", false).size(), static_cast(3)); + auto tail = FormatConsoleRpcResultLines("l1\nl2\nl3\nl4\n", false, 2); + EXPECT_EQ(tail.size(), static_cast(2)); + EXPECT_EQ(tail[0].text, std::string("l3")); + EXPECT_EQ(tail[1].text, std::string("l4")); + EXPECT_EQ(FormatConsoleRpcResultLines("l1\nl2", false, 2).size(), static_cast(2)); + EXPECT_EQ(FormatConsoleRpcResultLines("l1\nl2", false, 5).size(), static_cast(2)); + auto one = FormatConsoleRpcResultLines("l1\nl2\nl3", false, 1); + EXPECT_EQ(one.size(), static_cast(1)); + EXPECT_EQ(one[0].text, std::string("l3")); + // JSON classification keys off the whole result even when the head is cut. + auto jt = FormatConsoleRpcResultLines("{\n \"a\": 1,\n \"b\": 2\n}", false, 2); + EXPECT_EQ(jt[0].role, dragonx::ui::ConsoleResultLineRole::JsonKey); + auto rs = dragonx::ui::ComputeConsoleResultFoldSpans( + FormatConsoleRpcResultLines("{\n \"a\": 1\n}", false)); + EXPECT_EQ(rs[0], 2); + } } void testMiningThreadCommit()