Compare commits

...

7 Commits

Author SHA1 Message Date
Gordon Lam (SH) (from Dev Box)
836147b3c3 initial fix 2025-10-15 18:52:53 +08:00
Jiří Polášek
bb6f9a8b08 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
2025-10-13 12:54:50 -05:00
Jiří Polášek
05b605ef27 CmdPal: Cleanup content page view model when no longer needed (#42293)
## Summary of the Pull Request

This PR:

- Cleans up ContentPageViewModel when its page unloads to ensure it
unsubscribes from ItemsChanged.

- Clears the command bar before initializing a new page view model,
allowing the new VM to set its own state without being overridden by the
shell afterward.


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

- [x] Closes: #42291 
- [ ] **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
2025-10-13 12:03:38 -05:00
Jiří Polášek
7c8b30246e CmdPal: Window Walker - reevaluate process type when window process is updated (#42317)
## Summary of the Pull Request

This PR moves `ProcessPackagingInspector.Inspect` from the
`WindowProcess` constructor to `UpdateProcessInfo`, ensuring the process
type is correctly re-evaluated when the window’s backing process changes
(as with UWP apps hosted in `ApplicationFrameHost.exe`).

See
4d47659ff9/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowWalker/Components/Window.cs (L295-L350)

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

- [x] Closes: #38353 
- [ ] **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
2025-10-13 11:52:24 -05:00
leileizhang
4d47659ff9 Fix PowerRename crash caused by missing PRI file (#42300)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Not sure why In WinAppSDK 1.8, the default WinUI targets no longer
automatically generate PRI files for unpackaged apps.
By importing the MSIX SDK build tools, the project gains standalone PRI
generation capability.

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

- [ ] Closes: #xxx
- [ ] **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
2025-10-11 12:45:42 -07:00
Jiří Polášek
97e62b3253 CmdPal: Update special fallbacks separately from the other fallbacks (#42289)
## Summary of the Pull Request

This PR introduces a hotfix that updates special fallback items
separately from the rest. This allows the loop handling special fallback
items to finish faster, ensuring they are not delayed by other fallback
items. As a result, calculator and run fallback items will be more
readily available to users.

This partially solves #42286 for special fallback items.

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

- [ ] Related to: #42286
- [ ] **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
2025-10-10 17:58:42 +02:00
Mike Griese
cd5b76c988 CmdPal: make the context menu search look more like a cmdpal (#42081)
Replaces our styling with the same styleing we use for the search bar

But we can't _just_ do that, because the stupid "text cursors don't show
up on top of transparent backgrounds" thing.

So I just added the smoke backdrop to the search box. Seemed reasonable.

Screenshots below.

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2025-10-10 06:26:04 -05:00
30 changed files with 1088 additions and 120 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

@@ -265,6 +265,9 @@ public partial class ShellViewModel : ObservableObject,
throw new NotSupportedException();
}
// Clear command bar, ViewModel initialization can already set new commands if it wants to
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
// Kick off async loading of our ViewModel
LoadPageViewModelAsync(pageViewModel, navigationToken)
.ContinueWith(
@@ -275,9 +278,6 @@ public partial class ShellViewModel : ObservableObject,
{
newCts.Dispose();
}
// When we're done loading the page, then update the command bar to match
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
},
navigationToken,
TaskContinuationOptions.None,

View File

@@ -244,7 +244,36 @@ public partial class MainListPage : DynamicListPage,
var commands = _tlcManager.TopLevelCommands;
lock (commands)
{
UpdateFallbacks(SearchText, commands.ToImmutableArray(), token);
if (token.IsCancellationRequested)
{
return;
}
// prefilter fallbacks
var specialFallbacks = new List<TopLevelViewModel>(_specialFallbacks.Length);
var commonFallbacks = new List<TopLevelViewModel>();
foreach (var s in commands)
{
if (!s.IsFallback)
{
continue;
}
if (_specialFallbacks.Contains(s.CommandProviderId))
{
specialFallbacks.Add(s);
}
else
{
commonFallbacks.Add(s);
}
}
// start update of fallbacks; update special fallbacks separately,
// so they can finish faster
UpdateFallbacks(SearchText, specialFallbacks, token);
UpdateFallbacks(SearchText, commonFallbacks, token);
if (token.IsCancellationRequested)
{
@@ -316,15 +345,12 @@ public partial class MainListPage : DynamicListPage,
// with a list of all our commands & apps.
if (!newFilteredItems.Any() && !newApps.Any())
{
// We're going to start over with our fallbacks
newFallbacks = Enumerable.Empty<IListItem>();
newFilteredItems = commands.Where(s => !s.IsFallback);
// Fallbacks are always included in the list, even if they
// don't match the search text. But we don't want to
// consider them when filtering the list.
newFallbacks = commands.Where(s => s.IsFallback && !_specialFallbacks.Contains(s.CommandProviderId));
newFallbacks = commonFallbacks;
if (token.IsCancellationRequested)
{

View File

@@ -14,7 +14,6 @@
xmlns:toolkit="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
PreviewKeyDown="UserControl_PreviewKeyDown"
mc:Ignorable="d">
@@ -22,7 +21,7 @@
<ResourceDictionary>
<cmdpalUI:KeyChordToStringConverter x:Key="KeyChordToStringConverter" />
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Thickness x:Key="DefaultContextMenuItemPadding">12,8,12,8</Thickness>
<cmdpalUI:ContextItemTemplateSelector
x:Key="ContextItemTemplateSelector"
Critical="{StaticResource CriticalContextMenuViewModelTemplate}"
@@ -31,7 +30,7 @@
<!-- Template for context items in the context item menu -->
<DataTemplate x:Key="DefaultContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -71,7 +70,7 @@
<!-- Template for context items flagged as critical -->
<DataTemplate x:Key="CriticalContextMenuViewModelTemplate" x:DataType="coreViewModels:CommandContextItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid Padding="{StaticResource DefaultContextMenuItemPadding}" AutomationProperties.Name="{x:Bind Title, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
@@ -114,7 +113,7 @@
<DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
<Rectangle
Height="1"
Margin="-16,-12,-12,-12"
Margin="0,2,0,2"
Fill="{ThemeResource MenuFlyoutSeparatorBackground}" />
</DataTemplate>
</ResourceDictionary>
@@ -125,35 +124,39 @@
<RowDefinition />
<RowDefinition />
</Grid.RowDefinitions>
<StackPanel x:Name="CommandsPanel">
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</StackPanel>
<ListView
x:Name="CommandsDropdown"
MinWidth="248"
Margin="0,4,0,2"
IsItemClickEnabled="True"
ItemClick="CommandsDropdown_ItemClick"
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
<Border BorderBrush="{ThemeResource MenuFlyoutSeparatorBackground}" BorderThickness="0,0,0,1" />
<TextBox
x:Name="ContextFilterBox"
x:Uid="ContextFilterBox"
Margin="4"
Margin="0"
Padding="10,7,6,8"
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
BorderThickness="0,0,0,2"
CornerRadius="8, 8, 0, 0"
IsTextScaleFactorEnabled="True"
KeyDown="ContextFilterBox_KeyDown"
PreviewKeyDown="ContextFilterBox_PreviewKeyDown"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="ContextFilterBox_TextChanged" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ContextMenuOrder">
@@ -162,9 +165,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="True" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="1" />
<Setter Target="CommandsDropdown.(Grid.Row)" Value="1" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="0" />
<Setter Target="CommandsDropdown.Margin" Value="0, 0, 0, 4" />
<Setter Target="CommandsDropdown.Margin" Value="0, 3, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="8, 8, 0, 0" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-1" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="FilterOnBottom">
@@ -172,9 +177,11 @@
<ui:IsEqualStateTrigger Value="{x:Bind ViewModel.FilterOnTop, Mode=OneWay}" To="False" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="CommandsPanel.(Grid.Row)" Value="0" />
<Setter Target="CommandsDropdown.(Grid.Row)" Value="0" />
<Setter Target="ContextFilterBox.(Grid.Row)" Value="1" />
<Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 0" />
<Setter Target="CommandsDropdown.Margin" Value="0, 4, 0, 4" />
<Setter Target="ContextFilterBox.CornerRadius" Value="0, 0, 8, 8" />
<Setter Target="ContextFilterBox.Margin" Value="0,0,0,-2" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -78,6 +78,12 @@ public sealed partial class ContentPage : Page,
WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this);
// Clean-up event listeners
if (e.NavigationMode != NavigationMode.New)
{
ViewModel?.SafeCleanup();
CleanupHelper.Cleanup(this);
}
ViewModel = null;
}

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],
};
}

View File

@@ -324,7 +324,7 @@ internal sealed class Window
// Correct the process data if the window belongs to a uwp app hosted by 'ApplicationFrameHost.exe'
// (This only works if the window isn't minimized. For minimized windows the required child window isn't assigned.)
if (string.Equals(_handlesToProcessCache[hWindow].Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase))
if (_handlesToProcessCache[hWindow].IsUwpAppFrameHost)
{
new Task(() =>
{

View File

@@ -23,7 +23,7 @@ internal sealed class WindowProcess
/// <summary>
/// An indicator if the window belongs to an 'Universal Windows Platform (UWP)' process
/// </summary>
private readonly bool _isUwpAppFrameHost;
private bool _isUwpAppFrameHost;
/// <summary>
/// Gets the id of the process
@@ -126,6 +126,14 @@ internal sealed class WindowProcess
get; private set;
}
/// <summary>
/// Gets the type of the process (UWP app, packaged Win32 app, unpackaged Win32 app, ...).
/// </summary>
internal ProcessPackagingInfo ProcessType
{
get; private set;
}
/// <summary>
/// Initializes a new instance of the <see cref="WindowProcess"/> class.
/// </summary>
@@ -134,13 +142,10 @@ internal sealed class WindowProcess
/// <param name="name">New process name.</param>
internal WindowProcess(uint pid, uint tid, string name)
{
ProcessType = ProcessPackagingInfo.Empty;
UpdateProcessInfo(pid, tid, name);
ProcessType = ProcessPackagingInspector.Inspect((int)pid);
_isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase);
}
public ProcessPackagingInfo ProcessType { get; private set; }
/// <summary>
/// Updates the process information of the <see cref="WindowProcess"/> instance.
/// </summary>
@@ -156,6 +161,10 @@ internal sealed class WindowProcess
// Process can be elevated only if process id is not 0 (Dummy value on error)
IsFullAccessDenied = (pid != 0) ? TestProcessAccessUsingAllAccessFlag(pid) : false;
// Update process type
ProcessType = ProcessPackagingInspector.Inspect((int)pid);
_isUwpAppFrameHost = string.Equals(Name, "ApplicationFrameHost.exe", StringComparison.OrdinalIgnoreCase);
}
/// <summary>

View File

@@ -11,4 +11,13 @@ internal sealed record ProcessPackagingInfo(
bool IsAppContainer,
string? PackageFullName,
int? LastError
);
)
{
public static ProcessPackagingInfo Empty { get; } = new(
Pid: 0,
Kind: ProcessPackagingKind.Unknown,
HasPackageIdentity: false,
IsAppContainer: false,
PackageFullName: null,
LastError: null);
}

View File

@@ -11,6 +11,7 @@
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" />
<PropertyGroup Label="Globals">
<CppWinRTOptimized>true</CppWinRTOptimized>
@@ -214,7 +215,8 @@
<ImportGroup Label="ExtensionTargets">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" />
<Import Project="..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets" Condition="Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.Base.1.8.250831001\build\native\Microsoft.WindowsAppSDK.Base.targets')" />
@@ -235,6 +237,8 @@
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.ImplementationLibrary.1.0.231216.1\build\native\Microsoft.Windows.ImplementationLibrary.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.10.0.26100.4188\build\Microsoft.Windows.SDK.BuildTools.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.SDK.BuildTools.MSIX.1.7.20250829.1\build\Microsoft.Windows.SDK.BuildTools.MSIX.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Web.WebView2.1.0.2903.40\build\native\Microsoft.Web.WebView2.targets'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.WindowsAppSDK.1.8.250907003\build\native\Microsoft.WindowsAppSDK.targets'))" />

View File

@@ -65,6 +65,17 @@ public partial class ImageSize : INotifyPropertyChanged, IHasId
get => !(Unit == ResizeUnit.Percent && Fit != ResizeFit.Stretch);
}
/// <summary>
/// Gets the localized header text for the Width field. When in Percent mode (non-stretch),
/// returns "Percent" since the value represents a scale factor for both dimensions.
/// Otherwise returns "Width".
/// </summary>
[JsonIgnore]
public string WidthHeader
{
get => !IsHeightUsed ? ResourceLoader.GetString("ImageResizer_Sizes_Units_Percent") : ResourceLoader.GetString("ImageResizer_Width");
}
[JsonPropertyName("name")]
public string Name
{
@@ -81,6 +92,7 @@ public partial class ImageSize : INotifyPropertyChanged, IHasId
if (SetProperty(ref _fit, value))
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHeightUsed)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WidthHeader)));
}
}
}
@@ -105,9 +117,18 @@ public partial class ImageSize : INotifyPropertyChanged, IHasId
get => _unit;
set
{
var previousUnit = _unit;
if (SetProperty(ref _unit, value))
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHeightUsed)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WidthHeader)));
// When switching to Percent unit, reset width and height to 100%
if (value == ResizeUnit.Percent && previousUnit != ResizeUnit.Percent)
{
Width = 100;
Height = 100;
}
}
}
}

View File

@@ -134,8 +134,8 @@
<StackPanel Orientation="Horizontal" Spacing="8">
<controls:ImageResizerDimensionsNumberBox
x:Uid="ImageResizer_Width"
Width="116"
Header="{x:Bind WidthHeader, Mode=OneWay}"
Minimum="0"
SpinButtonPlacementMode="Compact"
Value="{x:Bind Width, Mode=TwoWay, Converter={StaticResource ImageResizerNumberBoxValueConverter}}" />