CmdPal: Add metadata to items in the clipboard history (#42188)

## Summary of the Pull Request

This PR introduces the `IClipboardMetadataProvider` interface, which
inspects clipboard items and returns metadata plus optional actions.

Also this implementation updates changes how `DetailsLink` link is
handled through shell, to enable `file:` scheme to be handled
(`Hyperlink.NavigateUri` and `HyperlinkButton.NavigateUri` explicitly
blocks `file:` scheme, see
[here](https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.hyperlinkbutton?view=windows-app-sdk-1.8)).

**Implemented providers:**
- `ImageMetadataProvider` — reports image dimensions, DPI, and byte
size.
- `TextFileSystemMetadataProvider` — recognizes text as a file-system
path and, if it exists, provides details about the target.
- `WebLinkMetadataProvider` — recognizes text as a URL and provides
link-related metadata.
- `TextMetadataProvider` — reports text statistics (e.g., character and
word counts).

### Pictures? Pictures!

Image metadata:

<img width="1666" height="1478" alt="image"
src="https://github.com/user-attachments/assets/472a8516-624f-457a-850c-009c66ccadcf"
/>

Text metadata:

<img width="1714" height="1534" alt="image"
src="https://github.com/user-attachments/assets/69503fb1-2dfd-46c4-894a-e6b0fc26e7da"
/>

Text as a web link metadata:

<img width="1712" height="1518" alt="image"
src="https://github.com/user-attachments/assets/bd9c26bd-eab3-4431-bab0-abf8e6fad610"
/>


Text as a file system path:

<img width="1673" height="1452" alt="image"
src="https://github.com/user-attachments/assets/0bff415c-01e2-4abf-a3c5-9abdc9475031"
/>

<img width="1646" height="1005" alt="image"
src="https://github.com/user-attachments/assets/41afc3e7-8baa-4a81-9ce5-c81b1a6df2f6"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #42201
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2025-10-13 19:54:50 +02:00
committed by GitHub
parent 05b605ef27
commit bb6f9a8b08
20 changed files with 957 additions and 71 deletions

View File

@@ -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;
}
/// <summary>
/// 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.
/// </summary>
public static string Unquote(string? text)
{
return string.IsNullOrWhiteSpace(text) ? (text ?? string.Empty) : text.Trim().Trim('"');
}
/// <summary>
/// Quick heuristic to determine if the string looks like a Windows file path (UNC or drive-letter based).
/// </summary>
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] == ':';
}
/// <summary>
/// Validates path syntax without performing any I/O by using Path.GetFullPath.
/// </summary>
public static bool HasValidPathSyntax(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
try
{
_ = Path.GetFullPath(path);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Checks if a string represents a valid Windows file path (local or network)
/// using fast syntax validation only. Reuses LooksLikeFilePath and HasValidPathSyntax.
/// </summary>
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;
}
}
}

View File

@@ -12,4 +12,8 @@ MonitorFromWindow
SHOW_WINDOW_CMD
ShellExecuteEx
SEE_MASK_INVOKEIDLIST
SEE_MASK_INVOKEIDLIST
GetFileAttributes
FILE_FLAGS_AND_ATTRIBUTES
INVALID_FILE_ATTRIBUTES

View File

@@ -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<IPageContext> context) : DetailsElementViewModel(_detailsElement, context)
{
private static readonly string[] _initProperties = [
nameof(Text),
nameof(Link),
nameof(IsLink),
nameof(IsText),
nameof(NavigateCommand)];
private readonly ExtensionObject<IDetailsLink> _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);
}
}

View File

@@ -108,6 +108,7 @@
Visibility="{x:Bind IsText, Mode=OneWay}" />
<HyperlinkButton
Padding="0"
Command="{x:Bind NavigateCommand, Mode=OneWay}"
NavigateUri="{x:Bind Link, Mode=OneWay}"
Visibility="{x:Bind IsLink, Mode=OneWay}">
<TextBlock Text="{x:Bind Text, Mode=OneWay}" TextWrapping="Wrap" />

View File

@@ -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;
/// <summary>
/// Abstraction for providers that can extract metadata and offer actions for a clipboard context.
/// </summary>
internal interface IClipboardMetadataProvider
{
/// <summary>
/// Gets the section title to show in the UI for this provider's metadata.
/// </summary>
string SectionTitle { get; }
/// <summary>
/// Returns true if this provider can produce metadata for the given item.
/// </summary>
bool CanHandle(ClipboardItem item);
/// <summary>
/// Returns metadata elements for the UI. Caller decides section grouping.
/// </summary>
IEnumerable<DetailsElement> GetDetails(ClipboardItem item);
/// <summary>
/// Returns context actions to be appended to MoreCommands. Use unique IDs for de-duplication.
/// </summary>
IEnumerable<ProviderAction> GetActions(ClipboardItem item);
}

View File

@@ -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);

View File

@@ -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
{
/// <summary>
/// Reads image metadata from a RandomAccessStreamReference without decoding pixels.
/// Returns oriented dimensions (EXIF rotation applied).
/// </summary>
public static async Task<ImageMetadata> 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;
}
}
}

View File

@@ -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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> GetActions(ClipboardItem item) => [];
}

View File

@@ -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,
}

View File

@@ -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;
/// <summary>
/// Represents an action exposed by a metadata provider.
/// </summary>
/// <param name="Id">Unique identifier for de-duplication (case-insensitive).</param>
/// <param name="Action">The actual context menu item to be shown.</param>
internal readonly record struct ProviderAction(string Id, CommandContextItem Action);

View File

@@ -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;
/// <summary>
/// Utility for formatting byte sizes to a human-readable string.
/// </summary>
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);
}
}

View File

@@ -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;
/// <summary>
/// Detects when text content is a valid existing file or directory path and exposes basic metadata.
/// </summary>
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<DetailsElement> GetDetails(ClipboardItem item)
{
ArgumentNullException.ThrowIfNull(item);
var result = new List<DetailsElement>();
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<ProviderAction> 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);
}
}
}

View File

@@ -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}";
}
}

View File

@@ -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, "(?<!\r)\n").Count;
var crCount = Regex.Matches(text, "\r(?!\n)").Count;
var endingTypes = (crlfCount > 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();
}

View File

@@ -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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> GetActions(ClipboardItem item) => [];
}

View File

@@ -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;
/// <summary>
/// Detects web links in text and shows normalized URL and key parts.
/// </summary>
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<DetailsElement> GetDetails(ClipboardItem item)
{
var result = new List<DetailsElement>();
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<ProviderAction> 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);
}
}

View File

@@ -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;
/// <summary>
/// Well-known action id constants used to de-duplicate provider actions.
/// </summary>
internal static class WellKnownActionIds
{
public const string Open = "open";
public const string OpenLocation = "openLocation";
public const string CopyPath = "copyPath";
public const string OpenConsole = "openConsole";
}

View File

@@ -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;
}
/// <summary>
/// Checks if a string represents a valid file path (local or network)
/// </summary>
/// <param name="path">The string to check</param>
/// <returns>True if the string is a valid file path, false otherwise</returns>
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;
}
}
}

View File

@@ -10,6 +10,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
</ItemGroup>
<ItemGroup>

View File

@@ -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<Details> _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<string>(StringComparer.OrdinalIgnoreCase);
var temp = new List<IContextItem>();
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<IDetailsElement> 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],
};
}