mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,8 @@ MonitorFromWindow
|
||||
|
||||
SHOW_WINDOW_CMD
|
||||
ShellExecuteEx
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
SEE_MASK_INVOKEIDLIST
|
||||
|
||||
GetFileAttributes
|
||||
FILE_FLAGS_AND_ATTRIBUTES
|
||||
INVALID_FILE_ATTRIBUTES
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => [];
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) => [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user