log_bench 0.2.6 → 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: b7dee0d20491347e20b631992dd88b3859ddc373dd4962306fa8d36a1955f119
4
- data.tar.gz: 2923c231a8afaa145b42d485319c997c03cd4765bc13c0e224cb3e8a6955bdaf
3
+ metadata.gz: a7bf1724c565d5848f6867b14ec19675a50594b369cb3d7729d117b310f4d399
4
+ data.tar.gz: cc2c21dc13a0981b11c92933ecbe967a6cf98b7a7ff21e27d322e156932e42a0
5
5
  SHA512:
6
- metadata.gz: 70fb36f5e8bbf5c349e40ad145971e90526c1f37cdfe08f3e93a7e539ef6a99cbfe90be6ddb91e39f9eb6d77009b9fb91b6b4cb4facc5a4d7c53a7dbb20dc1be
7
- data.tar.gz: 7474a672815d2403fcc9e4fdf7e0c962f763476bda2662ffedbd2332c9e043c5eb37204e30d65a9a317696aad9eb3f33a0547a6419a7f05a00afd6b9570ed539
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]