Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,61 @@ public ServerMetadata? Metadata
public event EventHandler? RobotAdviceRequested;
public event EventHandler? CopyReproRequested;

/// <summary>
/// Navigates to a specific plan node by ID: selects it, zooms to show it,
/// and scrolls to center it in the viewport.
/// </summary>
public void NavigateToNode(int nodeId)
{
// Find the Border for this node
Border? targetBorder = null;
PlanNode? targetNode = null;
foreach (var (border, node) in _nodeBorderMap)
{
if (node.NodeId == nodeId)
{
targetBorder = border;
targetNode = node;
break;
}
}

if (targetBorder == null || targetNode == null)
return;

// Activate the parent window so the plan viewer becomes visible
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
parentWindow.Activate();

// Select the node (highlights it and shows properties)
SelectNode(targetBorder, targetNode);

// Ensure zoom level makes the node comfortably visible
var viewWidth = PlanScrollViewer.Bounds.Width;
var viewHeight = PlanScrollViewer.Bounds.Height;
if (viewWidth <= 0 || viewHeight <= 0)
return;

// If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport
var nodeW = PlanLayoutEngine.NodeWidth;
var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode);
var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4));
if (_zoomLevel < minVisibleZoom)
SetZoom(Math.Min(minVisibleZoom, 1.0));

// Scroll to center the node in the viewport
var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2;
var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2;
centerX = Math.Max(0, centerX);
centerY = Math.Max(0, centerY);

Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
PlanScrollViewer.Offset = new Vector(centerX, centerY);
});
}

public void LoadPlan(string planXml, string label, string? queryText = null)
{
_label = label;
Expand Down Expand Up @@ -2870,7 +2925,34 @@ private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEv
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
e.Handled = true;
SetZoom(_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep));

var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom,
_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep)));

if (Math.Abs(newZoom - _zoomLevel) < 0.001)
return;

// Mouse position relative to the ScrollViewer viewport
var mouseInView = e.GetPosition(PlanScrollViewer);

// Content point under the mouse at the current zoom level
var contentX = (PlanScrollViewer.Offset.X + mouseInView.X) / _zoomLevel;
var contentY = (PlanScrollViewer.Offset.Y + mouseInView.Y) / _zoomLevel;

// Apply the new zoom
_zoomLevel = newZoom;
_zoomTransform.ScaleX = _zoomLevel;
_zoomTransform.ScaleY = _zoomLevel;
ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";

// Adjust offset so the same content point stays under the mouse
var newOffsetX = Math.Max(0, contentX * _zoomLevel - mouseInView.X);
var newOffsetY = Math.Max(0, contentY * _zoomLevel - mouseInView.Y);

Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
});
}
}

Expand Down
44 changes: 36 additions & 8 deletions src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -659,12 +659,17 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
}

private AnalysisResult? GetCurrentAnalysis()
{
return GetCurrentAnalysisWithViewer().Analysis;
}

private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer()
{
// Find the currently selected plan tab's PlanViewerControl
if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
&& viewer.CurrentPlan != null)
{
return ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata);
return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer);
}

// Fallback: find the most recent plan tab
Expand All @@ -673,20 +678,20 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v
&& v.CurrentPlan != null)
{
return ResultMapper.Map(v.CurrentPlan, "query editor");
return (ResultMapper.Map(v.CurrentPlan, "query editor"), v);
}
}

return null;
return (null, null);
}

private void HumanAdvice_Click(object? sender, RoutedEventArgs e)
{
var analysis = GetCurrentAnalysis();
var (analysis, viewer) = GetCurrentAnalysisWithViewer();
if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }

var text = TextFormatter.Format(analysis);
ShowAdviceWindow("Advice for Humans", text, analysis);
ShowAdviceWindow("Advice for Humans", text, analysis, viewer);
}

private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
Expand All @@ -698,9 +703,12 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
ShowAdviceWindow("Advice for Robots", json);
}

private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
var styledContent = AdviceContentBuilder.Build(content, analysis);
Action<int>? onNodeClick = sourceViewer != null
? nodeId => sourceViewer.NavigateToNode(nodeId)
: null;
var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);

var scrollViewer = new ScrollViewer
{
Expand Down Expand Up @@ -741,10 +749,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
buttonPanel.Children.Add(copyBtn);
buttonPanel.Children.Add(closeBtn);

var scaleTransform = new ScaleTransform(1, 1);
var layoutTransform = new LayoutTransformControl
{
LayoutTransform = scaleTransform,
Child = scrollViewer
};

var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
DockPanel.SetDock(buttonPanel, Dock.Bottom);
panel.Children.Add(buttonPanel);
panel.Children.Add(scrollViewer);
panel.Children.Add(layoutTransform);

var window = new Window
{
Expand All @@ -759,6 +774,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
Content = panel
};

double adviceZoom = 1.0;
window.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, (_, args) =>
{
if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
{
args.Handled = true;
adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
scaleTransform.ScaleX = adviceZoom;
scaleTransform.ScaleY = adviceZoom;
}
}, Avalonia.Interactivity.RoutingStrategies.Tunnel);

copyBtn.Click += async (_, _) =>
{
var clipboard = window.Clipboard;
Expand Down
31 changes: 27 additions & 4 deletions src/PlanViewer.App/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
{
if (viewer.CurrentPlan == null) return;
var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis);
ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer);
};

Action showRobotAdvice = () =>
Expand Down Expand Up @@ -594,9 +594,12 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
return panel;
}

private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
var styledContent = AdviceContentBuilder.Build(content, analysis);
Action<int>? onNodeClick = sourceViewer != null
? nodeId => sourceViewer.NavigateToNode(nodeId)
: null;
var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);

var scrollViewer = new ScrollViewer
{
Expand Down Expand Up @@ -637,10 +640,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
buttonPanel.Children.Add(copyBtn);
buttonPanel.Children.Add(closeBtn);

var scaleTransform = new ScaleTransform(1, 1);
var layoutTransform = new LayoutTransformControl
{
LayoutTransform = scaleTransform,
Child = scrollViewer
};

var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
DockPanel.SetDock(buttonPanel, Dock.Bottom);
panel.Children.Add(buttonPanel);
panel.Children.Add(scrollViewer);
panel.Children.Add(layoutTransform);

var window = new Window
{
Expand All @@ -655,6 +665,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
Content = panel
};

double adviceZoom = 1.0;
window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) =>
{
if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
{
args.Handled = true;
adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
scaleTransform.ScaleX = adviceZoom;
scaleTransform.ScaleY = adviceZoom;
}
}, RoutingStrategies.Tunnel);

copyBtn.Click += async (_, _) =>
{
var clipboard = window.Clipboard;
Expand Down
Loading
Loading