diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs new file mode 100644 index 0000000000..75cfcac444 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Helpers/PathHelper.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.CompilerServices; +using Windows.Win32; +using Windows.Win32.Storage.FileSystem; + +namespace Microsoft.CmdPal.Core.Common.Helpers; + +public static class PathHelper +{ + public static bool Exists(string path, out bool isDirectory) + { + isDirectory = false; + if (string.IsNullOrEmpty(path)) + { + return false; + } + + string? fullPath; + try + { + fullPath = Path.GetFullPath(path); + } + catch (Exception ex) when (ex is ArgumentException or IOException or UnauthorizedAccessException) + { + return false; + } + + var result = ExistsCore(fullPath, out isDirectory); + if (result && IsDirectorySeparator(fullPath[^1])) + { + // Some sys-calls remove all trailing slashes and may give false positives for existing files. + // We want to make sure that if the path ends in a trailing slash, it's truly a directory. + return isDirectory; + } + + return result; + } + + /// + /// Normalize potential local/UNC file path text input: trim whitespace and surrounding quotes. + /// Windows file paths cannot contain quotes, but user input can include them. + /// + public static string Unquote(string? text) + { + return string.IsNullOrWhiteSpace(text) ? (text ?? string.Empty) : text.Trim().Trim('"'); + } + + /// + /// Quick heuristic to determine if the string looks like a Windows file path (UNC or drive-letter based). + /// + public static bool LooksLikeFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + // UNC path + if (path.StartsWith(@"\\", StringComparison.Ordinal)) + { + // Win32 File Namespaces \\?\ + if (path.StartsWith(@"\\?\", StringComparison.Ordinal)) + { + return IsSlow(path[4..]); + } + + // Basic UNC path validation: \\server\share or \\server\share\path + var parts = path[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries); + + return parts.Length >= 2; // At minimum: server and share + } + + // Drive letter path (e.g., C:\ or C:) + return path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':'; + } + + /// + /// Validates path syntax without performing any I/O by using Path.GetFullPath. + /// + public static bool HasValidPathSyntax(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + _ = Path.GetFullPath(path); + return true; + } + catch + { + return false; + } + } + + /// + /// Checks if a string represents a valid Windows file path (local or network) + /// using fast syntax validation only. Reuses LooksLikeFilePath and HasValidPathSyntax. + /// + public static bool IsValidFilePath(string? path) + { + return LooksLikeFilePath(path) && HasValidPathSyntax(path); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + + private static bool ExistsCore(string fullPath, out bool isDirectory) + { + var attributes = PInvoke.GetFileAttributes(fullPath); + var result = attributes != PInvoke.INVALID_FILE_ATTRIBUTES; + isDirectory = result && (attributes & (uint)FILE_FLAGS_AND_ATTRIBUTES.FILE_ATTRIBUTE_DIRECTORY) != 0; + return result; + } + + public static bool IsSlow(string path) + { + if (string.IsNullOrEmpty(path)) + { + return false; + } + + try + { + var root = Path.GetPathRoot(path); + if (!string.IsNullOrEmpty(root)) + { + if (root.Length > 2 && char.IsLetter(root[0]) && root[1] == ':') + { + return new DriveInfo(root).DriveType is not (DriveType.Fixed or DriveType.Ram); + } + else if (root.StartsWith(@"\\", StringComparison.Ordinal)) + { + return !root.StartsWith(@"\\?\", StringComparison.Ordinal) || IsSlow(root[4..]); + } + } + + return false; + } + catch + { + return false; + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt index 61e89b68c4..03318381a6 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/NativeMethods.txt @@ -12,4 +12,8 @@ MonitorFromWindow SHOW_WINDOW_CMD ShellExecuteEx -SEE_MASK_INVOKEIDLIST \ No newline at end of file +SEE_MASK_INVOKEIDLIST + +GetFileAttributes +FILE_FLAGS_AND_ATTRIBUTES +INVALID_FILE_ATTRIBUTES \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs index 427fcd170e..81fec6e363 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/DetailsLinkViewModel.cs @@ -2,8 +2,10 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Mvvm.Input; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; @@ -11,6 +13,13 @@ public partial class DetailsLinkViewModel( IDetailsElement _detailsElement, WeakReference context) : DetailsElementViewModel(_detailsElement, context) { + private static readonly string[] _initProperties = [ + nameof(Text), + nameof(Link), + nameof(IsLink), + nameof(IsText), + nameof(NavigateCommand)]; + private readonly ExtensionObject _dataModel = new(_detailsElement.Data as IDetailsLink); @@ -22,6 +31,8 @@ public partial class DetailsLinkViewModel( public bool IsText => !IsLink; + public RelayCommand? NavigateCommand { get; private set; } + public override void InitializeProperties() { base.InitializeProperties(); @@ -38,9 +49,18 @@ public partial class DetailsLinkViewModel( Text = Link.ToString(); } - UpdateProperty(nameof(Text)); - UpdateProperty(nameof(Link)); - UpdateProperty(nameof(IsLink)); - UpdateProperty(nameof(IsText)); + if (Link is not null) + { + // Custom command to open a link in the default browser or app, + // depending on the link type. + // Binding Link to a Hyperlink(Button).NavigateUri works only for + // certain URI schemes (e.g., http, https) and cannot open file: + // scheme URIs or local files. + NavigateCommand = new RelayCommand( + () => ShellHelpers.OpenInShell(Link.ToString()), + () => Link is not null); + } + + UpdateProperty(_initProperties); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml index 597072241a..fe1a29dd97 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml @@ -108,6 +108,7 @@ Visibility="{x:Bind IsText, Mode=OneWay}" /> diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs new file mode 100644 index 0000000000..9b73ade32b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/IClipboardMetadataProvider.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Abstraction for providers that can extract metadata and offer actions for a clipboard context. +/// +internal interface IClipboardMetadataProvider +{ + /// + /// Gets the section title to show in the UI for this provider's metadata. + /// + string SectionTitle { get; } + + /// + /// Returns true if this provider can produce metadata for the given item. + /// + bool CanHandle(ClipboardItem item); + + /// + /// Returns metadata elements for the UI. Caller decides section grouping. + /// + IEnumerable GetDetails(ClipboardItem item); + + /// + /// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication. + /// + IEnumerable GetActions(ClipboardItem item); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs new file mode 100644 index 0000000000..429f6341f3 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadata.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record ImageMetadata( + uint Width, + uint Height, + double DpiX, + double DpiY, + ulong? StorageSize); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs new file mode 100644 index 0000000000..e69a7d3d9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataAnalyzer.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Windows.Graphics.Imaging; +using Windows.Storage.Streams; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal static class ImageMetadataAnalyzer +{ + /// + /// Reads image metadata from a RandomAccessStreamReference without decoding pixels. + /// Returns oriented dimensions (EXIF rotation applied). + /// + public static async Task GetAsync(RandomAccessStreamReference reference) + { + ArgumentNullException.ThrowIfNull(reference); + + using IRandomAccessStream ras = await reference.OpenReadAsync().AsTask().ConfigureAwait(false); + var sizeBytes = TryGetSize(ras); + + // BitmapDecoder does not decode pixel data unless you ask it to, + // so this is fast and memory-friendly. + var decoder = await BitmapDecoder.CreateAsync(ras).AsTask().ConfigureAwait(false); + + // OrientedPixelWidth/Height account for EXIF orientation + var width = decoder.OrientedPixelWidth; + var height = decoder.OrientedPixelHeight; + + return new ImageMetadata( + Width: width, + Height: height, + DpiX: decoder.DpiX, + DpiY: decoder.DpiY, + StorageSize: sizeBytes); + } + + private static ulong? TryGetSize(IRandomAccessStream s) + { + try + { + // On file-backed streams this is accurate. + // On some URI/virtual streams this may be unsupported or 0. + var size = s.Size; + return size == 0 ? (ulong?)0 : size; + } + catch + { + return null; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs new file mode 100644 index 0000000000..09a3f33f2e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ImageMetadataProvider.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using ManagedCommon; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class ImageMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Image metadata"; + + public bool CanHandle(ClipboardItem item) => item.IsImage; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || item.ImageData is null) + { + return result; + } + + try + { + var metadata = ImageMetadataAnalyzer.GetAsync(item.ImageData).GetAwaiter().GetResult(); + + result.Add(new DetailsElement + { + Key = "Dimensions", + Data = new DetailsLink($"{metadata.Width} x {metadata.Height}"), + }); + result.Add(new DetailsElement + { + Key = "DPI", + Data = new DetailsLink($"{metadata.DpiX:0.###} x {metadata.DpiY:0.###}"), + }); + + if (metadata.StorageSize != null) + { + result.Add(new DetailsElement + { + Key = "Storage size", + Data = new DetailsLink(SizeFormatter.FormatSize(metadata.StorageSize.Value)), + }); + } + } + catch (Exception ex) + { + Logger.LogDebug("Failed to retrieve image metadata:" + ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs new file mode 100644 index 0000000000..1274d1ace9 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/LineEndingType.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal enum LineEndingType +{ + None, + Windows, // \r\n (CRLF) + Unix, // \n (LF) + Mac, // \r (CR) + Mixed, +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs new file mode 100644 index 0000000000..1827fa8744 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/ProviderAction.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Represents an action exposed by a metadata provider. +/// +/// Unique identifier for de-duplication (case-insensitive). +/// The actual context menu item to be shown. +internal readonly record struct ProviderAction(string Id, CommandContextItem Action); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs new file mode 100644 index 0000000000..a08ab32bc2 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/SizeFormatter.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Utility for formatting byte sizes to a human-readable string. +/// +internal static class SizeFormatter +{ + private const long KB = 1024; + private const long MB = 1024 * KB; + private const long GB = 1024 * MB; + + public static string FormatSize(long bytes) + { + return bytes switch + { + >= GB => string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", (double)bytes / GB), + >= MB => string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", (double)bytes / MB), + >= KB => string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", (double)bytes / KB), + _ => string.Format(CultureInfo.CurrentCulture, "{0} B", bytes), + }; + } + + public static string FormatSize(ulong bytes) + { + // Use double for division to avoid overflow; thresholds mirror long version + if (bytes >= (ulong)GB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} GB", bytes / (double)GB); + } + + if (bytes >= (ulong)MB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} MB", bytes / (double)MB); + } + + if (bytes >= (ulong)KB) + { + return string.Format(CultureInfo.CurrentCulture, "{0:F2} KB", bytes / (double)KB); + } + + return string.Format(CultureInfo.CurrentCulture, "{0} B", bytes); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs new file mode 100644 index 0000000000..a51444a3af --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextFileSystemMetadataProvider.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using ManagedCommon; +using Microsoft.CmdPal.Core.Common.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects when text content is a valid existing file or directory path and exposes basic metadata. +/// +internal sealed class TextFileSystemMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "File"; + + public bool CanHandle(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + var text = PathHelper.Unquote(item.Content); + return PathHelper.IsValidFilePath(text); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(Path.GetFileName(path)) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(path), path) }); + return result; + } + + try + { + if (!isDirectory) + { + var fi = new FileInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(fi.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(fi.FullName), fi.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink(fi.Extension) }); + result.Add(new DetailsElement { Key = "Size", Data = new DetailsLink(SizeFormatter.FormatSize(fi.Length)) }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(fi.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(fi.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + else + { + var di = new DirectoryInfo(path); + result.Add(new DetailsElement { Key = "Name", Data = new DetailsLink(di.Name) }); + result.Add(new DetailsElement { Key = "Location", Data = new DetailsLink(UrlHelper.NormalizeUrl(di.FullName), di.FullName) }); + result.Add(new DetailsElement { Key = "Type", Data = new DetailsLink("Folder") }); + result.Add(new DetailsElement { Key = "Modified", Data = new DetailsLink(di.LastWriteTime.ToString(CultureInfo.CurrentCulture)) }); + result.Add(new DetailsElement { Key = "Created", Data = new DetailsLink(di.CreationTime.ToString(CultureInfo.CurrentCulture)) }); + } + } + catch (Exception ex) + { + Logger.LogError("Failed to retrieve file system metadata.", ex); + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + ArgumentNullException.ThrowIfNull(item); + + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + yield break; + } + + var path = PathHelper.Unquote(item.Content); + + if (PathHelper.IsSlow(path) || !PathHelper.Exists(path, out var isDirectory)) + { + // One anything + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + yield break; + } + + if (!isDirectory) + { + // Open file + var open = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + + // Show in folder (select) + var show = new CommandContextItem(new ShowFileInFolderCommand(path)) { RequestedShortcut = WellKnownKeyChords.OpenFileLocation }; + yield return new ProviderAction(WellKnownActionIds.OpenLocation, show); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + + // Open in console at file location + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromFile(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + } + else + { + // Open folder + var openFolder = new CommandContextItem(new OpenFileCommand(path)) { RequestedShortcut = KeyChords.OpenUrl }; + yield return new ProviderAction(WellKnownActionIds.Open, openFolder); + + // Open in console + var openConsole = new CommandContextItem(OpenInConsoleCommand.FromDirectory(path)) { RequestedShortcut = WellKnownKeyChords.OpenInConsole }; + yield return new ProviderAction(WellKnownActionIds.OpenConsole, openConsole); + + // Copy path + var copy = new CommandContextItem(new CopyPathCommand(path)) { RequestedShortcut = WellKnownKeyChords.CopyFilePath }; + yield return new ProviderAction(WellKnownActionIds.CopyPath, copy); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs new file mode 100644 index 0000000000..726a15c37e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadata.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed record TextMetadata +{ + public int CharacterCount { get; init; } + + public int WordCount { get; init; } + + public int SentenceCount { get; init; } + + public int LineCount { get; init; } + + public int ParagraphCount { get; init; } + + public LineEndingType LineEnding { get; init; } + + public override string ToString() + { + return $"Characters: {CharacterCount}, Words: {WordCount}, Sentences: {SentenceCount}, Lines: {LineCount}, Paragraphs: {ParagraphCount}, Line Ending: {LineEnding}"; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs new file mode 100644 index 0000000000..83992f6428 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataAnalyzer.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal partial class TextMetadataAnalyzer +{ + public TextMetadata Analyze(string input) + { + ArgumentNullException.ThrowIfNull(input); + + return new TextMetadata + { + CharacterCount = input.Length, + WordCount = CountWords(input), + SentenceCount = CountSentences(input), + LineCount = CountLines(input), + ParagraphCount = CountParagraphs(input), + LineEnding = DetectLineEnding(input), + }; + } + + private LineEndingType DetectLineEnding(string text) + { + var crlfCount = Regex.Matches(text, "\r\n").Count; + var lfCount = Regex.Matches(text, "(? 0 ? 1 : 0) + (lfCount > 0 ? 1 : 0) + (crCount > 0 ? 1 : 0); + + if (endingTypes > 1) + { + return LineEndingType.Mixed; + } + + if (crlfCount > 0) + { + return LineEndingType.Windows; + } + + if (lfCount > 0) + { + return LineEndingType.Unix; + } + + if (crCount > 0) + { + return LineEndingType.Mac; + } + + return LineEndingType.None; + } + + private int CountLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return text.Count(c => c == '\n') + 1; + } + + private int CountParagraphs(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var paragraphs = ParagraphsRegex() + .Split(text) + .Count(static p => !string.IsNullOrWhiteSpace(p)); + + return paragraphs > 0 ? paragraphs : 1; + } + + private int CountWords(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + return Regex.Matches(text, @"\b\w+\b").Count; + } + + private int CountSentences(string text) + { + if (string.IsNullOrEmpty(text)) + { + return 0; + } + + var matches = SentencesRegex().Matches(text); + return matches.Count > 0 ? matches.Count : (text.Trim().Length > 0 ? 1 : 0); + } + + [GeneratedRegex(@"(\r?\n){2,}")] + private static partial Regex ParagraphsRegex(); + + [GeneratedRegex(@"[.!?]+(?=\s|$)")] + private static partial Regex SentencesRegex(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs new file mode 100644 index 0000000000..86e2a32270 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/TextMetadataProvider.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Globalization; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +internal sealed class TextMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Text statistics"; + + public bool CanHandle(ClipboardItem item) => item.IsText; + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!CanHandle(item) || string.IsNullOrEmpty(item.Content)) + { + return result; + } + + var r = new TextMetadataAnalyzer().Analyze(item.Content); + + result.Add(new DetailsElement + { + Key = "Characters", + Data = new DetailsLink(r.CharacterCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Words", + Data = new DetailsLink(r.WordCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Sentences", + Data = new DetailsLink(r.SentenceCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Lines", + Data = new DetailsLink(r.LineCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Paragraphs", + Data = new DetailsLink(r.ParagraphCount.ToString(CultureInfo.CurrentCulture)), + }); + result.Add(new DetailsElement + { + Key = "Line Ending", + Data = new DetailsLink(r.LineEnding.ToString()), + }); + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) => []; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs new file mode 100644 index 0000000000..0a2afc3e01 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WebLinkMetadataProvider.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CmdPal.Ext.ClipboardHistory.Models; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Detects web links in text and shows normalized URL and key parts. +/// +internal sealed class WebLinkMetadataProvider : IClipboardMetadataProvider +{ + public string SectionTitle => "Link"; + + public bool CanHandle(ClipboardItem item) + { + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return false; + } + + if (!UrlHelper.IsValidUrl(item.Content)) + { + return false; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return false; + } + + // Exclude file: scheme; it's handled by TextFileSystemMetadataProvider + return !uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + + public IEnumerable GetDetails(ClipboardItem item) + { + var result = new List(); + if (!item.IsText || string.IsNullOrWhiteSpace(item.Content)) + { + return result; + } + + try + { + var normalized = UrlHelper.NormalizeUrl(item.Content); + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + return result; + } + + // Skip file: at runtime as well (defensive) + if (uri.Scheme.Equals(Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + + result.Add(new DetailsElement { Key = "URL", Data = new DetailsLink(normalized) }); + result.Add(new DetailsElement { Key = "Host", Data = new DetailsLink(uri.Host) }); + + if (!uri.IsDefaultPort) + { + result.Add(new DetailsElement { Key = "Port", Data = new DetailsLink(uri.Port.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/") + { + result.Add(new DetailsElement { Key = "Path", Data = new DetailsLink(uri.AbsolutePath) }); + } + + if (!string.IsNullOrEmpty(uri.Query)) + { + var q = uri.Query; + var count = q.Count(static c => c == '&') + (q.Length > 1 ? 1 : 0); + result.Add(new DetailsElement { Key = "Query params", Data = new DetailsLink(count.ToString(CultureInfo.CurrentCulture)) }); + } + + if (!string.IsNullOrEmpty(uri.Fragment)) + { + result.Add(new DetailsElement { Key = "Fragment", Data = new DetailsLink(uri.Fragment) }); + } + } + catch + { + // ignore malformed inputs + } + + return result; + } + + public IEnumerable GetActions(ClipboardItem item) + { + if (!CanHandle(item)) + { + yield break; + } + + var normalized = UrlHelper.NormalizeUrl(item.Content!); + + var open = new CommandContextItem(new OpenUrlCommand(normalized)) + { + RequestedShortcut = KeyChords.OpenUrl, + }; + yield return new ProviderAction(WellKnownActionIds.Open, open); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs new file mode 100644 index 0000000000..7fa2a74aea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/Analyzers/WellKnownActionIds.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; + +/// +/// Well-known action id constants used to de-duplicate provider actions. +/// +internal static class WellKnownActionIds +{ + public const string Open = "open"; + public const string OpenLocation = "openLocation"; + public const string CopyPath = "copyPath"; + public const string OpenConsole = "openConsole"; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs index 60e7851761..fe160e4c1b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/UrlHelper.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System; -using System.IO; +using Microsoft.CmdPal.Core.Common.Helpers; namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; @@ -31,7 +31,7 @@ internal static class UrlHelper } // Check if it's a valid file path (local or network) - if (IsValidFilePath(url)) + if (PathHelper.IsValidFilePath(url)) { return true; } @@ -78,7 +78,7 @@ internal static class UrlHelper url = url.Trim(); // If it's a valid file path, convert to file:// URI - if (IsValidFilePath(url) && !url.StartsWith("file://", StringComparison.OrdinalIgnoreCase)) + if (!url.StartsWith("file://", StringComparison.OrdinalIgnoreCase) && PathHelper.IsValidFilePath(url)) { try { @@ -105,40 +105,4 @@ internal static class UrlHelper return url; } - - /// - /// Checks if a string represents a valid file path (local or network) - /// - /// The string to check - /// True if the string is a valid file path, false otherwise - private static bool IsValidFilePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return false; - } - - try - { - // Check for UNC paths (network paths starting with \\) - if (path.StartsWith(@"\\", StringComparison.Ordinal)) - { - // Basic UNC path validation: \\server\share or \\server\share\path - var parts = path.Substring(2).Split('\\', StringSplitOptions.RemoveEmptyEntries); - return parts.Length >= 2; // At minimum: server and share - } - - // Check for drive letters (C:\ or C:) - if (path.Length >= 2 && char.IsLetter(path[0]) && path[1] == ':') - { - return true; - } - - return false; - } - catch - { - return false; - } - } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj index e3d17fb500..b0c0617c34 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj @@ -10,6 +10,7 @@ enable + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs index 9b5aae6f7d..865d8f6b91 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Pages/ClipboardListItem.cs @@ -9,6 +9,7 @@ using System.Linq; using Microsoft.CmdPal.Common.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Commands; using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers; +using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers.Analyzers; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -16,13 +17,20 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models; internal sealed partial class ClipboardListItem : ListItem { + private static readonly IClipboardMetadataProvider[] MetadataProviders = + [ + new ImageMetadataProvider(), + new TextFileSystemMetadataProvider(), + new WebLinkMetadataProvider(), + new TextMetadataProvider(), + ]; + private readonly SettingsManager _settingsManager; private readonly ClipboardItem _item; private readonly CommandContextItem _deleteContextMenuItem; private readonly CommandContextItem? _pasteCommand; private readonly CommandContextItem? _copyCommand; - private readonly CommandContextItem? _openUrlCommand; private readonly Lazy
_lazyDetails; public override IDetails? Details @@ -73,26 +81,11 @@ internal sealed partial class ClipboardListItem : ListItem _pasteCommand = new CommandContextItem(new PasteCommand(_item, ClipboardFormat.Text, _settingsManager)); _copyCommand = new CommandContextItem(new CopyCommand(_item, ClipboardFormat.Text)); - - // Check if the text content is a valid URL and add OpenUrl command - if (UrlHelper.IsValidUrl(_item.Content ?? string.Empty)) - { - var normalizedUrl = UrlHelper.NormalizeUrl(_item.Content ?? string.Empty); - _openUrlCommand = new CommandContextItem(new OpenUrlCommand(normalizedUrl)) - { - RequestedShortcut = KeyChords.OpenUrl, - }; - } - else - { - _openUrlCommand = null; - } } else { _pasteCommand = null; _copyCommand = null; - _openUrlCommand = null; } RefreshCommands(); @@ -163,27 +156,74 @@ internal sealed partial class ClipboardListItem : ListItem commands.Add(firstCommand); } - if (_openUrlCommand != null) + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var temp = new List(); + foreach (var provider in MetadataProviders) { - commands.Add(_openUrlCommand); + if (!provider.CanHandle(_item)) + { + continue; + } + + foreach (var action in provider.GetActions(_item)) + { + if (string.IsNullOrEmpty(action.Id) || !seen.Add(action.Id)) + { + continue; + } + + temp.Add(action.Action); + } + } + + if (temp.Count > 0) + { + if (commands.Count > 0) + { + commands.Add(new Separator()); + } + + commands.AddRange(temp); } commands.Add(new Separator()); commands.Add(_deleteContextMenuItem); - return commands.ToArray(); + return [.. commands]; } private Details CreateDetails() { - IDetailsElement[] metadata = - [ - new DetailsElement + List metadata = []; + + foreach (var provider in MetadataProviders) + { + if (provider.CanHandle(_item)) { - Key = "Copied on", - Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + var details = provider.GetDetails(_item); + if (details.Any()) + { + metadata.Add(new DetailsElement + { + Key = provider.SectionTitle, + Data = new DetailsSeparator(), + }); + + metadata.AddRange(details); + } } - ]; + } + + metadata.Add(new DetailsElement + { + Key = "General", + Data = new DetailsSeparator(), + }); + metadata.Add(new DetailsElement + { + Key = "Copied", + Data = new DetailsLink(_item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)), + }); if (_item.IsImage) { @@ -193,7 +233,7 @@ internal sealed partial class ClipboardListItem : ListItem { Title = _item.GetDataType(), HeroImage = heroImage, - Metadata = metadata, + Metadata = [.. metadata], }; } @@ -203,7 +243,7 @@ internal sealed partial class ClipboardListItem : ListItem { Title = _item.GetDataType(), Body = $"```text\n{_item.Content}\n```", - Metadata = metadata, + Metadata = [.. metadata], }; }