tp_tree 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/Rakefile +12 -0
- data/examples/interactive_timing_demo.rb +35 -0
- data/examples/semi_empty_nodes_demo.rb +47 -0
- data/examples/timing_demo.rb +28 -0
- data/exe/tp_tree +23 -0
- data/lib/tp_tree/call_stack.rb +72 -0
- data/lib/tp_tree/formatter.rb +47 -0
- data/lib/tp_tree/formatters/ansi_formatter.rb +20 -0
- data/lib/tp_tree/formatters/base_formatter.rb +87 -0
- data/lib/tp_tree/formatters/xml_formatter.rb +14 -0
- data/lib/tp_tree/interactive_viewer.rb +687 -0
- data/lib/tp_tree/method_filter.rb +53 -0
- data/lib/tp_tree/presenters/tree_node_presenter.rb +76 -0
- data/lib/tp_tree/tree_builder.rb +136 -0
- data/lib/tp_tree/tree_node.rb +105 -0
- data/lib/tp_tree/version.rb +5 -0
- data/lib/tp_tree/xml_formatter.rb +47 -0
- data/lib/tp_tree.rb +38 -0
- data/sig/tp_tree.rbs +4 -0
- metadata +71 -0
@@ -0,0 +1,687 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'curses'
|
4
|
+
require 'set'
|
5
|
+
require_relative 'xml_formatter'
|
6
|
+
require_relative 'tree_node'
|
7
|
+
|
8
|
+
module TPTree
|
9
|
+
class InteractiveViewer
|
10
|
+
include XMLFormatter
|
11
|
+
|
12
|
+
def initialize(tree)
|
13
|
+
@tree = tree
|
14
|
+
@original_tree = tree # Keep reference to original tree for navigation
|
15
|
+
@tree_stack = [] # Stack to track zoom levels
|
16
|
+
@lines = []
|
17
|
+
@visible_lines = []
|
18
|
+
@scroll_pos = 0
|
19
|
+
@cursor_pos = 0
|
20
|
+
@stdscr = Curses.init_screen
|
21
|
+
@expanded_nodes = Set.new
|
22
|
+
@node_children = {}
|
23
|
+
|
24
|
+
# Initialize all nodes as expanded and build parent-child relationships
|
25
|
+
analyze_tree_structure
|
26
|
+
expand_all_initially
|
27
|
+
end
|
28
|
+
|
29
|
+
def show
|
30
|
+
Curses.start_color
|
31
|
+
init_color_pairs
|
32
|
+
Curses.curs_set(0) # Hide cursor
|
33
|
+
Curses.noecho
|
34
|
+
@stdscr.keypad(true)
|
35
|
+
|
36
|
+
prepare_lines
|
37
|
+
update_visible_lines
|
38
|
+
main_loop
|
39
|
+
ensure
|
40
|
+
Curses.close_screen
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def analyze_tree_structure
|
46
|
+
@node_children = {}
|
47
|
+
call_stack = []
|
48
|
+
|
49
|
+
@tree.each_with_index do |node, index|
|
50
|
+
case node.event
|
51
|
+
when :call
|
52
|
+
# This is a call that will have children (has separate return)
|
53
|
+
call_stack.push(index)
|
54
|
+
@node_children[index] = []
|
55
|
+
when :return
|
56
|
+
# This is a return, pop the corresponding call
|
57
|
+
if call_stack.any?
|
58
|
+
call_index = call_stack.pop
|
59
|
+
# Mark all nodes between call and return as children
|
60
|
+
@node_children[call_index] = ((call_index + 1)...index).to_a
|
61
|
+
end
|
62
|
+
when :call_return
|
63
|
+
# This is a leaf node (no children)
|
64
|
+
@node_children[index] = []
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def expand_all_initially
|
70
|
+
@node_children.each_key do |index|
|
71
|
+
@expanded_nodes.add(index) if has_children?(index)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def has_children?(index)
|
76
|
+
@node_children[index] && @node_children[index].any?
|
77
|
+
end
|
78
|
+
|
79
|
+
def is_expanded?(index)
|
80
|
+
@expanded_nodes.include?(index)
|
81
|
+
end
|
82
|
+
|
83
|
+
def toggle_expansion(index)
|
84
|
+
if has_children?(index)
|
85
|
+
if is_expanded?(index)
|
86
|
+
@expanded_nodes.delete(index)
|
87
|
+
else
|
88
|
+
@expanded_nodes.add(index)
|
89
|
+
end
|
90
|
+
update_visible_lines
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def update_visible_lines
|
95
|
+
@visible_lines = []
|
96
|
+
@tree.each_with_index do |node, index|
|
97
|
+
@visible_lines << index if should_show_node?(index)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def should_show_node?(index)
|
102
|
+
# Always show root level nodes
|
103
|
+
return true if @tree[index].depth == 0
|
104
|
+
|
105
|
+
# Check if ALL ancestors are expanded
|
106
|
+
all_ancestors_expanded?(index)
|
107
|
+
end
|
108
|
+
|
109
|
+
def all_ancestors_expanded?(index)
|
110
|
+
current_depth = @tree[index].depth
|
111
|
+
|
112
|
+
# Check each ancestor level
|
113
|
+
(1..current_depth).each do |depth_to_check|
|
114
|
+
ancestor_index = find_ancestor_at_depth(index, depth_to_check - 1)
|
115
|
+
next if ancestor_index.nil?
|
116
|
+
|
117
|
+
# If this ancestor has children and is not expanded, hide this node
|
118
|
+
if has_children?(ancestor_index) && !is_expanded?(ancestor_index)
|
119
|
+
return false
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
true
|
124
|
+
end
|
125
|
+
|
126
|
+
def find_ancestor_at_depth(index, target_depth)
|
127
|
+
current_depth = @tree[index].depth
|
128
|
+
|
129
|
+
# Look backwards for a call at the target depth
|
130
|
+
(index - 1).downto(0) do |i|
|
131
|
+
node = @tree[i]
|
132
|
+
if node.depth == target_depth && (node.event == :call || node.event == :call_return)
|
133
|
+
return i
|
134
|
+
end
|
135
|
+
# Stop if we've gone too far back (past a shallower depth)
|
136
|
+
break if node.depth < target_depth
|
137
|
+
end
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
def find_parent_call(index)
|
142
|
+
current_depth = @tree[index].depth
|
143
|
+
|
144
|
+
# Look backwards for a call at depth-1
|
145
|
+
(index - 1).downto(0) do |i|
|
146
|
+
node = @tree[i]
|
147
|
+
if node.depth == current_depth - 1 && (node.event == :call || node.event == :call_return)
|
148
|
+
return i
|
149
|
+
end
|
150
|
+
end
|
151
|
+
nil
|
152
|
+
end
|
153
|
+
|
154
|
+
def init_color_pairs
|
155
|
+
Curses.init_pair(Curses::COLOR_RED, Curses::COLOR_RED, Curses::COLOR_BLACK)
|
156
|
+
Curses.init_pair(Curses::COLOR_GREEN, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
|
157
|
+
Curses.init_pair(Curses::COLOR_YELLOW, Curses::COLOR_YELLOW, Curses::COLOR_BLACK)
|
158
|
+
Curses.init_pair(Curses::COLOR_BLUE, Curses::COLOR_BLUE, Curses::COLOR_BLACK)
|
159
|
+
Curses.init_pair(Curses::COLOR_MAGENTA, Curses::COLOR_MAGENTA, Curses::COLOR_BLACK)
|
160
|
+
Curses.init_pair(Curses::COLOR_CYAN, Curses::COLOR_CYAN, Curses::COLOR_BLACK)
|
161
|
+
Curses.init_pair(Curses::COLOR_WHITE, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
|
162
|
+
|
163
|
+
# Status bar color pairs with default background
|
164
|
+
Curses.init_pair(8, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
|
165
|
+
Curses.init_pair(9, Curses::COLOR_GREEN, Curses::COLOR_BLACK) # class name: green
|
166
|
+
Curses.init_pair(10, Curses::COLOR_YELLOW, Curses::COLOR_BLACK) # method name: yellow
|
167
|
+
end
|
168
|
+
|
169
|
+
def prepare_lines
|
170
|
+
@lines = @tree.map do |node|
|
171
|
+
node.to_parts(formatter: self)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def main_loop
|
176
|
+
loop do
|
177
|
+
adjust_scroll
|
178
|
+
draw
|
179
|
+
handle_input
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def adjust_scroll
|
184
|
+
max_visible_lines = @stdscr.maxy - 1 # Reserve one line for status bar
|
185
|
+
|
186
|
+
if @cursor_pos < @scroll_pos
|
187
|
+
@scroll_pos = @cursor_pos
|
188
|
+
elsif @cursor_pos >= @scroll_pos + max_visible_lines
|
189
|
+
@scroll_pos = @cursor_pos - max_visible_lines + 1
|
190
|
+
end
|
191
|
+
|
192
|
+
# Ensure we don't scroll past the end
|
193
|
+
max_scroll = [@visible_lines.size - max_visible_lines, 0].max
|
194
|
+
@scroll_pos = [@scroll_pos, max_scroll].min
|
195
|
+
|
196
|
+
# Special case: if we're at the last selectable line, make sure final returns are visible
|
197
|
+
last_selectable_idx = find_last_selectable_line
|
198
|
+
if @cursor_pos == last_selectable_idx
|
199
|
+
# Calculate scroll to show the cursor and as many final visible lines as possible
|
200
|
+
desired_scroll = [@visible_lines.size - max_visible_lines, 0].max
|
201
|
+
@scroll_pos = [desired_scroll, @scroll_pos].max
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
def find_last_selectable_line
|
206
|
+
(@visible_lines.size - 1).downto(0) do |i|
|
207
|
+
actual_index = @visible_lines[i]
|
208
|
+
return i if @tree[actual_index].event != :return
|
209
|
+
end
|
210
|
+
0 # fallback
|
211
|
+
end
|
212
|
+
|
213
|
+
def draw
|
214
|
+
@stdscr.clear
|
215
|
+
draw_content
|
216
|
+
draw_status_bar
|
217
|
+
@stdscr.refresh
|
218
|
+
end
|
219
|
+
|
220
|
+
def draw_content
|
221
|
+
(@stdscr.maxy - 1).times do |i|
|
222
|
+
visible_line_idx = @scroll_pos + i
|
223
|
+
next unless visible_line_idx < @visible_lines.size
|
224
|
+
|
225
|
+
actual_line_idx = @visible_lines[visible_line_idx]
|
226
|
+
display_line(@lines[actual_line_idx], i, visible_line_idx, actual_line_idx)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def display_line(parts, row, visible_line_idx, actual_line_idx)
|
231
|
+
prefix_parts, content_xml = parts
|
232
|
+
x = 0
|
233
|
+
|
234
|
+
# Add expansion indicator for nodes with children
|
235
|
+
if has_children?(actual_line_idx)
|
236
|
+
expansion_indicator = is_expanded?(actual_line_idx) ? "[-] " : "[+] "
|
237
|
+
@stdscr.attron(Curses.color_pair(Curses::COLOR_CYAN))
|
238
|
+
@stdscr.setpos(row, x)
|
239
|
+
@stdscr.addstr(expansion_indicator)
|
240
|
+
@stdscr.attroff(Curses.color_pair(Curses::COLOR_CYAN))
|
241
|
+
x += expansion_indicator.length
|
242
|
+
else
|
243
|
+
# Add spacing to align with expandable nodes
|
244
|
+
@stdscr.setpos(row, x)
|
245
|
+
@stdscr.addstr(" ")
|
246
|
+
x += 4
|
247
|
+
end
|
248
|
+
|
249
|
+
# Draw prefix (no highlighting)
|
250
|
+
prefix_parts.each do |text, color_name|
|
251
|
+
attrs = color_pair_for(color_name) || Curses.color_pair(Curses::COLOR_WHITE)
|
252
|
+
|
253
|
+
@stdscr.attron(attrs)
|
254
|
+
@stdscr.setpos(row, x)
|
255
|
+
@stdscr.addstr(text)
|
256
|
+
@stdscr.attroff(attrs)
|
257
|
+
x += text.length
|
258
|
+
end
|
259
|
+
|
260
|
+
# Draw content with method name highlighting for current line
|
261
|
+
draw_xml_string(content_xml, row, x, visible_line_idx == @cursor_pos)
|
262
|
+
end
|
263
|
+
|
264
|
+
def draw_xml_string(xml_str, row, start_x, highlight_method_name = false)
|
265
|
+
x = start_x
|
266
|
+
xml_str.scan(/(<(\w+)>)?([^<]+)(<\/\w+>)?/).each do |_, color_tag, text, _|
|
267
|
+
color_name = color_tag&.to_sym
|
268
|
+
|
269
|
+
# Highlight method name (colored text) on current line
|
270
|
+
is_method_name = highlight_method_name && color_name && color_name != :white
|
271
|
+
|
272
|
+
attrs = color_pair_for(color_name) || Curses.color_pair(Curses::COLOR_WHITE)
|
273
|
+
attrs |= Curses::A_STANDOUT if is_method_name
|
274
|
+
|
275
|
+
@stdscr.attron(attrs)
|
276
|
+
@stdscr.setpos(row, x)
|
277
|
+
@stdscr.addstr(text)
|
278
|
+
@stdscr.attroff(attrs)
|
279
|
+
x += text.length
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
def color_pair_for(color_name)
|
284
|
+
case color_name
|
285
|
+
when :red then Curses.color_pair(Curses::COLOR_RED)
|
286
|
+
when :green then Curses.color_pair(Curses::COLOR_GREEN)
|
287
|
+
when :yellow then Curses.color_pair(Curses::COLOR_YELLOW)
|
288
|
+
when :blue then Curses.color_pair(Curses::COLOR_BLUE)
|
289
|
+
when :magenta then Curses.color_pair(Curses::COLOR_MAGENTA)
|
290
|
+
when :cyan then Curses.color_pair(Curses::COLOR_CYAN)
|
291
|
+
else Curses.color_pair(Curses::COLOR_WHITE)
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def draw_status_bar
|
296
|
+
actual_index = @visible_lines[@cursor_pos] if @cursor_pos < @visible_lines.size
|
297
|
+
return unless actual_index
|
298
|
+
|
299
|
+
node = @tree[actual_index]
|
300
|
+
|
301
|
+
# Build method signature
|
302
|
+
method_signature = if node.defined_class
|
303
|
+
class_name, separator = format_class_and_separator(node.defined_class)
|
304
|
+
"#{class_name}#{separator}#{node.method_name}"
|
305
|
+
else
|
306
|
+
node.method_name.to_s
|
307
|
+
end
|
308
|
+
|
309
|
+
# Add zoom level indicator
|
310
|
+
if @tree_stack.any?
|
311
|
+
zoom_indicator = " [#{@tree_stack.size + 1}]"
|
312
|
+
method_signature += zoom_indicator
|
313
|
+
end
|
314
|
+
|
315
|
+
# Build location info
|
316
|
+
location_info = if node.path && node.lineno
|
317
|
+
filename = File.basename(node.path)
|
318
|
+
"#{filename}:#{node.lineno}"
|
319
|
+
else
|
320
|
+
""
|
321
|
+
end
|
322
|
+
|
323
|
+
# Create a status bar with method on left, file on right
|
324
|
+
status_bar_width = @stdscr.maxx
|
325
|
+
padding = 2
|
326
|
+
|
327
|
+
# Calculate available space for content
|
328
|
+
left_padding = " " * padding
|
329
|
+
right_padding = " " * padding
|
330
|
+
|
331
|
+
# Calculate middle spacing
|
332
|
+
used_space = left_padding.length + method_signature.length + location_info.length + right_padding.length
|
333
|
+
available_space = status_bar_width - used_space
|
334
|
+
|
335
|
+
# Handle overflow by truncating the method signature
|
336
|
+
if available_space < 0
|
337
|
+
max_method_length = status_bar_width - location_info.length - (padding * 2) - 3 # 3 for "..."
|
338
|
+
if max_method_length > 0
|
339
|
+
method_signature = method_signature[0, max_method_length] + "..."
|
340
|
+
available_space = status_bar_width - left_padding.length - method_signature.length - location_info.length - right_padding.length
|
341
|
+
else
|
342
|
+
# Extreme case: just show truncated method, no file info
|
343
|
+
method_signature = method_signature[0, status_bar_width - (padding * 2) - 3] + "..."
|
344
|
+
location_info = ""
|
345
|
+
available_space = 0
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
# Calculate middle spacing
|
350
|
+
middle_spacing = available_space > 0 ? " " * available_space : ""
|
351
|
+
|
352
|
+
# Start drawing from left
|
353
|
+
x = 0
|
354
|
+
white_pair = Curses.color_pair(8)
|
355
|
+
|
356
|
+
# Draw left padding in white
|
357
|
+
@stdscr.attron(white_pair)
|
358
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
359
|
+
@stdscr.addstr(left_padding)
|
360
|
+
x += left_padding.length
|
361
|
+
@stdscr.attroff(white_pair)
|
362
|
+
|
363
|
+
# Split method_signature into class/sep/method if separator present
|
364
|
+
class_part, sep, method_part = method_signature.rpartition(/[.#]/)
|
365
|
+
if sep.empty?
|
366
|
+
# Fallback: no separator found
|
367
|
+
class_part = ""
|
368
|
+
sep = ""
|
369
|
+
method_part = method_signature
|
370
|
+
end
|
371
|
+
|
372
|
+
# Class name in green
|
373
|
+
green_pair = Curses.color_pair(9)
|
374
|
+
unless class_part.empty?
|
375
|
+
@stdscr.attron(green_pair)
|
376
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
377
|
+
@stdscr.addstr(class_part)
|
378
|
+
@stdscr.attroff(green_pair)
|
379
|
+
x += class_part.length
|
380
|
+
end
|
381
|
+
|
382
|
+
# Separator in white
|
383
|
+
unless sep.empty?
|
384
|
+
@stdscr.attron(white_pair)
|
385
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
386
|
+
@stdscr.addstr(sep)
|
387
|
+
@stdscr.attroff(white_pair)
|
388
|
+
x += sep.length
|
389
|
+
end
|
390
|
+
|
391
|
+
# Method name in yellow
|
392
|
+
yellow_pair = Curses.color_pair(10)
|
393
|
+
@stdscr.attron(yellow_pair)
|
394
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
395
|
+
@stdscr.addstr(method_part)
|
396
|
+
@stdscr.attroff(yellow_pair)
|
397
|
+
x += method_part.length
|
398
|
+
|
399
|
+
# Middle spacing in white
|
400
|
+
@stdscr.attron(white_pair)
|
401
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
402
|
+
@stdscr.addstr(middle_spacing)
|
403
|
+
@stdscr.attroff(white_pair)
|
404
|
+
x += middle_spacing.length
|
405
|
+
|
406
|
+
# Location info in white
|
407
|
+
@stdscr.attron(white_pair)
|
408
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
409
|
+
@stdscr.addstr(location_info)
|
410
|
+
@stdscr.attroff(white_pair)
|
411
|
+
x += location_info.length
|
412
|
+
|
413
|
+
# Right padding in white
|
414
|
+
@stdscr.attron(white_pair)
|
415
|
+
@stdscr.setpos(@stdscr.maxy - 1, x)
|
416
|
+
@stdscr.addstr(right_padding)
|
417
|
+
@stdscr.attroff(white_pair)
|
418
|
+
end
|
419
|
+
|
420
|
+
def format_class_and_separator(klass)
|
421
|
+
class_str = klass.to_s
|
422
|
+
|
423
|
+
# Handle singleton classes like #<Class:Gem::Specification>
|
424
|
+
if class_str.match(/^#<Class:(.+)>$/)
|
425
|
+
["#{$1}", "."] # Class method: Gem::Specification.method_name
|
426
|
+
# Handle singleton classes for instances like #<Class:#<Gem::ConfigFile:0x...>>
|
427
|
+
elsif class_str.match(/^#<Class:#<(.+?):/)
|
428
|
+
["#{$1}.instance.class", "#"] # Instance singleton: Class.instance.class#method
|
429
|
+
# Handle regular classes and modules
|
430
|
+
else
|
431
|
+
[class_str, "#"] # Instance method: ClassName#method_name
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
def current_method_name
|
436
|
+
return 'N/A' if @visible_lines.empty?
|
437
|
+
|
438
|
+
actual_index = @visible_lines[@cursor_pos]
|
439
|
+
return 'N/A' unless actual_index
|
440
|
+
|
441
|
+
node = @tree[actual_index]
|
442
|
+
node.method_name.to_s
|
443
|
+
end
|
444
|
+
|
445
|
+
def handle_input
|
446
|
+
case @stdscr.getch
|
447
|
+
when Curses::KEY_UP, 'k'
|
448
|
+
move_up
|
449
|
+
when Curses::KEY_DOWN, 'j'
|
450
|
+
move_down
|
451
|
+
when Curses::KEY_RIGHT, 'l'
|
452
|
+
expand_current_node
|
453
|
+
when Curses::KEY_LEFT, 'h'
|
454
|
+
collapse_current_node
|
455
|
+
when Curses::KEY_ENTER, 10, 13 # Enter key
|
456
|
+
enter_current_call
|
457
|
+
when 'T'
|
458
|
+
collapse_all
|
459
|
+
when 't'
|
460
|
+
expand_all
|
461
|
+
when 'b' # Back - go up one level in the call stack
|
462
|
+
go_back
|
463
|
+
when 'q'
|
464
|
+
exit
|
465
|
+
end
|
466
|
+
end
|
467
|
+
|
468
|
+
def expand_current_node
|
469
|
+
return if @visible_lines.empty?
|
470
|
+
|
471
|
+
actual_index = @visible_lines[@cursor_pos]
|
472
|
+
if has_children?(actual_index) && !is_expanded?(actual_index)
|
473
|
+
@expanded_nodes.add(actual_index)
|
474
|
+
update_visible_lines
|
475
|
+
end
|
476
|
+
end
|
477
|
+
|
478
|
+
def collapse_current_node
|
479
|
+
return if @visible_lines.empty?
|
480
|
+
|
481
|
+
actual_index = @visible_lines[@cursor_pos]
|
482
|
+
if has_children?(actual_index) && is_expanded?(actual_index)
|
483
|
+
@expanded_nodes.delete(actual_index)
|
484
|
+
update_visible_lines
|
485
|
+
|
486
|
+
# If we collapsed and cursor is beyond visible lines, adjust
|
487
|
+
if @cursor_pos >= @visible_lines.size
|
488
|
+
@cursor_pos = [@visible_lines.size - 1, 0].max
|
489
|
+
end
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
def enter_current_call
|
494
|
+
return if @visible_lines.empty?
|
495
|
+
|
496
|
+
actual_index = @visible_lines[@cursor_pos]
|
497
|
+
current_node = @tree[actual_index]
|
498
|
+
|
499
|
+
# Only allow entering calls that have children
|
500
|
+
return unless has_children?(actual_index)
|
501
|
+
|
502
|
+
# Save current state to stack (including computed state to avoid recomputation)
|
503
|
+
@tree_stack.push({
|
504
|
+
tree: @tree,
|
505
|
+
expanded_nodes: @expanded_nodes.dup,
|
506
|
+
cursor_pos: @cursor_pos,
|
507
|
+
scroll_pos: @scroll_pos,
|
508
|
+
node_children: @node_children.dup,
|
509
|
+
lines: @lines.dup,
|
510
|
+
visible_lines: @visible_lines.dup
|
511
|
+
})
|
512
|
+
|
513
|
+
# Create new filtered tree starting from the selected call
|
514
|
+
new_tree = extract_subtree(actual_index)
|
515
|
+
return if new_tree.empty?
|
516
|
+
|
517
|
+
# Update tree and reset state
|
518
|
+
@tree = new_tree
|
519
|
+
@expanded_nodes = Set.new
|
520
|
+
@node_children = {}
|
521
|
+
@cursor_pos = 0
|
522
|
+
@scroll_pos = 0
|
523
|
+
|
524
|
+
# Rebuild tree structure and expand all
|
525
|
+
analyze_tree_structure
|
526
|
+
expand_all_initially
|
527
|
+
prepare_lines
|
528
|
+
update_visible_lines
|
529
|
+
end
|
530
|
+
|
531
|
+
def go_back
|
532
|
+
return if @tree_stack.empty?
|
533
|
+
|
534
|
+
# Restore previous state (including computed state to avoid expensive recomputation)
|
535
|
+
previous_state = @tree_stack.pop
|
536
|
+
@tree = previous_state[:tree]
|
537
|
+
@expanded_nodes = previous_state[:expanded_nodes]
|
538
|
+
@cursor_pos = previous_state[:cursor_pos]
|
539
|
+
@scroll_pos = previous_state[:scroll_pos]
|
540
|
+
@node_children = previous_state[:node_children] || {}
|
541
|
+
@lines = previous_state[:lines] || []
|
542
|
+
@visible_lines = previous_state[:visible_lines] || []
|
543
|
+
|
544
|
+
# Only rebuild if cached state is missing (for backward compatibility)
|
545
|
+
if @node_children.empty? || @lines.empty?
|
546
|
+
analyze_tree_structure
|
547
|
+
prepare_lines
|
548
|
+
update_visible_lines
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
def extract_subtree(root_index)
|
553
|
+
return [] unless has_children?(root_index)
|
554
|
+
|
555
|
+
root_node = @tree[root_index]
|
556
|
+
children_indices = @node_children[root_index]
|
557
|
+
|
558
|
+
# Create new tree with adjusted depths
|
559
|
+
new_tree = []
|
560
|
+
root_depth = root_node.depth
|
561
|
+
|
562
|
+
# Add the root call
|
563
|
+
new_tree << TreeNode.new(
|
564
|
+
root_node.event,
|
565
|
+
root_node.method_name,
|
566
|
+
root_node.parameters,
|
567
|
+
root_node.return_value,
|
568
|
+
0, # New root starts at depth 0
|
569
|
+
root_node.defined_class,
|
570
|
+
root_node.path,
|
571
|
+
root_node.lineno,
|
572
|
+
root_node.start_time,
|
573
|
+
root_node.end_time
|
574
|
+
)
|
575
|
+
|
576
|
+
# Add all children with adjusted depths
|
577
|
+
children_indices.each do |child_index|
|
578
|
+
child_node = @tree[child_index]
|
579
|
+
new_depth = child_node.depth - root_depth
|
580
|
+
|
581
|
+
new_tree << TreeNode.new(
|
582
|
+
child_node.event,
|
583
|
+
child_node.method_name,
|
584
|
+
child_node.parameters,
|
585
|
+
child_node.return_value,
|
586
|
+
new_depth,
|
587
|
+
child_node.defined_class,
|
588
|
+
child_node.path,
|
589
|
+
child_node.lineno,
|
590
|
+
child_node.start_time,
|
591
|
+
child_node.end_time
|
592
|
+
)
|
593
|
+
end
|
594
|
+
|
595
|
+
# Find and add the return event for the root call
|
596
|
+
return_index = find_matching_return(root_index)
|
597
|
+
if return_index
|
598
|
+
return_node = @tree[return_index]
|
599
|
+
new_tree << TreeNode.new(
|
600
|
+
return_node.event,
|
601
|
+
return_node.method_name,
|
602
|
+
return_node.parameters,
|
603
|
+
return_node.return_value,
|
604
|
+
0, # Return at same depth as root call
|
605
|
+
return_node.defined_class,
|
606
|
+
return_node.path,
|
607
|
+
return_node.lineno,
|
608
|
+
return_node.start_time,
|
609
|
+
return_node.end_time
|
610
|
+
)
|
611
|
+
end
|
612
|
+
|
613
|
+
new_tree
|
614
|
+
end
|
615
|
+
|
616
|
+
def find_matching_return(call_index)
|
617
|
+
root_node = @tree[call_index]
|
618
|
+
return nil if root_node.event != :call
|
619
|
+
|
620
|
+
# Look for the return event at the same depth with the same method name
|
621
|
+
# It should come after all the children
|
622
|
+
children_indices = @node_children[call_index]
|
623
|
+
search_start = children_indices.any? ? children_indices.max + 1 : call_index + 1
|
624
|
+
|
625
|
+
(search_start...@tree.length).each do |i|
|
626
|
+
node = @tree[i]
|
627
|
+
if node.event == :return &&
|
628
|
+
node.method_name == root_node.method_name &&
|
629
|
+
node.depth == root_node.depth # Return events are at the same depth as their call
|
630
|
+
return i
|
631
|
+
end
|
632
|
+
# Stop if we encounter a node at a shallower depth (we've gone too far)
|
633
|
+
break if node.depth < root_node.depth
|
634
|
+
end
|
635
|
+
|
636
|
+
nil
|
637
|
+
end
|
638
|
+
|
639
|
+
def expand_all
|
640
|
+
@node_children.each_key do |index|
|
641
|
+
@expanded_nodes.add(index) if has_children?(index)
|
642
|
+
end
|
643
|
+
update_visible_lines
|
644
|
+
|
645
|
+
# Adjust cursor if it's beyond the new visible range
|
646
|
+
if @cursor_pos >= @visible_lines.size
|
647
|
+
@cursor_pos = [@visible_lines.size - 1, 0].max
|
648
|
+
end
|
649
|
+
end
|
650
|
+
|
651
|
+
def collapse_all
|
652
|
+
@expanded_nodes.clear
|
653
|
+
update_visible_lines
|
654
|
+
|
655
|
+
# Adjust cursor if it's beyond the new visible range
|
656
|
+
if @cursor_pos >= @visible_lines.size
|
657
|
+
@cursor_pos = [@visible_lines.size - 1, 0].max
|
658
|
+
end
|
659
|
+
end
|
660
|
+
|
661
|
+
def move_up
|
662
|
+
new_pos = @cursor_pos - 1
|
663
|
+
|
664
|
+
# Skip return events in visible lines
|
665
|
+
while new_pos >= 0
|
666
|
+
actual_index = @visible_lines[new_pos]
|
667
|
+
break if @tree[actual_index].event != :return
|
668
|
+
new_pos -= 1
|
669
|
+
end
|
670
|
+
|
671
|
+
@cursor_pos = new_pos if new_pos >= 0
|
672
|
+
end
|
673
|
+
|
674
|
+
def move_down
|
675
|
+
new_pos = @cursor_pos + 1
|
676
|
+
|
677
|
+
# Skip return events in visible lines
|
678
|
+
while new_pos < @visible_lines.size
|
679
|
+
actual_index = @visible_lines[new_pos]
|
680
|
+
break if @tree[actual_index].event != :return
|
681
|
+
new_pos += 1
|
682
|
+
end
|
683
|
+
|
684
|
+
@cursor_pos = new_pos if new_pos < @visible_lines.size
|
685
|
+
end
|
686
|
+
end
|
687
|
+
end
|