feat(ui): Implement dual-pane AST Inspector with line-based highlights
This commit is contained in:
+52
-4
@@ -243,6 +243,7 @@ class App:
|
|||||||
self._show_ast_inspector = False
|
self._show_ast_inspector = False
|
||||||
self._cached_ast_nodes = []
|
self._cached_ast_nodes = []
|
||||||
self._cached_ast_file_path = ''
|
self._cached_ast_file_path = ''
|
||||||
|
self._cached_ast_file_lines = []
|
||||||
self.ui_editing_slices_file = None
|
self.ui_editing_slices_file = None
|
||||||
self.context_files = []
|
self.context_files = []
|
||||||
"""UI-level wrapper for approving a pending tool execution ask."""
|
"""UI-level wrapper for approving a pending tool execution ask."""
|
||||||
@@ -1699,12 +1700,12 @@ class App:
|
|||||||
|
|
||||||
self._cached_ast_nodes = []
|
self._cached_ast_nodes = []
|
||||||
import re
|
import re
|
||||||
pattern = re.compile(r'^(\s*)\[(.*?)\] (.*?) \(Lines \d+-\d+\)')
|
pattern = re.compile(r'^(\s*)\[(.*?)\] (.*?) \(Lines (\d+)-(\d+)\)')
|
||||||
stack = [] # (indent, name)
|
stack = [] # (indent, name)
|
||||||
for line in outline.splitlines():
|
for line in outline.splitlines():
|
||||||
m = pattern.match(line)
|
m = pattern.match(line)
|
||||||
if m:
|
if m:
|
||||||
indent_str, kind, name = m.groups()
|
indent_str, kind, name, start_ln, end_ln = m.groups()
|
||||||
indent = len(indent_str)
|
indent = len(indent_str)
|
||||||
while stack and stack[-1][0] >= indent:
|
while stack and stack[-1][0] >= indent:
|
||||||
stack.pop()
|
stack.pop()
|
||||||
@@ -1714,14 +1715,25 @@ class App:
|
|||||||
'indent': indent,
|
'indent': indent,
|
||||||
'kind': kind,
|
'kind': kind,
|
||||||
'name': name,
|
'name': name,
|
||||||
'full_path': full_path
|
'full_path': full_path,
|
||||||
|
'start_line': int(start_ln),
|
||||||
|
'end_line': int(end_ln)
|
||||||
})
|
})
|
||||||
|
try:
|
||||||
|
content = mcp_client.read_file(f_path)
|
||||||
|
self._cached_ast_file_lines = content.splitlines()
|
||||||
|
except Exception:
|
||||||
|
self._cached_ast_file_lines = ["Error loading file content."]
|
||||||
self._cached_ast_file_path = f_path
|
self._cached_ast_file_path = f_path
|
||||||
|
|
||||||
imgui.text(f"Inspecting AST: {f_path}")
|
imgui.text(f"Inspecting AST: {f_path}")
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
|
|
||||||
imgui.begin_child("ast_tree_scroll", imgui.ImVec2(800, 600), True)
|
if imgui.begin_table('ast_dual_pane', 2, imgui.TableFlags_.resizable | imgui.TableFlags_.borders_inner_v):
|
||||||
|
imgui.table_next_column()
|
||||||
|
|
||||||
|
# --- LEFT COLUMN (Tree) ---
|
||||||
|
imgui.begin_child("ast_tree_scroll", imgui.ImVec2(0, 600), True)
|
||||||
if not self._cached_ast_nodes:
|
if not self._cached_ast_nodes:
|
||||||
imgui.text("No AST nodes found or error fetching outline.")
|
imgui.text("No AST nodes found or error fetching outline.")
|
||||||
else:
|
else:
|
||||||
@@ -1750,6 +1762,42 @@ class App:
|
|||||||
imgui.pop_id()
|
imgui.pop_id()
|
||||||
imgui.end_child()
|
imgui.end_child()
|
||||||
|
|
||||||
|
imgui.table_next_column()
|
||||||
|
|
||||||
|
# --- RIGHT COLUMN (Content) ---
|
||||||
|
imgui.begin_child("ast_content_scroll", imgui.ImVec2(0, 600), True)
|
||||||
|
if not hasattr(self, '_cached_ast_file_lines') or not self._cached_ast_file_lines:
|
||||||
|
imgui.text("No file content loaded.")
|
||||||
|
else:
|
||||||
|
draw_list = imgui.get_window_draw_list()
|
||||||
|
for i, line_text in enumerate(self._cached_ast_file_lines):
|
||||||
|
line_num = i + 1
|
||||||
|
|
||||||
|
# Prioritize the most specific node (deepest indent) that covers the line
|
||||||
|
deepest_node = None
|
||||||
|
for node in self._cached_ast_nodes:
|
||||||
|
if node['start_line'] <= line_num <= node['end_line']:
|
||||||
|
if deepest_node is None or node['indent'] > deepest_node['indent']:
|
||||||
|
deepest_node = node
|
||||||
|
|
||||||
|
mode = 'hide'
|
||||||
|
if deepest_node:
|
||||||
|
mode = f_item.ast_mask.get(deepest_node['full_path'], 'hide')
|
||||||
|
|
||||||
|
pos = imgui.get_cursor_screen_pos()
|
||||||
|
line_height = imgui.get_text_line_height()
|
||||||
|
|
||||||
|
if mode == 'def':
|
||||||
|
# Green, alpha 0.2
|
||||||
|
draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + imgui.get_content_region_avail().x, pos.y + line_height), imgui.get_color_u32(vec4(0, 255, 0, 0.2)))
|
||||||
|
elif mode == 'sig':
|
||||||
|
# Blue, alpha 0.2
|
||||||
|
draw_list.add_rect_filled(pos, imgui.ImVec2(pos.x + imgui.get_content_region_avail().x, pos.y + line_height), imgui.get_color_u32(vec4(0, 0, 255, 0.2)))
|
||||||
|
|
||||||
|
imgui.text(f"{line_num:4} | {line_text}")
|
||||||
|
imgui.end_child()
|
||||||
|
imgui.end_table()
|
||||||
|
|
||||||
imgui.separator()
|
imgui.separator()
|
||||||
if imgui.button("Close", imgui.ImVec2(120, 0)):
|
if imgui.button("Close", imgui.ImVec2(120, 0)):
|
||||||
self.ui_inspecting_ast_file = None
|
self.ui_inspecting_ast_file = None
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import unittest.mock
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from src.gui_2 import App
|
||||||
|
from src import models
|
||||||
|
|
||||||
|
def test_ast_inspector_line_range_parsing():
|
||||||
|
# 1. Setup mock App instance
|
||||||
|
app = MagicMock(spec=App)
|
||||||
|
app._show_ast_inspector = True
|
||||||
|
app.ui_inspecting_ast_file = models.FileItem(path="test.py")
|
||||||
|
app._cached_ast_file_path = ""
|
||||||
|
app._cached_ast_nodes = []
|
||||||
|
|
||||||
|
# 2. Define mock outline string with line ranges
|
||||||
|
# Note: outline_tool uses 2 spaces for indent
|
||||||
|
mock_outline = "[Func] foo (Lines 10-20)\n [Class] Bar (Lines 30-50)"
|
||||||
|
|
||||||
|
# 3. Patch imgui and mcp_client
|
||||||
|
with patch("src.gui_2.imgui") as mock_imgui, \
|
||||||
|
patch("src.gui_2.mcp_client.py_get_code_outline", return_value=mock_outline):
|
||||||
|
|
||||||
|
# begin_popup_modal needs to return (expanded, opened)
|
||||||
|
mock_imgui.begin_popup_modal.return_value = (True, True)
|
||||||
|
# begin_child returns True usually
|
||||||
|
mock_imgui.begin_child.return_value = True
|
||||||
|
# radio_button returns (changed, active)
|
||||||
|
mock_imgui.radio_button.return_value = (False, False)
|
||||||
|
|
||||||
|
# 4. Call the method
|
||||||
|
App._render_ast_inspector_modal(app)
|
||||||
|
|
||||||
|
# 5. Assertions
|
||||||
|
assert len(app._cached_ast_nodes) == 2
|
||||||
|
|
||||||
|
node1 = app._cached_ast_nodes[0]
|
||||||
|
assert node1['name'] == "foo"
|
||||||
|
assert node1['kind'] == "Func"
|
||||||
|
assert node1['start_line'] == 10
|
||||||
|
assert node1['end_line'] == 20
|
||||||
|
|
||||||
|
node2 = app._cached_ast_nodes[1]
|
||||||
|
assert node2['name'] == "Bar"
|
||||||
|
assert node2['kind'] == "Class"
|
||||||
|
assert node2['start_line'] == 30
|
||||||
|
assert node2['end_line'] == 50
|
||||||
Reference in New Issue
Block a user