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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08b5b0c6673f13c5ef68af14e5699ac8591d46605a0783e9d9ad70036feaf10e'
4
- data.tar.gz: '06338df9398f01ba5c0235c01952c1ee4c59a3ee1b56f555d18441363ecea820'
3
+ metadata.gz: a7bf1724c565d5848f6867b14ec19675a50594b369cb3d7729d117b310f4d399
4
+ data.tar.gz: cc2c21dc13a0981b11c92933ecbe967a6cf98b7a7ff21e27d322e156932e42a0
5
5
  SHA512:
6
- metadata.gz: 2d9c1e13307b411dc4da7839102ef7751414567f066893a1b405b9f4e16a2cd6300da26ecaa1af492ed64ba9d2ccd6134c89a37d596a4faa1b8fc5447239127c
7
- data.tar.gz: 0307b86cc997309aba98fc81c93cb0a01ac87c78866d227b9d86716eb8a7486837547516913188af41cc85c331e2dfa85e34985112c71281c59336253392b00c
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
- state.adjust_scroll_for_selection(visible_height) if state.left_pane_focused?
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
- state.detail_scroll_offset += visible_height
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
- state.detail_scroll_offset = [state.detail_scroll_offset - visible_height, 0].max
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
- state.detail_scroll_offset += visible_height / 2
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
- state.detail_scroll_offset = [state.detail_scroll_offset - visible_height / 2, 0].max
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.detail_scroll_offset = 0
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
- # Calculate max scroll for detail pane
234
- state.detail_scroll_offset = 999 # Will be adjusted by renderer
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
- # Extract initial color state
46
- text.scan(/\e\[[0-9;]*m/) do |ansi_code|
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
- while remaining.length > 0
55
- clean_remaining = remaining.gsub(/\e\[[0-9;]*m/, "")
46
+ current_chunk = ""
47
+ current_chunk_length = 0
48
+ active_color_state = ""
56
49
 
57
- if clean_remaining.length <= max_width
58
- # Last chunk
59
- chunks << if active_colors.any? && !remaining.start_with?(*active_colors)
60
- active_colors.join("") + remaining
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
- remaining
56
+ segment[:text]
63
57
  end
64
- break
58
+ current_chunk += segment[:text]
65
59
  else
66
- # Find break point and preserve color state
67
- break_point = max_width
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
- original_pos += ansi_code.length
87
- idx + ansi_code.length - 1
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
- if clean_pos >= break_point
93
- break
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
- chunk_text = remaining[0...original_pos]
99
- chunks << if active_colors.any? && !chunk_text.start_with?(*active_colors)
100
- active_colors.join("") + chunk_text
101
- else
102
- chunk_text
103
- end
80
+ # Finish current chunk
81
+ chunks << current_chunk
104
82
 
105
- remaining = remaining[original_pos..]
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 segment[:color]
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
- ansi_renderer.parse_and_render(line_data[:text], detail_win)
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 line_data[:color]
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
- else
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
- add_path_lines(lines, request, max_width)
151
- add_status_duration_lines(lines, request)
152
- add_controller_lines(lines, request)
153
- add_request_id_lines(lines, request)
154
- add_params_lines(lines, request, max_width)
155
- add_related_logs_section(lines, request)
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 memoized query statistics from request object
345
- query_stats = build_query_stats_from_request(request)
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
- # Build summary line with string interpolation
356
- summary_parts = ["#{query_stats[:total_queries]} queries"]
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
- unless breakdown_parts.empty?
378
- lines << {text: " #{breakdown_parts.join(", ")}", color: color_pair(2)}
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
- lines << EMPTY_LINE
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
- filtered_related_logs.each do |related|
403
- case related.type
404
- when :sql, :cache
405
- render_padded_text_with_spacing(related.content, lines, extra_empty_lines: 0)
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
- render_padded_text_with_spacing(related.content, lines, extra_empty_lines: 1)
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
- extra_empty_lines.times { lines << EMPTY_LINE }
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
@@ -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.detail_scroll_offset = [detail_scroll_offset - 1, 0].max
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.detail_scroll_offset += 1
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LogBench
4
- VERSION = "0.2.8"
4
+ VERSION = "0.2.9"
5
5
  end
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.8
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-20 00:00:00.000000000 Z
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