From b8a4a11f907dc58e475130c6bc317e0292e4596c Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 08:23:30 -0500 Subject: [PATCH] fix(console): keep JSON folding active under channel filters JSON folding in the console was bypassed in any filtered view, including when only a channel toggle (daemon, RPC or app) was active. A collapsed block silently expanded the moment a channel was toggled. - Folding is now kept active unless the view is text-filtered or errors-only, which are flat so that every match stays reachable. Channel toggles keep folding, since JSON blocks are atomic per channel. - The fold triangles and the collapsed " ... }" summary are drawn only in the fold-aware view, so the glyph and the rendered block can never disagree. User-visible: JSON results stay foldable while channel filters are applied. Co-Authored-By: Claude Opus 4.8 --- src/ui/windows/console_tab.cpp | 56 +++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 8c72013..217a9d2 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -915,6 +915,26 @@ void ConsoleTab::renderOutput() // ── renderOutput sub-steps ─────────────────────────────────────────────────── +namespace { +// Folding applies unless the view is text-filtered or errors-only (flat views). +inline bool consoleFoldingActive(bool hasTextFilter, bool errorsOnly) { return !(hasTextFilter || errorsOnly); } + +// Visible-index build for the fold-aware view. A collapsed opener that passes the filter hides its +// interior + closer (foldSpan lines); a hidden opener hides only itself (blocks are atomic per channel). +template +void BuildConsoleVisibleIndices(std::vector& out, int n, SpanFn span, CollapsedFn collapsed, PassFn pass) +{ + out.clear(); + for (int i = 0; i < n; ) { + if (!pass(i)) { ++i; continue; } + out.push_back(i); + const int fs = span(i); + if (fs > 0 && collapsed(i)) i += fs + 1; + else ++i; + } +} +} // namespace + void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower) { // Memoize: rebuild the visible set only when the model changed or the filter state changed. @@ -944,10 +964,12 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow bool has_filter = hasTextFilter || !outputFilter.daemonMessagesEnabled || !outputFilter.rpcTraceEnabled || !outputFilter.appMessagesEnabled || outputFilter.errorsOnly; - // Folding is only honored in the unfiltered view; the filtered view is flat. Record it so the - // fold triangles aren't drawn/clickable while filtering (else clicks would silently mutate the + // Folding is honored unless the view is text-filtered or errors-only (those are flat so every match is + // reachable). Channel toggles alone keep folding: JSON blocks are atomic per channel. Record it so the + // fold triangles aren't drawn/clickable in the flat view (else clicks would silently mutate the // collapsed flag with no visible effect and the glyph would disagree with the rendered block). - folding_active_ = !has_filter; + const bool folding = consoleFoldingActive(hasTextFilter, outputFilter.errorsOnly); + folding_active_ = folding; const int n = static_cast(model_.size()); const std::uint64_t seqFirst = model_.firstSeq(); const int flatFlags = (outputFilter.daemonMessagesEnabled ? 1 : 0) | (outputFilter.errorsOnly ? 2 : 0) | @@ -955,31 +977,29 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow // Filtered (flat) view with the exact same filter as the last pass and a model that only appended/evicted // since: drop evicted indices, rebase, test only the new lines. Identical to the full scan below (line text + // channel are immutable; the flat view ignores folds) without its per-drain O(total chars) lowercase pass. - const bool extended = has_filter && vis_flat_valid_ && vis_flat_flags_ == flatFlags && + const bool extended = !folding && vis_flat_valid_ && vis_flat_flags_ == flatFlags && vis_flat_filter_ == outputFilter.text && ExtendFlatVisibleIndices(visible_indices_, vis_flat_first_, vis_flat_end_, seqFirst, n, [&](int i) { return consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter); }); if (extended) { // visible_indices_ updated in place - } else if (has_filter) { + } else if (!folding) { visible_indices_.clear(); - // Filtered view is flat — folding is bypassed so every match is reachable. + // Flat view — folding is bypassed so every match is reachable. for (int i = 0; i < n; i++) { if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue; visible_indices_.push_back(i); } } else { - visible_indices_.clear(); - // No filter — show all lines, but hide the interior of any collapsed JSON block - // (its opener stays visible; the opener + foldSpan lines through the closer are skipped). - for (int i = 0; i < n; ) { - visible_indices_.push_back(i); - const ConsoleModelLine& line = model_[i]; - if (line.foldSpan > 0 && line.collapsed) i += line.foldSpan + 1; // skip inner + closer - else i++; - } + // Fold-aware view: hide the interior of any collapsed JSON block (its opener stays visible; the + // opener + foldSpan lines through the closer are skipped). Channel toggles still apply. + BuildConsoleVisibleIndices(visible_indices_, n, + [&](int i) { return model_[i].foldSpan; }, + [&](int i) { return model_[i].collapsed; }, + [&](int i) { return !has_filter || + consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter); }); } - vis_flat_valid_ = has_filter; + vis_flat_valid_ = !folding; vis_flat_flags_ = flatFlags; vis_flat_filter_ = outputFilter.text; vis_flat_first_ = seqFirst; @@ -1125,7 +1145,7 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt // JSON fold toggle — a small triangle in the left gutter for block-opener lines. // Openers carry no accent bar (result/JSON channels), so the gutter is free. Only shown in the - // unfiltered view, where folding actually applies (see folding_active_ in computeVisibleLines). + // fold-aware view, where folding actually applies (see folding_active_ in computeVisibleLines). if (folding_active_ && line.foldSpan > 0) { // Size from the (DPI/density-scaled) gutter width, not the zoomed font, and center it in // the gutter band [origin-padX, origin) so the glyph and its clickable cell stay aligned. @@ -1231,7 +1251,7 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt // Collapsed JSON block: append a dim " ... }" summary after the opener's text so the // fold reads as e.g. "outputs": [ ... ]. Anchor it on the opener's LAST visual row // and that row's measured width so it stays put even if the opener wraps. - if (line.foldSpan > 0 && line.collapsed && !segs.empty()) { + if (folding_active_ && line.foldSpan > 0 && line.collapsed && !segs.empty()) { char closeCh = '}'; for (size_t k = line.text.size(); k-- > 0; ) { if (line.text[k] != ' ' && line.text[k] != '\t') {