log_bench 0.2.8 → 0.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +20 -0
- data/lib/log_bench/app/clipboard.rb +31 -0
- data/lib/log_bench/app/copy_handler.rb +94 -0
- data/lib/log_bench/app/input_handler.rb +66 -9
- data/lib/log_bench/app/mouse_handler.rb +6 -0
- data/lib/log_bench/app/query_summary.rb +101 -0
- data/lib/log_bench/app/renderer/ansi.rb +74 -53
- data/lib/log_bench/app/renderer/details.rb +123 -113
- data/lib/log_bench/app/renderer/header.rb +1 -1
- data/lib/log_bench/app/renderer/main.rb +4 -0
- data/lib/log_bench/app/state.rb +45 -3
- data/lib/log_bench/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a7bf1724c565d5848f6867b14ec19675a50594b369cb3d7729d117b310f4d399
|
4
|
+
data.tar.gz: cc2c21dc13a0981b11c92933ecbe967a6cf98b7a7ff21e27d322e156932e42a0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 61423f51d81a4c5b30d963fb3da1ba4739aa0420b7b388e6b5fb01952cbb7c60eba2b3091ef520fefb81ab67310611d175d45bacb9d00f63199911cf3ab4cc44
|
7
|
+
data.tar.gz: 5a4c48ed233d247ca70d1aaa36f9eb676b598282dfb573055a3200d9002f7978f31c46a44f57e0e4270308a296523862f4670360ab8df3eabb530ac5f7365bcd
|
data/README.md
CHANGED
@@ -116,6 +116,8 @@ log_bench log/development.log
|
|
116
116
|
- **Clear filter**: `c` to clear an active filter (press `escape` or `enter` before pressing `c` to clear)
|
117
117
|
- **Sorting**: `s` to cycle through sort options (timestamp, duration, status)
|
118
118
|
- **Auto-scroll**: `a` to toggle auto-scroll mode
|
119
|
+
- **Copy**: `y` to copy the selected item to clipboard (request details or SQL query)
|
120
|
+
- **Text selection**: `t` to toggle text selection mode (enables mouse text selection)
|
119
121
|
- **Quit**: `q` to exit
|
120
122
|
|
121
123
|
### Filtering
|
@@ -142,6 +144,24 @@ Examples:
|
|
142
144
|
In the right pane you can filter related log lines by text content to find specific SQL queries or anything else
|
143
145
|
you want to find in the logs.
|
144
146
|
|
147
|
+
### Copying Content
|
148
|
+
|
149
|
+
LogBench provides multiple ways to copy content:
|
150
|
+
|
151
|
+
**Smart Copy with `y` key:**
|
152
|
+
- **Left pane**: Copies complete request details (method, path, status, duration, etc.)
|
153
|
+
- **Right pane**: Copies the selected SQL query with its call source location
|
154
|
+
|
155
|
+
**Text Selection Mode:**
|
156
|
+
- Press `t` to toggle text selection mode
|
157
|
+
- When enabled, you can use your mouse to select and copy text normally
|
158
|
+
- When disabled, mouse clicks navigate the interface
|
159
|
+
|
160
|
+
**Clipboard Support:**
|
161
|
+
- **macOS**: Uses `pbcopy` (built-in)
|
162
|
+
- **Linux**: Uses `xclip` or `xsel` (install with your package manager)
|
163
|
+
- **Fallback**: Saves to `/tmp/logbench_copy.txt` if no clipboard tool available
|
164
|
+
|
145
165
|
|
146
166
|
## Log Format
|
147
167
|
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class Clipboard
|
6
|
+
def self.copy(text)
|
7
|
+
new.copy(text)
|
8
|
+
end
|
9
|
+
|
10
|
+
def copy(text)
|
11
|
+
# Try different clipboard commands based on the platform
|
12
|
+
if system("which pbcopy > /dev/null 2>&1")
|
13
|
+
# macOS
|
14
|
+
IO.popen("pbcopy", "w") { |io| io.write(text) }
|
15
|
+
elsif system("which xclip > /dev/null 2>&1")
|
16
|
+
# Linux with xclip
|
17
|
+
IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
|
18
|
+
elsif system("which xsel > /dev/null 2>&1")
|
19
|
+
# Linux with xsel
|
20
|
+
IO.popen("xsel --clipboard --input", "w") { |io| io.write(text) }
|
21
|
+
else
|
22
|
+
# Fallback: write to a temporary file
|
23
|
+
temp_file = "/tmp/logbench_copy.txt"
|
24
|
+
File.write(temp_file, text)
|
25
|
+
end
|
26
|
+
rescue
|
27
|
+
# Silently fail - we don't want to crash the TUI
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class CopyHandler
|
6
|
+
def initialize(state, renderer)
|
7
|
+
self.state = state
|
8
|
+
self.renderer = renderer
|
9
|
+
end
|
10
|
+
|
11
|
+
def copy_to_clipboard
|
12
|
+
if state.left_pane_focused?
|
13
|
+
copy_selected_request
|
14
|
+
else
|
15
|
+
copy_selected_detail_entry
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_accessor :state, :renderer
|
22
|
+
|
23
|
+
def copy_selected_request
|
24
|
+
request = state.current_request
|
25
|
+
return unless request
|
26
|
+
|
27
|
+
# Create a comprehensive text representation of the request
|
28
|
+
content = []
|
29
|
+
content << "```"
|
30
|
+
content << "#{request.method} #{request.path} #{request.status}"
|
31
|
+
content << "Duration: #{request.duration}ms" if request.duration
|
32
|
+
content << "Controller: #{request.controller}" if request.controller
|
33
|
+
content << "Action: #{request.action}" if request.action
|
34
|
+
content << "Request ID: #{request.request_id}" if request.request_id
|
35
|
+
content << "Timestamp: #{request.timestamp}" if request.timestamp
|
36
|
+
content << "Params: #{request.params}" if request.params && !request.params.empty?
|
37
|
+
|
38
|
+
# Add query summary if there are related logs
|
39
|
+
if request.related_logs && !request.related_logs.empty?
|
40
|
+
query_summary = QuerySummary.new(request).build_text_summary
|
41
|
+
content << ""
|
42
|
+
content << query_summary
|
43
|
+
end
|
44
|
+
|
45
|
+
content << "```"
|
46
|
+
|
47
|
+
Clipboard.copy(content.join("\n"))
|
48
|
+
end
|
49
|
+
|
50
|
+
def copy_selected_detail_entry
|
51
|
+
request = state.current_request
|
52
|
+
return unless request
|
53
|
+
|
54
|
+
# Get the detail lines for the current request
|
55
|
+
lines = renderer&.get_cached_detail_lines(request)
|
56
|
+
return unless lines
|
57
|
+
|
58
|
+
# Find all unique entry IDs, excluding separator lines
|
59
|
+
entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
|
60
|
+
return if state.detail_selected_entry >= entry_ids.size
|
61
|
+
|
62
|
+
# Get the selected entry ID
|
63
|
+
selected_entry_id = entry_ids[state.detail_selected_entry]
|
64
|
+
return unless selected_entry_id
|
65
|
+
|
66
|
+
# Find all lines belonging to the selected entry
|
67
|
+
selected_lines = lines.select { |line| line[:entry_id] == selected_entry_id }
|
68
|
+
|
69
|
+
# Extract the text content, removing ANSI codes and padding
|
70
|
+
content = selected_lines.map do |line|
|
71
|
+
text = line[:text] || ""
|
72
|
+
# Remove ANSI escape codes and trim padding
|
73
|
+
text.gsub(/\e\[[0-9;]*m/, "").strip
|
74
|
+
end.reject(&:empty?)
|
75
|
+
|
76
|
+
# Check if this is a SQL query by looking for SQL keywords in the content
|
77
|
+
content_text = content.join(" ")
|
78
|
+
is_sql_query = sql_query?(content_text)
|
79
|
+
|
80
|
+
if is_sql_query
|
81
|
+
Clipboard.copy("```sql\n#{content.join("\n")}\n```")
|
82
|
+
else
|
83
|
+
Clipboard.copy(content.join("\n"))
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def sql_query?(text)
|
88
|
+
# Check for common SQL keywords that indicate this is a SQL query
|
89
|
+
sql_keywords = %w[SELECT INSERT UPDATE DELETE TRANSACTION BEGIN COMMIT ROLLBACK SAVEPOINT]
|
90
|
+
sql_keywords.any? { |keyword| text.upcase.include?(keyword) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -22,6 +22,7 @@ module LogBench
|
|
22
22
|
self.screen = screen
|
23
23
|
self.renderer = renderer
|
24
24
|
self.mouse_handler = MouseHandler.new(state, screen)
|
25
|
+
self.copy_handler = CopyHandler.new(state, renderer)
|
25
26
|
end
|
26
27
|
|
27
28
|
def handle_input
|
@@ -44,7 +45,7 @@ module LogBench
|
|
44
45
|
|
45
46
|
private
|
46
47
|
|
47
|
-
attr_accessor :state, :screen, :renderer, :mouse_handler
|
48
|
+
attr_accessor :state, :screen, :renderer, :mouse_handler, :copy_handler
|
48
49
|
|
49
50
|
def filter_mode_active?
|
50
51
|
state.filter_mode || state.detail_filter_mode
|
@@ -138,6 +139,8 @@ module LogBench
|
|
138
139
|
when "t", "T"
|
139
140
|
state.toggle_text_selection_mode
|
140
141
|
screen.turn_text_selection_mode(state.text_selection_mode?)
|
142
|
+
when "y", "Y"
|
143
|
+
copy_handler.copy_to_clipboard
|
141
144
|
when ESC
|
142
145
|
handle_escape
|
143
146
|
end
|
@@ -152,16 +155,30 @@ module LogBench
|
|
152
155
|
end
|
153
156
|
|
154
157
|
def handle_up_navigation
|
158
|
+
old_selected = state.selected if state.left_pane_focused?
|
155
159
|
state.navigate_up
|
156
|
-
|
160
|
+
if state.left_pane_focused?
|
161
|
+
state.adjust_scroll_for_selection(visible_height)
|
162
|
+
|
163
|
+
# Reset detail selection when switching requests
|
164
|
+
if old_selected != state.selected
|
165
|
+
state.reset_detail_selection
|
166
|
+
end
|
167
|
+
end
|
157
168
|
end
|
158
169
|
|
159
170
|
def handle_down_navigation
|
160
171
|
if state.left_pane_focused?
|
172
|
+
old_selected = state.selected
|
161
173
|
max_index = state.filtered_requests.size - 1
|
162
174
|
state.selected = [state.selected + 1, max_index].min
|
163
175
|
state.auto_scroll = false
|
164
176
|
state.adjust_scroll_for_selection(visible_height)
|
177
|
+
|
178
|
+
# Reset detail selection when switching requests
|
179
|
+
if old_selected != state.selected
|
180
|
+
state.reset_detail_selection
|
181
|
+
end
|
165
182
|
else
|
166
183
|
state.navigate_down
|
167
184
|
end
|
@@ -169,69 +186,109 @@ module LogBench
|
|
169
186
|
|
170
187
|
def handle_page_down
|
171
188
|
if state.left_pane_focused?
|
189
|
+
old_selected = state.selected
|
172
190
|
page_size = visible_height
|
173
191
|
max_index = state.filtered_requests.size - 1
|
174
192
|
state.selected = [state.selected + page_size, max_index].min
|
175
193
|
state.auto_scroll = false
|
176
194
|
state.adjust_scroll_for_selection(visible_height)
|
195
|
+
|
196
|
+
# Reset detail selection when switching requests
|
197
|
+
if old_selected != state.selected
|
198
|
+
state.reset_detail_selection
|
199
|
+
end
|
177
200
|
else
|
178
|
-
|
201
|
+
page_size = visible_height
|
202
|
+
state.detail_selected_entry += page_size
|
179
203
|
end
|
180
204
|
end
|
181
205
|
|
182
206
|
def handle_page_up
|
183
207
|
if state.left_pane_focused?
|
208
|
+
old_selected = state.selected
|
184
209
|
page_size = visible_height
|
185
210
|
state.selected = [state.selected - page_size, 0].max
|
186
211
|
state.auto_scroll = false
|
187
212
|
state.adjust_scroll_for_selection(visible_height)
|
213
|
+
|
214
|
+
# Reset detail selection when switching requests
|
215
|
+
if old_selected != state.selected
|
216
|
+
state.reset_detail_selection
|
217
|
+
end
|
188
218
|
else
|
189
|
-
|
219
|
+
page_size = visible_height
|
220
|
+
state.detail_selected_entry = [state.detail_selected_entry - page_size, 0].max
|
190
221
|
end
|
191
222
|
end
|
192
223
|
|
193
224
|
def handle_half_page_down
|
194
225
|
if state.left_pane_focused?
|
226
|
+
old_selected = state.selected
|
195
227
|
half_page = visible_height / 2
|
196
228
|
max_index = state.filtered_requests.size - 1
|
197
229
|
state.selected = [state.selected + half_page, max_index].min
|
198
230
|
state.auto_scroll = false
|
199
231
|
state.adjust_scroll_for_selection(visible_height)
|
232
|
+
|
233
|
+
# Reset detail selection when switching requests
|
234
|
+
if old_selected != state.selected
|
235
|
+
state.reset_detail_selection
|
236
|
+
end
|
200
237
|
else
|
201
|
-
|
238
|
+
half_page = visible_height / 2
|
239
|
+
state.detail_selected_entry += half_page
|
202
240
|
end
|
203
241
|
end
|
204
242
|
|
205
243
|
def handle_half_page_up
|
206
244
|
if state.left_pane_focused?
|
245
|
+
old_selected = state.selected
|
207
246
|
half_page = visible_height / 2
|
208
247
|
state.selected = [state.selected - half_page, 0].max
|
209
248
|
state.auto_scroll = false
|
210
249
|
state.adjust_scroll_for_selection(visible_height)
|
250
|
+
|
251
|
+
# Reset detail selection when switching requests
|
252
|
+
if old_selected != state.selected
|
253
|
+
state.reset_detail_selection
|
254
|
+
end
|
211
255
|
else
|
212
|
-
|
256
|
+
half_page = visible_height / 2
|
257
|
+
state.detail_selected_entry = [state.detail_selected_entry - half_page, 0].max
|
213
258
|
end
|
214
259
|
end
|
215
260
|
|
216
261
|
def handle_go_to_top
|
217
262
|
if state.left_pane_focused?
|
263
|
+
old_selected = state.selected
|
218
264
|
state.selected = 0
|
219
265
|
state.auto_scroll = false
|
220
266
|
state.adjust_scroll_for_selection(visible_height)
|
267
|
+
|
268
|
+
# Reset detail selection when switching requests
|
269
|
+
if old_selected != state.selected
|
270
|
+
state.reset_detail_selection
|
271
|
+
end
|
221
272
|
else
|
222
|
-
state.
|
273
|
+
state.detail_selected_entry = 0
|
223
274
|
end
|
224
275
|
end
|
225
276
|
|
226
277
|
def handle_go_to_bottom
|
227
278
|
if state.left_pane_focused?
|
279
|
+
old_selected = state.selected
|
228
280
|
max_index = state.filtered_requests.size - 1
|
229
281
|
state.selected = [max_index, 0].max
|
230
282
|
state.auto_scroll = false
|
231
283
|
state.adjust_scroll_for_selection(visible_height)
|
284
|
+
|
285
|
+
# Reset detail selection when switching requests
|
286
|
+
if old_selected != state.selected
|
287
|
+
state.reset_detail_selection
|
288
|
+
end
|
232
289
|
else
|
233
|
-
#
|
234
|
-
state.
|
290
|
+
# Set to a high value, will be adjusted by bounds checking
|
291
|
+
state.detail_selected_entry = 999999
|
235
292
|
end
|
236
293
|
end
|
237
294
|
|
@@ -40,9 +40,15 @@ module LogBench
|
|
40
40
|
request_index = click_to_request_index(y)
|
41
41
|
return unless request_index
|
42
42
|
|
43
|
+
old_selected = state.selected
|
43
44
|
max_index = state.filtered_requests.size - 1
|
44
45
|
state.selected = [request_index, max_index].min
|
45
46
|
state.auto_scroll = false
|
47
|
+
|
48
|
+
# Reset detail selection when switching requests
|
49
|
+
if old_selected != state.selected
|
50
|
+
state.reset_detail_selection
|
51
|
+
end
|
46
52
|
elsif click_in_right_pane?(x, y)
|
47
53
|
# Switch to right pane
|
48
54
|
state.switch_to_right_pane unless state.right_pane_focused?
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class QuerySummary
|
6
|
+
def initialize(request)
|
7
|
+
self.request = request
|
8
|
+
end
|
9
|
+
|
10
|
+
def build_stats
|
11
|
+
# Use memoized methods from request object for better performance
|
12
|
+
stats = {
|
13
|
+
total_queries: request.query_count,
|
14
|
+
total_time: request.total_query_time,
|
15
|
+
cached_queries: request.cached_query_count,
|
16
|
+
select: 0,
|
17
|
+
insert: 0,
|
18
|
+
update: 0,
|
19
|
+
delete: 0,
|
20
|
+
transaction: 0
|
21
|
+
}
|
22
|
+
|
23
|
+
# Categorize by operation type for breakdown
|
24
|
+
request.related_logs.each do |log|
|
25
|
+
next unless [:sql, :cache].include?(log.type)
|
26
|
+
|
27
|
+
categorize_sql_operation(log, stats)
|
28
|
+
end
|
29
|
+
|
30
|
+
stats
|
31
|
+
end
|
32
|
+
|
33
|
+
def build_text_summary
|
34
|
+
query_stats = build_stats
|
35
|
+
|
36
|
+
summary_lines = []
|
37
|
+
summary_lines << "Query Summary:"
|
38
|
+
|
39
|
+
if query_stats[:total_queries] > 0
|
40
|
+
summary_lines << build_summary_line(query_stats)
|
41
|
+
|
42
|
+
breakdown_line = build_breakdown_line(query_stats)
|
43
|
+
summary_lines << breakdown_line unless breakdown_line.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
summary_lines.join("\n")
|
47
|
+
end
|
48
|
+
|
49
|
+
def build_summary_line(query_stats = nil)
|
50
|
+
query_stats ||= build_stats
|
51
|
+
|
52
|
+
summary_parts = ["#{query_stats[:total_queries]} queries"]
|
53
|
+
|
54
|
+
if query_stats[:total_time] > 0
|
55
|
+
time_part = "#{query_stats[:total_time].round(1)}ms total"
|
56
|
+
time_part += ", #{query_stats[:cached_queries]} cached" if query_stats[:cached_queries] > 0
|
57
|
+
summary_parts << "(#{time_part})"
|
58
|
+
elsif query_stats[:cached_queries] > 0
|
59
|
+
summary_parts << "(#{query_stats[:cached_queries]} cached)"
|
60
|
+
end
|
61
|
+
|
62
|
+
summary_parts.join(" ")
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_breakdown_line(query_stats = nil)
|
66
|
+
query_stats ||= build_stats
|
67
|
+
|
68
|
+
breakdown_parts = [
|
69
|
+
("#{query_stats[:select]} SELECT" if query_stats[:select] > 0),
|
70
|
+
("#{query_stats[:insert]} INSERT" if query_stats[:insert] > 0),
|
71
|
+
("#{query_stats[:update]} UPDATE" if query_stats[:update] > 0),
|
72
|
+
("#{query_stats[:delete]} DELETE" if query_stats[:delete] > 0),
|
73
|
+
("#{query_stats[:transaction]} TRANSACTION" if query_stats[:transaction] > 0)
|
74
|
+
].compact
|
75
|
+
|
76
|
+
breakdown_parts.join(", ")
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
attr_accessor :request
|
82
|
+
|
83
|
+
def categorize_sql_operation(log, stats)
|
84
|
+
# Use unified QueryEntry for both SQL and CACHE entries
|
85
|
+
return unless log.is_a?(LogBench::Log::QueryEntry)
|
86
|
+
|
87
|
+
if log.select?
|
88
|
+
stats[:select] += 1
|
89
|
+
elsif log.insert?
|
90
|
+
stats[:insert] += 1
|
91
|
+
elsif log.update?
|
92
|
+
stats[:update] += 1
|
93
|
+
elsif log.delete?
|
94
|
+
stats[:delete] += 1
|
95
|
+
elsif log.transaction? || log.begin? || log.commit? || log.rollback? || log.savepoint?
|
96
|
+
stats[:transaction] += 1
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -39,73 +39,60 @@ module LogBench
|
|
39
39
|
[text]
|
40
40
|
else
|
41
41
|
chunks = []
|
42
|
-
remaining = text
|
43
|
-
active_colors = []
|
44
42
|
|
45
|
-
#
|
46
|
-
text
|
47
|
-
if /\e\[0m/.match?(ansi_code)
|
48
|
-
active_colors.clear
|
49
|
-
else
|
50
|
-
active_colors << ansi_code
|
51
|
-
end
|
52
|
-
end
|
43
|
+
# Parse the text to extract segments with their colors
|
44
|
+
segments = parse_ansi_segments(text)
|
53
45
|
|
54
|
-
|
55
|
-
|
46
|
+
current_chunk = ""
|
47
|
+
current_chunk_length = 0
|
48
|
+
active_color_state = ""
|
56
49
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
50
|
+
segments.each do |segment|
|
51
|
+
if segment[:type] == :ansi
|
52
|
+
# Track color state
|
53
|
+
active_color_state = if segment[:text] == "\e[0m"
|
54
|
+
""
|
61
55
|
else
|
62
|
-
|
56
|
+
segment[:text]
|
63
57
|
end
|
64
|
-
|
58
|
+
current_chunk += segment[:text]
|
65
59
|
else
|
66
|
-
#
|
67
|
-
|
68
|
-
original_pos = 0
|
69
|
-
clean_pos = 0
|
70
|
-
chunk_colors = active_colors.dup
|
71
|
-
|
72
|
-
remaining.each_char.with_index do |char, idx|
|
73
|
-
if /^\e\[[0-9;]*m/.match?(remaining[idx..])
|
74
|
-
# Found ANSI sequence
|
75
|
-
ansi_match = remaining[idx..].match(/^(\e\[[0-9;]*m)/)
|
76
|
-
ansi_code = ansi_match[1]
|
77
|
-
|
78
|
-
if /\e\[0m/.match?(ansi_code)
|
79
|
-
chunk_colors.clear
|
80
|
-
active_colors.clear
|
81
|
-
else
|
82
|
-
chunk_colors << ansi_code unless chunk_colors.include?(ansi_code)
|
83
|
-
active_colors << ansi_code unless active_colors.include?(ansi_code)
|
84
|
-
end
|
60
|
+
# Text segment - check if it fits
|
61
|
+
text_content = segment[:text]
|
85
62
|
|
86
|
-
|
87
|
-
|
88
|
-
else
|
89
|
-
clean_pos += 1
|
90
|
-
original_pos += 1
|
63
|
+
while text_content.length > 0
|
64
|
+
remaining_space = max_width - current_chunk_length
|
91
65
|
|
92
|
-
|
93
|
-
|
66
|
+
if text_content.length <= remaining_space
|
67
|
+
# Entire text fits in current chunk
|
68
|
+
current_chunk += text_content
|
69
|
+
current_chunk_length += text_content.length
|
70
|
+
break
|
71
|
+
else
|
72
|
+
# Need to split the text
|
73
|
+
if remaining_space > 0
|
74
|
+
# Take what fits in current chunk
|
75
|
+
chunk_part = text_content[0...remaining_space]
|
76
|
+
current_chunk += chunk_part
|
77
|
+
text_content = text_content[remaining_space..]
|
94
78
|
end
|
95
|
-
end
|
96
|
-
end
|
97
79
|
|
98
|
-
|
99
|
-
|
100
|
-
active_colors.join("") + chunk_text
|
101
|
-
else
|
102
|
-
chunk_text
|
103
|
-
end
|
80
|
+
# Finish current chunk
|
81
|
+
chunks << current_chunk
|
104
82
|
|
105
|
-
|
83
|
+
# Start new chunk with color state
|
84
|
+
current_chunk = active_color_state
|
85
|
+
current_chunk_length = 0
|
86
|
+
end
|
87
|
+
end
|
106
88
|
end
|
107
89
|
end
|
108
90
|
|
91
|
+
# Add final chunk if it has content
|
92
|
+
if current_chunk.length > 0
|
93
|
+
chunks << current_chunk
|
94
|
+
end
|
95
|
+
|
109
96
|
chunks
|
110
97
|
end
|
111
98
|
end
|
@@ -143,6 +130,40 @@ module LogBench
|
|
143
130
|
|
144
131
|
attr_accessor :screen
|
145
132
|
|
133
|
+
def parse_ansi_segments(text)
|
134
|
+
segments = []
|
135
|
+
remaining = text
|
136
|
+
|
137
|
+
while remaining.length > 0
|
138
|
+
# Look for next ANSI sequence
|
139
|
+
ansi_match = remaining.match(/^(\e\[[0-9;]*m)/)
|
140
|
+
|
141
|
+
if ansi_match
|
142
|
+
# Found ANSI sequence at start
|
143
|
+
segments << {type: :ansi, text: ansi_match[1]}
|
144
|
+
remaining = remaining[ansi_match[1].length..]
|
145
|
+
else
|
146
|
+
# Look for ANSI sequence anywhere in remaining text
|
147
|
+
next_ansi = remaining.match(/(\e\[[0-9;]*m)/)
|
148
|
+
|
149
|
+
if next_ansi
|
150
|
+
# Text before ANSI sequence
|
151
|
+
text_before = remaining[0...next_ansi.begin(1)]
|
152
|
+
if text_before.length > 0
|
153
|
+
segments << {type: :text, text: text_before}
|
154
|
+
end
|
155
|
+
remaining = remaining[next_ansi.begin(1)..]
|
156
|
+
else
|
157
|
+
# No more ANSI sequences, rest is text
|
158
|
+
segments << {type: :text, text: remaining}
|
159
|
+
break
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
segments
|
165
|
+
end
|
166
|
+
|
146
167
|
def ansi_to_curses_color(codes)
|
147
168
|
# Convert ANSI color codes to curses color pairs
|
148
169
|
return nil if codes.empty? || codes == [0]
|
@@ -8,6 +8,7 @@ module LogBench
|
|
8
8
|
class Details
|
9
9
|
include Curses
|
10
10
|
EMPTY_LINE = {text: "", color: nil}
|
11
|
+
SEPARATOR_LINE = {text: "", color: nil, separator: true}
|
11
12
|
|
12
13
|
def initialize(screen, state, scrollbar, ansi_renderer)
|
13
14
|
self.screen = screen
|
@@ -26,6 +27,20 @@ module LogBench
|
|
26
27
|
draw_request_details
|
27
28
|
end
|
28
29
|
|
30
|
+
def get_cached_detail_lines(request)
|
31
|
+
current_cache_key = build_cache_key(request)
|
32
|
+
|
33
|
+
# Return cached lines if cache is still valid
|
34
|
+
if cached_lines && cache_key == current_cache_key
|
35
|
+
return cached_lines
|
36
|
+
end
|
37
|
+
|
38
|
+
# Cache is invalid, rebuild lines
|
39
|
+
self.cached_lines = build_detail_lines(request)
|
40
|
+
self.cache_key = current_cache_key
|
41
|
+
cached_lines
|
42
|
+
end
|
43
|
+
|
29
44
|
private
|
30
45
|
|
31
46
|
attr_accessor :screen, :state, :scrollbar, :ansi_renderer, :cached_lines, :cache_key
|
@@ -60,16 +75,34 @@ module LogBench
|
|
60
75
|
visible_height = detail_win.maxy - 2
|
61
76
|
|
62
77
|
adjust_detail_scroll(lines.size, visible_height)
|
78
|
+
state.adjust_detail_scroll_for_entry_selection(visible_height, lines)
|
79
|
+
|
80
|
+
# Find all unique entry IDs and determine selected entry, excluding separator lines
|
81
|
+
entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
|
82
|
+
selected_entry_id = entry_ids[state.detail_selected_entry] if state.detail_selected_entry < entry_ids.size
|
63
83
|
|
64
84
|
visible_lines = lines[state.detail_scroll_offset, visible_height] || []
|
65
85
|
visible_lines.each_with_index do |line_data, i|
|
66
86
|
y = i + 1 # Start at row 1 (after border)
|
87
|
+
# Don't highlight separator lines
|
88
|
+
is_selected = !line_data[:separator] && line_data[:entry_id] == selected_entry_id && state.right_pane_focused?
|
89
|
+
|
90
|
+
# Draw highlight background if selected
|
91
|
+
if is_selected
|
92
|
+
detail_win.setpos(y, 1)
|
93
|
+
detail_win.attron(color_pair(10) | A_DIM) do
|
94
|
+
detail_win.addstr(" " * (detail_win.maxx - 2))
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
67
98
|
detail_win.setpos(y, 2)
|
68
99
|
|
69
100
|
# Handle multi-segment lines (for mixed colors)
|
70
101
|
if line_data.is_a?(Hash) && line_data[:segments]
|
71
102
|
line_data[:segments].each do |segment|
|
72
|
-
if
|
103
|
+
if is_selected
|
104
|
+
detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(segment[:text]) }
|
105
|
+
elsif segment[:color]
|
73
106
|
detail_win.attron(segment[:color]) { detail_win.addstr(segment[:text]) }
|
74
107
|
else
|
75
108
|
detail_win.addstr(segment[:text])
|
@@ -77,16 +110,26 @@ module LogBench
|
|
77
110
|
end
|
78
111
|
elsif line_data.is_a?(Hash) && line_data[:raw_ansi]
|
79
112
|
# Handle lines with raw ANSI codes (like colorized SQL)
|
80
|
-
|
113
|
+
if is_selected
|
114
|
+
# For selected ANSI lines, render without ANSI codes to maintain highlight
|
115
|
+
plain_text = line_data[:text].gsub(/\e\[[0-9;]*m/, "")
|
116
|
+
detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(plain_text) }
|
117
|
+
else
|
118
|
+
ansi_renderer.parse_and_render(line_data[:text], detail_win)
|
119
|
+
end
|
81
120
|
elsif line_data.is_a?(Hash)
|
82
121
|
# Handle single-color lines
|
83
|
-
if
|
122
|
+
if is_selected
|
123
|
+
detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data[:text]) }
|
124
|
+
elsif line_data[:color]
|
84
125
|
detail_win.attron(line_data[:color]) { detail_win.addstr(line_data[:text]) }
|
85
126
|
else
|
86
127
|
detail_win.addstr(line_data[:text])
|
87
128
|
end
|
88
|
-
|
129
|
+
elsif is_selected
|
89
130
|
# Simple string
|
131
|
+
detail_win.attron(color_pair(10) | A_DIM) { detail_win.addstr(line_data.to_s) }
|
132
|
+
else
|
90
133
|
detail_win.addstr(line_data.to_s)
|
91
134
|
end
|
92
135
|
end
|
@@ -97,20 +140,6 @@ module LogBench
|
|
97
140
|
end
|
98
141
|
end
|
99
142
|
|
100
|
-
def get_cached_detail_lines(request)
|
101
|
-
current_cache_key = build_cache_key(request)
|
102
|
-
|
103
|
-
# Return cached lines if cache is still valid
|
104
|
-
if cached_lines && cache_key == current_cache_key
|
105
|
-
return cached_lines
|
106
|
-
end
|
107
|
-
|
108
|
-
# Cache is invalid, rebuild lines
|
109
|
-
self.cached_lines = build_detail_lines(request)
|
110
|
-
self.cache_key = current_cache_key
|
111
|
-
cached_lines
|
112
|
-
end
|
113
|
-
|
114
143
|
def build_cache_key(request)
|
115
144
|
# Cache key includes factors that affect the rendered output
|
116
145
|
[
|
@@ -123,6 +152,7 @@ module LogBench
|
|
123
152
|
|
124
153
|
def build_detail_lines(request)
|
125
154
|
lines = []
|
155
|
+
entry_id = 0 # Track logical log entries
|
126
156
|
# Cache window width to avoid repeated method calls
|
127
157
|
window_width = detail_win.maxx
|
128
158
|
max_width = window_width - 6 # Leave margin for borders and scrollbar
|
@@ -136,10 +166,12 @@ module LogBench
|
|
136
166
|
else color_pair(2) | A_BOLD
|
137
167
|
end
|
138
168
|
|
139
|
-
lines << EMPTY_LINE
|
169
|
+
lines << EMPTY_LINE.merge(entry_id: entry_id)
|
170
|
+
entry_id += 1
|
140
171
|
lines << {
|
141
172
|
text: "Method: #{request.method}",
|
142
173
|
color: nil,
|
174
|
+
entry_id: entry_id,
|
143
175
|
segments: [
|
144
176
|
{text: "Method: ", color: color_pair(1)},
|
145
177
|
{text: request.method, color: method_color}
|
@@ -147,17 +179,23 @@ module LogBench
|
|
147
179
|
}
|
148
180
|
|
149
181
|
# Path - allow multiple lines with proper color separation
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
182
|
+
entry_id += 1
|
183
|
+
add_path_lines(lines, request, max_width, entry_id)
|
184
|
+
entry_id += 1
|
185
|
+
add_status_duration_lines(lines, request, entry_id)
|
186
|
+
entry_id += 1
|
187
|
+
add_controller_lines(lines, request, entry_id)
|
188
|
+
entry_id += 1
|
189
|
+
add_request_id_lines(lines, request, entry_id)
|
190
|
+
entry_id += 1
|
191
|
+
add_params_lines(lines, request, max_width, entry_id)
|
192
|
+
entry_id += 1
|
193
|
+
add_related_logs_section(lines, request, entry_id)
|
156
194
|
|
157
195
|
lines
|
158
196
|
end
|
159
197
|
|
160
|
-
def add_path_lines(lines, request, max_width)
|
198
|
+
def add_path_lines(lines, request, max_width, entry_id)
|
161
199
|
path_prefix = "Path: "
|
162
200
|
remaining_path = request.path
|
163
201
|
|
@@ -167,6 +205,7 @@ module LogBench
|
|
167
205
|
lines << {
|
168
206
|
text: path_prefix + remaining_path,
|
169
207
|
color: nil,
|
208
|
+
entry_id: entry_id,
|
170
209
|
segments: [
|
171
210
|
{text: path_prefix, color: color_pair(1)},
|
172
211
|
{text: remaining_path, color: nil} # Default white color
|
@@ -178,6 +217,7 @@ module LogBench
|
|
178
217
|
lines << {
|
179
218
|
text: path_prefix + first_chunk,
|
180
219
|
color: nil,
|
220
|
+
entry_id: entry_id,
|
181
221
|
segments: [
|
182
222
|
{text: path_prefix, color: color_pair(1)},
|
183
223
|
{text: first_chunk, color: nil} # Default white color
|
@@ -188,13 +228,13 @@ module LogBench
|
|
188
228
|
# Continue on subsequent lines
|
189
229
|
while remaining_path.length > 0
|
190
230
|
line_chunk = remaining_path[0, max_width]
|
191
|
-
lines << {text: line_chunk, color: nil} # Default white color
|
231
|
+
lines << {text: line_chunk, color: nil, entry_id: entry_id} # Default white color
|
192
232
|
remaining_path = remaining_path[max_width..] || ""
|
193
233
|
end
|
194
234
|
end
|
195
235
|
end
|
196
236
|
|
197
|
-
def add_status_duration_lines(lines, request)
|
237
|
+
def add_status_duration_lines(lines, request, entry_id)
|
198
238
|
if request.status
|
199
239
|
# Add status color coding
|
200
240
|
status_color = case request.status
|
@@ -219,17 +259,19 @@ module LogBench
|
|
219
259
|
lines << {
|
220
260
|
text: status_text,
|
221
261
|
color: nil,
|
262
|
+
entry_id: entry_id,
|
222
263
|
segments: segments
|
223
264
|
}
|
224
265
|
end
|
225
266
|
end
|
226
267
|
|
227
|
-
def add_controller_lines(lines, request)
|
268
|
+
def add_controller_lines(lines, request, entry_id)
|
228
269
|
if request.controller
|
229
270
|
controller_value = "#{request.controller}##{request.action}"
|
230
271
|
lines << {
|
231
272
|
text: "Controller: #{controller_value}",
|
232
273
|
color: nil,
|
274
|
+
entry_id: entry_id,
|
233
275
|
segments: [
|
234
276
|
{text: "Controller: ", color: color_pair(1)},
|
235
277
|
{text: controller_value, color: nil} # Default white color
|
@@ -238,13 +280,14 @@ module LogBench
|
|
238
280
|
end
|
239
281
|
end
|
240
282
|
|
241
|
-
def add_params_lines(lines, request, max_width)
|
283
|
+
def add_params_lines(lines, request, max_width, entry_id)
|
242
284
|
return unless request.params
|
243
285
|
|
244
|
-
lines << EMPTY_LINE
|
286
|
+
lines << EMPTY_LINE.merge(entry_id: entry_id)
|
245
287
|
lines << {
|
246
288
|
text: "Params:",
|
247
289
|
color: nil,
|
290
|
+
entry_id: entry_id,
|
248
291
|
segments: [
|
249
292
|
{text: "Params:", color: color_pair(1) | A_BOLD}
|
250
293
|
]
|
@@ -259,7 +302,7 @@ module LogBench
|
|
259
302
|
|
260
303
|
while remaining_text && remaining_text.length > 0
|
261
304
|
line_chunk = remaining_text[0, line_width]
|
262
|
-
lines << {text: indent + line_chunk, color: nil}
|
305
|
+
lines << {text: indent + line_chunk, color: nil, entry_id: entry_id}
|
263
306
|
remaining_text = remaining_text[line_width..] || ""
|
264
307
|
end
|
265
308
|
end
|
@@ -312,11 +355,12 @@ module LogBench
|
|
312
355
|
end
|
313
356
|
end
|
314
357
|
|
315
|
-
def add_request_id_lines(lines, request)
|
358
|
+
def add_request_id_lines(lines, request, entry_id)
|
316
359
|
if request.request_id
|
317
360
|
lines << {
|
318
361
|
text: "Request ID: #{request.request_id}",
|
319
362
|
color: nil,
|
363
|
+
entry_id: entry_id,
|
320
364
|
segments: [
|
321
365
|
{text: "Request ID: ", color: color_pair(1)},
|
322
366
|
{text: request.request_id, color: nil} # Default white color
|
@@ -333,7 +377,7 @@ module LogBench
|
|
333
377
|
screen.detail_win
|
334
378
|
end
|
335
379
|
|
336
|
-
def add_related_logs_section(lines, request)
|
380
|
+
def add_related_logs_section(lines, request, entry_id)
|
337
381
|
# Related Logs (grouped by request_id) - only show non-HTTP request logs
|
338
382
|
if request.request_id && request.related_logs && !request.related_logs.empty?
|
339
383
|
related_logs = request.related_logs
|
@@ -341,45 +385,30 @@ module LogBench
|
|
341
385
|
# Apply detail filter to related logs
|
342
386
|
filtered_related_logs = filter_related_logs(related_logs)
|
343
387
|
|
344
|
-
# Use
|
345
|
-
|
388
|
+
# Use QuerySummary for consistent formatting
|
389
|
+
query_summary = QuerySummary.new(request)
|
390
|
+
query_stats = query_summary.build_stats
|
346
391
|
|
347
392
|
# Add query summary
|
348
|
-
lines << EMPTY_LINE
|
393
|
+
lines << EMPTY_LINE.merge(entry_id: entry_id)
|
349
394
|
|
350
395
|
# Show filter status in summary if filtering is active
|
351
396
|
summary_title = "Query Summary:"
|
352
|
-
lines << {text: summary_title, color: color_pair(1) | A_BOLD}
|
397
|
+
lines << {text: summary_title, color: color_pair(1) | A_BOLD, entry_id: entry_id}
|
353
398
|
|
354
399
|
if query_stats[:total_queries] > 0
|
355
|
-
#
|
356
|
-
|
357
|
-
|
358
|
-
if query_stats[:total_time] > 0
|
359
|
-
time_part = "#{query_stats[:total_time].round(1)}ms total"
|
360
|
-
time_part += ", #{query_stats[:cached_queries]} cached" if query_stats[:cached_queries] > 0
|
361
|
-
summary_parts << "(#{time_part})"
|
362
|
-
elsif query_stats[:cached_queries] > 0
|
363
|
-
summary_parts << "(#{query_stats[:cached_queries]} cached)"
|
364
|
-
end
|
365
|
-
|
366
|
-
lines << {text: " #{summary_parts.join(" ")}", color: color_pair(2)}
|
367
|
-
|
368
|
-
# Breakdown by operation type - build array efficiently
|
369
|
-
breakdown_parts = [
|
370
|
-
("#{query_stats[:select]} SELECT" if query_stats[:select] > 0),
|
371
|
-
("#{query_stats[:insert]} INSERT" if query_stats[:insert] > 0),
|
372
|
-
("#{query_stats[:update]} UPDATE" if query_stats[:update] > 0),
|
373
|
-
("#{query_stats[:delete]} DELETE" if query_stats[:delete] > 0),
|
374
|
-
("#{query_stats[:transaction]} TRANSACTION" if query_stats[:transaction] > 0)
|
375
|
-
].compact
|
400
|
+
# Use QuerySummary methods for consistent formatting
|
401
|
+
summary_line = query_summary.build_summary_line(query_stats)
|
402
|
+
lines << {text: " #{summary_line}", color: color_pair(2), entry_id: entry_id}
|
376
403
|
|
377
|
-
|
378
|
-
|
404
|
+
breakdown_line = query_summary.build_breakdown_line(query_stats)
|
405
|
+
unless breakdown_line.empty?
|
406
|
+
lines << {text: " #{breakdown_line}", color: color_pair(2), entry_id: entry_id}
|
379
407
|
end
|
380
408
|
end
|
381
409
|
|
382
|
-
|
410
|
+
entry_id += 1
|
411
|
+
lines << EMPTY_LINE.merge(entry_id: entry_id)
|
383
412
|
|
384
413
|
# Show filtered logs section
|
385
414
|
if state.detail_filter.present?
|
@@ -388,6 +417,7 @@ module LogBench
|
|
388
417
|
lines << {
|
389
418
|
text: logs_title_text,
|
390
419
|
color: nil,
|
420
|
+
entry_id: entry_id,
|
391
421
|
segments: [
|
392
422
|
{text: "Related Logs ", color: color_pair(1) | A_BOLD},
|
393
423
|
{text: count_text, color: A_DIM},
|
@@ -395,61 +425,37 @@ module LogBench
|
|
395
425
|
]
|
396
426
|
}
|
397
427
|
else
|
398
|
-
lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD}
|
428
|
+
lines << {text: "Related Logs:", color: color_pair(1) | A_BOLD, entry_id: entry_id}
|
399
429
|
end
|
400
430
|
|
401
|
-
# Use filtered logs for display
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
431
|
+
# Use filtered logs for display - group SQL queries with their call source lines
|
432
|
+
i = 0
|
433
|
+
while i < filtered_related_logs.size
|
434
|
+
current_log = filtered_related_logs[i]
|
435
|
+
next_log = filtered_related_logs[i + 1] if i + 1 < filtered_related_logs.size
|
436
|
+
|
437
|
+
entry_id += 1
|
438
|
+
|
439
|
+
# Check if current log is a SQL/cache query followed by a call source line
|
440
|
+
if [:sql, :cache].include?(current_log.type) && next_log && next_log.type == :sql_call_line
|
441
|
+
# Group the query and call source together with the same entry_id
|
442
|
+
render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 0)
|
443
|
+
render_padded_text_with_spacing(next_log.content, lines, entry_id, extra_empty_lines: 1, use_separator: true)
|
444
|
+
i += 2 # Skip the next log since we processed it
|
406
445
|
else
|
407
|
-
|
446
|
+
# Handle standalone logs
|
447
|
+
case current_log.type
|
448
|
+
when :sql, :cache
|
449
|
+
render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 0)
|
450
|
+
else
|
451
|
+
render_padded_text_with_spacing(current_log.content, lines, entry_id, extra_empty_lines: 1)
|
452
|
+
end
|
453
|
+
i += 1
|
408
454
|
end
|
409
455
|
end
|
410
456
|
end
|
411
457
|
end
|
412
458
|
|
413
|
-
def build_query_stats_from_request(request)
|
414
|
-
# Use memoized methods from request object for better performance
|
415
|
-
stats = {
|
416
|
-
total_queries: request.query_count,
|
417
|
-
total_time: request.total_query_time,
|
418
|
-
cached_queries: request.cached_query_count,
|
419
|
-
select: 0,
|
420
|
-
insert: 0,
|
421
|
-
update: 0,
|
422
|
-
delete: 0,
|
423
|
-
transaction: 0
|
424
|
-
}
|
425
|
-
|
426
|
-
# Categorize by operation type for breakdown
|
427
|
-
request.related_logs.each do |log|
|
428
|
-
next unless [:sql, :cache].include?(log.type)
|
429
|
-
|
430
|
-
categorize_sql_operation(log, stats)
|
431
|
-
end
|
432
|
-
|
433
|
-
stats
|
434
|
-
end
|
435
|
-
|
436
|
-
def categorize_sql_operation(log, stats)
|
437
|
-
# Use unified QueryEntry for both SQL and CACHE entries
|
438
|
-
return unless log.is_a?(LogBench::Log::QueryEntry)
|
439
|
-
|
440
|
-
if log.select?
|
441
|
-
stats[:select] += 1
|
442
|
-
elsif log.insert?
|
443
|
-
stats[:insert] += 1
|
444
|
-
elsif log.update?
|
445
|
-
stats[:update] += 1
|
446
|
-
elsif log.delete?
|
447
|
-
stats[:delete] += 1
|
448
|
-
elsif log.transaction? || log.begin? || log.commit? || log.rollback? || log.savepoint?
|
449
|
-
stats[:transaction] += 1
|
450
|
-
end
|
451
|
-
end
|
452
|
-
|
453
459
|
def filter_related_logs(related_logs)
|
454
460
|
# Filter related logs (SQL, cache, etc.) in the detail pane
|
455
461
|
return related_logs unless state.detail_filter.present?
|
@@ -479,7 +485,7 @@ module LogBench
|
|
479
485
|
matched_indices.sort.map { |index| related_logs[index] }
|
480
486
|
end
|
481
487
|
|
482
|
-
def render_padded_text_with_spacing(text, lines, extra_empty_lines: 1)
|
488
|
+
def render_padded_text_with_spacing(text, lines, entry_id, extra_empty_lines: 1, use_separator: false)
|
483
489
|
# Helper function that renders text with padding, breaking long text into multiple lines
|
484
490
|
content_width = detail_win.maxx - 8 # Account for padding (4 spaces each side)
|
485
491
|
|
@@ -497,14 +503,18 @@ module LogBench
|
|
497
503
|
# Render each chunk as a separate line with padding
|
498
504
|
text_chunks.each do |chunk|
|
499
505
|
lines << if has_ansi
|
500
|
-
{text: " #{chunk} ", color: nil, raw_ansi: true}
|
506
|
+
{text: " #{chunk} ", color: nil, raw_ansi: true, entry_id: entry_id}
|
501
507
|
else
|
502
|
-
{text: " #{chunk} ", color: nil}
|
508
|
+
{text: " #{chunk} ", color: nil, entry_id: entry_id}
|
503
509
|
end
|
504
510
|
end
|
505
511
|
|
506
512
|
# Add extra empty lines after all chunks
|
507
|
-
|
513
|
+
if use_separator
|
514
|
+
extra_empty_lines.times { lines << SEPARATOR_LINE }
|
515
|
+
else
|
516
|
+
extra_empty_lines.times { lines << EMPTY_LINE.merge(entry_id: entry_id) }
|
517
|
+
end
|
508
518
|
end
|
509
519
|
|
510
520
|
def adjust_detail_scroll(total_lines, visible_height)
|
@@ -84,7 +84,7 @@ module LogBench
|
|
84
84
|
|
85
85
|
header_win.setpos(3, 2)
|
86
86
|
header_win.attron(A_DIM) do
|
87
|
-
header_win.addstr("←→/hl:Switch Pane | ↑↓/jk/Click:Navigate | g/G:Top/End")
|
87
|
+
header_win.addstr("←→/hl:Switch Pane | ↑↓/jk/Click:Navigate | g/G:Top/End | y:Copy highlighted")
|
88
88
|
end
|
89
89
|
end
|
90
90
|
|
@@ -30,6 +30,10 @@ module LogBench
|
|
30
30
|
update_modal.handle_input(ch)
|
31
31
|
end
|
32
32
|
|
33
|
+
def get_cached_detail_lines(request)
|
34
|
+
details.get_cached_detail_lines(request)
|
35
|
+
end
|
36
|
+
|
33
37
|
private
|
34
38
|
|
35
39
|
attr_accessor :screen, :state, :header, :scrollbar, :request_list, :ansi_renderer, :details, :update_modal
|
data/lib/log_bench/app/state.rb
CHANGED
@@ -4,7 +4,7 @@ module LogBench
|
|
4
4
|
module App
|
5
5
|
class State
|
6
6
|
attr_reader :main_filter, :sort, :detail_filter
|
7
|
-
attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :text_selection_mode, :update_available, :update_version
|
7
|
+
attr_accessor :requests, :auto_scroll, :scroll_offset, :selected, :detail_scroll_offset, :detail_selected_entry, :text_selection_mode, :update_available, :update_version
|
8
8
|
|
9
9
|
def initialize
|
10
10
|
self.requests = []
|
@@ -14,6 +14,7 @@ module LogBench
|
|
14
14
|
self.running = true
|
15
15
|
self.focused_pane = :left
|
16
16
|
self.detail_scroll_offset = 0
|
17
|
+
self.detail_selected_entry = 0
|
17
18
|
self.text_selection_mode = false
|
18
19
|
self.main_filter = Filter.new
|
19
20
|
self.detail_filter = Filter.new
|
@@ -65,6 +66,7 @@ module LogBench
|
|
65
66
|
def clear_detail_filter
|
66
67
|
detail_filter.clear
|
67
68
|
self.detail_scroll_offset = 0
|
69
|
+
self.detail_selected_entry = 0
|
68
70
|
end
|
69
71
|
|
70
72
|
def cycle_sort_mode
|
@@ -153,7 +155,7 @@ module LogBench
|
|
153
155
|
self.selected = [selected - 1, 0].max
|
154
156
|
self.auto_scroll = false
|
155
157
|
else
|
156
|
-
self.
|
158
|
+
self.detail_selected_entry = [detail_selected_entry - 1, 0].max
|
157
159
|
end
|
158
160
|
end
|
159
161
|
|
@@ -163,10 +165,15 @@ module LogBench
|
|
163
165
|
self.selected = [selected + 1, max_index].min
|
164
166
|
self.auto_scroll = false
|
165
167
|
else
|
166
|
-
self.
|
168
|
+
self.detail_selected_entry += 1
|
167
169
|
end
|
168
170
|
end
|
169
171
|
|
172
|
+
def reset_detail_selection
|
173
|
+
self.detail_selected_entry = 0
|
174
|
+
self.detail_scroll_offset = 0
|
175
|
+
end
|
176
|
+
|
170
177
|
def adjust_scroll_for_selection(visible_height)
|
171
178
|
return unless left_pane_focused?
|
172
179
|
|
@@ -190,6 +197,41 @@ module LogBench
|
|
190
197
|
self.scroll_offset = scroll_offset.clamp(0, max_offset)
|
191
198
|
end
|
192
199
|
|
200
|
+
def adjust_detail_scroll_for_entry_selection(visible_height, lines)
|
201
|
+
return unless right_pane_focused?
|
202
|
+
|
203
|
+
# Find all unique entry IDs, excluding separator lines
|
204
|
+
entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
|
205
|
+
max_entry_index = [entry_ids.size - 1, 0].max
|
206
|
+
|
207
|
+
# Ensure detail_selected_entry is within bounds
|
208
|
+
self.detail_selected_entry = detail_selected_entry.clamp(0, max_entry_index)
|
209
|
+
|
210
|
+
# Find the first and last line of the selected entry
|
211
|
+
selected_entry_id = entry_ids[detail_selected_entry]
|
212
|
+
return unless selected_entry_id
|
213
|
+
|
214
|
+
first_line_index = lines.find_index { |line| line[:entry_id] == selected_entry_id }
|
215
|
+
return unless first_line_index
|
216
|
+
|
217
|
+
# Find the last line of the selected entry (including any separator lines that follow)
|
218
|
+
last_line_index = first_line_index
|
219
|
+
(first_line_index + 1...lines.size).each do |i|
|
220
|
+
if lines[i][:entry_id] == selected_entry_id || lines[i][:separator]
|
221
|
+
last_line_index = i
|
222
|
+
else
|
223
|
+
break
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# Adjust scroll to keep the entire selected entry visible
|
228
|
+
if first_line_index < detail_scroll_offset
|
229
|
+
self.detail_scroll_offset = first_line_index
|
230
|
+
elsif last_line_index >= detail_scroll_offset + visible_height
|
231
|
+
self.detail_scroll_offset = last_line_index - visible_height + 1
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
193
235
|
private
|
194
236
|
|
195
237
|
attr_accessor :focused_pane, :running
|
data/lib/log_bench/version.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: log_bench
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Benjamín Silva
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-08-
|
10
|
+
date: 2025-08-29 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: zeitwerk
|
@@ -124,11 +124,14 @@ files:
|
|
124
124
|
- Rakefile
|
125
125
|
- exe/log_bench
|
126
126
|
- lib/log_bench.rb
|
127
|
+
- lib/log_bench/app/clipboard.rb
|
128
|
+
- lib/log_bench/app/copy_handler.rb
|
127
129
|
- lib/log_bench/app/filter.rb
|
128
130
|
- lib/log_bench/app/input_handler.rb
|
129
131
|
- lib/log_bench/app/main.rb
|
130
132
|
- lib/log_bench/app/monitor.rb
|
131
133
|
- lib/log_bench/app/mouse_handler.rb
|
134
|
+
- lib/log_bench/app/query_summary.rb
|
132
135
|
- lib/log_bench/app/renderer/ansi.rb
|
133
136
|
- lib/log_bench/app/renderer/details.rb
|
134
137
|
- lib/log_bench/app/renderer/header.rb
|