From 50c96ccd95b453a7c898827758ae5ffab98b1eb3 Mon Sep 17 00:00:00 2001 From: Drew Noakes Date: Mon, 4 Aug 2025 22:40:55 +1000 Subject: [PATCH] Add "Show in Explorer" command to nodes When a node's value is a file or directory path that exists on disk, the node's context menu now shows an additional "Show in Explorer" command that opens the operating system's default file browser to display the relevant file system entry. --- .../Controls/BuildControl.xaml.cs | 21 +++ .../FileExplorerHelper.cs | 151 ++++++++++++++++++ .../Controls/BuildControl.xaml.cs | 22 +++ 3 files changed, 194 insertions(+) create mode 100644 src/StructuredLogViewer.Core/FileExplorerHelper.cs diff --git a/src/StructuredLogViewer.Avalonia/Controls/BuildControl.xaml.cs b/src/StructuredLogViewer.Avalonia/Controls/BuildControl.xaml.cs index 80a353917..e601345e2 100644 --- a/src/StructuredLogViewer.Avalonia/Controls/BuildControl.xaml.cs +++ b/src/StructuredLogViewer.Avalonia/Controls/BuildControl.xaml.cs @@ -40,6 +40,7 @@ public partial class BuildControl : UserControl private MenuItem copyNameItem; private MenuItem copyValueItem; private MenuItem viewSourceItem; + private MenuItem showFileInExplorerItem; private MenuItem preprocessItem; private MenuItem hideItem; private ContextMenu sharedTreeContextMenu; @@ -170,6 +171,7 @@ public BuildControl(Build build, string logFilePath) copyNameItem = new MenuItem() { Header = "Copy name" }; copyValueItem = new MenuItem() { Header = "Copy value" }; viewSourceItem = new MenuItem() { Header = "View source" }; + showFileInExplorerItem = new MenuItem() { Header = "Show in Explorer" }; preprocessItem = new MenuItem() { Header = "Preprocess" }; hideItem = new MenuItem() { Header = "Hide" }; copyItem.Click += (s, a) => Copy(); @@ -179,6 +181,7 @@ public BuildControl(Build build, string logFilePath) copyNameItem.Click += (s, a) => CopyName(); copyValueItem.Click += (s, a) => CopyValue(); viewSourceItem.Click += (s, a) => Invoke(treeView.SelectedItem as BaseNode); + showFileInExplorerItem.Click += (s, a) => ShowFileInExplorer(); preprocessItem.Click += (s, a) => Preprocess(treeView.SelectedItem as IPreprocessable); hideItem.Click += (s, a) => Delete(); contextMenu.AddItem(viewSourceItem); @@ -189,6 +192,8 @@ public BuildControl(Build build, string logFilePath) contextMenu.AddItem(sortChildrenByDurationItem); contextMenu.AddItem(copyNameItem); contextMenu.AddItem(copyValueItem); + contextMenu.AddItem(new Separator()); + contextMenu.AddItem(showFileInExplorerItem); contextMenu.AddItem(hideItem); Style GetTreeViewItemStyle() @@ -477,6 +482,7 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) copyNameItem.IsVisible = visibility; copyValueItem.IsVisible = visibility; viewSourceItem.IsVisible = CanView(node); + showFileInExplorerItem.IsVisible = CanShowInExplorer(); var hasChildren = node is TreeNode t && t.HasChildren; copySubtreeItem.IsVisible = hasChildren; sortChildrenByNameItem.IsVisible = hasChildren; @@ -1107,6 +1113,21 @@ public void CopyValue() } } + public void ShowFileInExplorer() + { + string path = FileExplorerHelper.GetFilePathFromNode(treeView.SelectedItem as BaseNode); + + if (path != null) + { + FileExplorerHelper.ShowInExplorer(path); + } + } + + private bool CanShowInExplorer() + { + return FileExplorerHelper.GetFilePathFromNode(treeView.SelectedItem as BaseNode) is not null; + } + private void MoveSelectionOut(BaseNode node) { var parent = node.Parent; diff --git a/src/StructuredLogViewer.Core/FileExplorerHelper.cs b/src/StructuredLogViewer.Core/FileExplorerHelper.cs new file mode 100644 index 000000000..3a84c8205 --- /dev/null +++ b/src/StructuredLogViewer.Core/FileExplorerHelper.cs @@ -0,0 +1,151 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Build.Logging.StructuredLogger; + +#nullable enable + +namespace StructuredLogViewer +{ + /// + /// Helper class for showing files and directories in the system file explorer. + /// + public static class FileExplorerHelper + { + /// + /// Shows the specified file or directory in the system file explorer. + /// + /// The path to show in the file explorer. + public static void ShowInExplorer(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + try + { + if (File.Exists(path)) + { + // Show file in file manager + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start("explorer.exe", $"/select,\"{path}\""); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", $"-R \"{path}\""); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // Try common Linux file managers + var directory = Path.GetDirectoryName(path); + if (Directory.Exists(directory)) + { + Process.Start(new ProcessStartInfo(directory) { UseShellExecute = true }); + } + } + } + else if (Directory.Exists(path)) + { + // Open directory + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Process.Start("explorer.exe", $"\"{path}\""); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", $"\"{path}\""); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); + } + } + } + catch + { + // If that fails, try just opening the directory + try + { + var directory = File.Exists(path) ? Path.GetDirectoryName(path) : path; + if (Directory.Exists(directory)) + { + Process.Start(new ProcessStartInfo(directory) { UseShellExecute = true }); + } + } + catch + { + // Ignore any errors + } + } + } + + /// + /// Gets a valid file path from the specified node if it contains one. + /// + /// The node to extract a file path from. + /// A valid file path if found, otherwise null. + public static string? GetFilePathFromNode(BaseNode? selectedNode) + { + if (selectedNode == null) + { + return null; + } + + // Check for NameValueNode first + if (selectedNode is NameValueNode nameValueNode && IsValidExistingPath(nameValueNode.Value)) + { + return nameValueNode.Value; + } + + // Check for Item node (representing items in ItemGroups) + if (selectedNode is Item item && IsValidExistingPath(item.Text)) + { + return item.Text; + } + + // Check for file path in standard nodes + if (selectedNode is Import import && IsValidExistingPath(import.ImportedProjectFilePath)) + { + return import.ImportedProjectFilePath; + } + + if (selectedNode is IHasSourceFile file && IsValidExistingPath(file.SourceFilePath)) + { + return file.SourceFilePath; + } + + return null; + } + + /// + /// Checks if the specified path is a valid existing file or directory path. + /// + /// The path to validate. + /// True if the path is valid and exists, otherwise false. + public static bool IsValidExistingPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + // Check if it looks like a valid file path + if (path!.IndexOfAny(Path.GetInvalidPathChars()) != -1) + { + return false; + } + + // Check if the file or directory actually exists + return File.Exists(path) || Directory.Exists(path); + } + catch + { + return false; + } + } + } +} diff --git a/src/StructuredLogViewer/Controls/BuildControl.xaml.cs b/src/StructuredLogViewer/Controls/BuildControl.xaml.cs index 1d34e6210..8b35beb50 100644 --- a/src/StructuredLogViewer/Controls/BuildControl.xaml.cs +++ b/src/StructuredLogViewer/Controls/BuildControl.xaml.cs @@ -59,6 +59,7 @@ public partial class BuildControl : UserControl private MenuItem viewFullTextItem; private MenuItem openFileItem; private MenuItem copyFilePathItem; + private MenuItem showFileInExplorerItem; private MenuItem preprocessItem; private MenuItem targetGraphItem; private MenuItem nugetGraphItem; @@ -226,6 +227,7 @@ public BuildControl(Build build, string logFilePath) unfavoriteItem = new MenuItem() { Header = "Remove from Favorites" }; openFileItem = new MenuItem() { Header = "Open File" }; copyFilePathItem = new MenuItem() { Header = "Copy file path" }; + showFileInExplorerItem = new MenuItem() { Header = "Show in Explorer" }; preprocessItem = new MenuItem() { Header = "Preprocess" }; targetGraphItem = new MenuItem { Header = "Target Graph" }; nugetGraphItem = new MenuItem { Header = "NuGet Graph" }; @@ -267,6 +269,7 @@ public BuildControl(Build build, string logFilePath) unfavoriteItem.Click += (s, a) => RemoveFromFavorites(); openFileItem.Click += (s, a) => OpenFile(); copyFilePathItem.Click += (s, a) => CopyFilePath(); + showFileInExplorerItem.Click += (s, a) => ShowFileInExplorer(); preprocessItem.Click += (s, a) => Preprocess(treeView.SelectedItem as IPreprocessable); targetGraphItem.Click += (s, a) => ViewTargetGraph(treeView.SelectedItem as IProjectOrEvaluation); nugetGraphItem.Click += (s, a) => ViewNuGetGraph(treeView.SelectedItem as IProjectOrEvaluation); @@ -311,6 +314,9 @@ public BuildControl(Build build, string logFilePath) contextMenu.AddItem(copyNameItem); contextMenu.AddItem(copyValueItem); + contextMenu.AddItem(new Separator()); + contextMenu.AddItem(showFileInExplorerItem); + contextMenu.AddItem(separator2); contextMenu.AddItem(viewSubtreeTextItem); @@ -967,6 +973,7 @@ private void ContextMenu_Opened(object sender, RoutedEventArgs e) copyFilePathItem.Visibility = node is Import || (node is IHasSourceFile file && !string.IsNullOrEmpty(file.SourceFilePath)) ? Visibility.Visible : Visibility.Collapsed; + showFileInExplorerItem.Visibility = CanShowInExplorer() ? Visibility.Visible : Visibility.Collapsed; var hasChildren = node is TreeNode t && t.HasChildren; var hasChildrenVisibility = hasChildren ? Visibility.Visible : Visibility.Collapsed; copySubtreeItem.Visibility = hasChildrenVisibility; @@ -2054,6 +2061,21 @@ public void CopyFilePath() } } + public void ShowFileInExplorer() + { + string path = FileExplorerHelper.GetFilePathFromNode(treeView.SelectedItem as BaseNode); + + if (path != null) + { + FileExplorerHelper.ShowInExplorer(path); + } + } + + private bool CanShowInExplorer() + { + return FileExplorerHelper.GetFilePathFromNode(treeView.SelectedItem as BaseNode) is not null; + } + public void SearchInSubtree() { if (treeView.SelectedItem is TimedNode treeNode)