Peek a file using command line and named pipe. (#26422) (#38754)

This is a PoC. It adds the ability to peek a file using a named pipe and
a command line.

Usage/testing before this gets merged and released:

1. Build release configuration of Peek.UI and Peek.CLI.
2. Terminate PowerToys.Peek.UI.exe if running.
3. Back up and replace PowerToys.Peek.UI[.dll;.exe;.pdb;.pri]. Use
[Everything](https://www.voidtools.com/downloads/) to find the source
and destination folders.
4. Call `PowerToys.Peek.CLI.exe <path>` or send the path to peek to the
`PeekPipe` named pipe.

If this solution is OK, documentation and installer need to be updated
and a follow-up issue needs to be filed to support navigation.

---------

Co-authored-by: Clint Rutkas <clint@rutkas.com>
Co-authored-by: Leilei Zhang <leilzh@microsoft.com>
Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Antonín Procházka
2025-11-05 09:38:07 +01:00
committed by GitHub
parent 20b5ca79a3
commit 31a0deee35
6 changed files with 213 additions and 42 deletions

View File

@@ -62,6 +62,12 @@ namespace Peek.UI
[ObservableProperty] [ObservableProperty]
private IFileSystemItem? _currentItem; private IFileSystemItem? _currentItem;
/// <summary>
/// Work around missing navigation when peeking from CLI.
/// TODO: Implement navigation when peeking from CLI.
/// </summary>
private bool _isFromCli;
partial void OnCurrentItemChanged(IFileSystemItem? value) partial void OnCurrentItemChanged(IFileSystemItem? value)
{ {
WindowTitle = value != null WindowTitle = value != null
@@ -129,7 +135,24 @@ namespace Peek.UI
NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs); NavigationThrottleTimer.Interval = TimeSpan.FromMilliseconds(NavigationThrottleDelayMs);
} }
public void Initialize(HWND foregroundWindowHandle) public void Initialize(SelectedItem selectedItem)
{
switch (selectedItem)
{
case SelectedItemByPath selectedItemByPath:
InitializeFromCli(selectedItemByPath.Path);
break;
case SelectedItemByWindowHandle selectedItemByWindowHandle:
InitializeFromExplorer(selectedItemByWindowHandle.WindowHandle);
break;
default:
throw new NotImplementedException($"Invalid type of selected item: '{selectedItem.GetType().FullName}'");
}
}
private void InitializeFromExplorer(HWND foregroundWindowHandle)
{ {
try try
{ {
@@ -141,10 +164,20 @@ namespace Peek.UI
} }
_currentIndex = DisplayIndex = 0; _currentIndex = DisplayIndex = 0;
_isFromCli = false;
CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null; CurrentItem = (Items != null && Items.Count > 0) ? Items[0] : null;
} }
private void InitializeFromCli(string path)
{
// TODO: implement navigation
_isFromCli = true;
Items = null;
_currentIndex = DisplayIndex = 0;
CurrentItem = new FileItem(path, Path.GetFileName(path));
}
public void Uninitialize() public void Uninitialize()
{ {
_currentIndex = DisplayIndex = 0; _currentIndex = DisplayIndex = 0;
@@ -153,6 +186,7 @@ namespace Peek.UI
Items = null; Items = null;
_navigationDirection = NavigationDirection.Forwards; _navigationDirection = NavigationDirection.Forwards;
IsErrorVisible = false; IsErrorVisible = false;
_isFromCli = false;
} }
public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards); public void AttemptPreviousNavigation() => Navigate(NavigationDirection.Backwards);
@@ -166,6 +200,12 @@ namespace Peek.UI
return; return;
} }
// TODO: implement navigation.
if (_isFromCli)
{
return;
}
if (Items == null || Items.Count == _deletedItemIndexes.Count) if (Items == null || Items.Count == _deletedItemIndexes.Count)
{ {
_currentIndex = DisplayIndex = 0; _currentIndex = DisplayIndex = 0;

View File

@@ -0,0 +1,11 @@
// 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 Peek.UI.Models
{
public abstract class SelectedItem
{
public abstract bool Matches(string? path);
}
}

View File

@@ -0,0 +1,23 @@
// 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;
namespace Peek.UI.Models
{
public class SelectedItemByPath : SelectedItem
{
public string Path { get; }
public SelectedItemByPath(string path)
{
Path = path;
}
public override bool Matches(string? path)
{
return string.Equals(Path, path, StringComparison.OrdinalIgnoreCase);
}
}
}

View File

@@ -0,0 +1,34 @@
// 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 Peek.UI.Extensions;
using Peek.UI.Helpers;
using Windows.Win32.Foundation;
namespace Peek.UI.Models
{
public class SelectedItemByWindowHandle : SelectedItem
{
public HWND WindowHandle { get; }
public SelectedItemByWindowHandle(HWND windowHandle)
{
WindowHandle = windowHandle;
}
public override bool Matches(string? path)
{
var selectedItems = FileExplorerHelper.GetSelectedItems(WindowHandle);
var selectedItemsCount = selectedItems?.GetCount() ?? 0;
if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1)
{
return false;
}
var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path;
var currentItemPath = path;
return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath;
}
}
}

View File

@@ -3,16 +3,19 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.IO;
using System.Threading;
using ManagedCommon; using ManagedCommon;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.PowerToys.Telemetry; using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls.Primitives;
using Peek.Common; using Peek.Common;
using Peek.FilePreviewer; using Peek.FilePreviewer;
using Peek.FilePreviewer.Models; using Peek.FilePreviewer.Models;
using Peek.FilePreviewer.Previewers; using Peek.FilePreviewer.Previewers;
using Peek.UI.Models;
using Peek.UI.Native; using Peek.UI.Native;
using Peek.UI.Telemetry.Events; using Peek.UI.Telemetry.Events;
using Peek.UI.Views; using Peek.UI.Views;
@@ -23,7 +26,7 @@ namespace Peek.UI
/// <summary> /// <summary>
/// Provides application-specific behavior to supplement the default Application class. /// Provides application-specific behavior to supplement the default Application class.
/// </summary> /// </summary>
public partial class App : Application, IApp public partial class App : Application, IApp, IDisposable
{ {
public static int PowerToysPID { get; set; } public static int PowerToysPID { get; set; }
@@ -36,6 +39,10 @@ namespace Peek.UI
private MainWindow? Window { get; set; } private MainWindow? Window { get; set; }
private bool _disposed;
private SelectedItem? _selectedItem;
private bool _launchedFromCli;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="App"/> class. /// Initializes a new instance of the <see cref="App"/> class.
/// Initializes the singleton application object. This is the first line of authored code /// Initializes the singleton application object. This is the first line of authored code
@@ -52,22 +59,22 @@ namespace Peek.UI
InitializeComponent(); InitializeComponent();
Logger.InitializeLogger("\\Peek\\Logs"); Logger.InitializeLogger("\\Peek\\Logs");
Host = Microsoft.Extensions.Hosting.Host. Host = Microsoft.Extensions.Hosting.Host
CreateDefaultBuilder(). .CreateDefaultBuilder()
UseContentRoot(AppContext.BaseDirectory). .UseContentRoot(AppContext.BaseDirectory)
ConfigureServices((context, services) => .ConfigureServices((context, services) =>
{ {
// Core Services // Core Services
services.AddTransient<NeighboringItemsQuery>(); services.AddTransient<NeighboringItemsQuery>();
services.AddSingleton<IUserSettings, UserSettings>(); services.AddSingleton<IUserSettings, UserSettings>();
services.AddSingleton<IPreviewSettings, PreviewSettings>(); services.AddSingleton<IPreviewSettings, PreviewSettings>();
// Views and ViewModels // Views and ViewModels
services.AddTransient<TitleBar>(); services.AddTransient<TitleBar>();
services.AddTransient<FilePreview>(); services.AddTransient<FilePreview>();
services.AddTransient<MainWindowViewModel>(); services.AddTransient<MainWindowViewModel>();
}). })
Build(); .Build();
UnhandledException += App_UnhandledException; UnhandledException += App_UnhandledException;
} }
@@ -99,6 +106,7 @@ namespace Peek.UI
var cmdArgs = Environment.GetCommandLineArgs(); var cmdArgs = Environment.GetCommandLineArgs();
if (cmdArgs?.Length > 1) if (cmdArgs?.Length > 1)
{ {
// Check if the last argument is a PowerToys Runner PID
if (int.TryParse(cmdArgs[^1], out int powerToysRunnerPid)) if (int.TryParse(cmdArgs[^1], out int powerToysRunnerPid))
{ {
RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () => RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () =>
@@ -107,9 +115,25 @@ namespace Peek.UI
Environment.Exit(0); Environment.Exit(0);
}); });
} }
else
{
// Command line argument is a file path - activate Peek with that file
string filePath = cmdArgs[^1];
if (File.Exists(filePath) || Directory.Exists(filePath))
{
_selectedItem = new SelectedItemByPath(filePath);
_launchedFromCli = true;
OnShowPeek();
return;
}
else
{
Logger.LogError($"Command line argument is not a valid file or directory: {filePath}");
}
}
} }
NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnPeekHotkey); NativeEventWaiter.WaitForEventLoop(Constants.ShowPeekEvent(), OnShowPeek);
NativeEventWaiter.WaitForEventLoop(Constants.TerminatePeekEvent(), () => NativeEventWaiter.WaitForEventLoop(Constants.TerminatePeekEvent(), () =>
{ {
ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); ShellPreviewHandlerPreviewer.ReleaseHandlerFactories();
@@ -126,11 +150,16 @@ namespace Peek.UI
/// <summary> /// <summary>
/// Handle Peek hotkey /// Handle Peek hotkey
/// </summary> /// </summary>
private void OnPeekHotkey() private void OnShowPeek()
{ {
// Need to read the foreground HWND before activating Peek to avoid focus stealing // null means explorer, not null means CLI
// Foreground HWND must always be Explorer or Desktop if (_selectedItem == null)
var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow(); {
// Need to read the foreground HWND before activating Peek to avoid focus stealing
// Foreground HWND must always be Explorer or Desktop
var foregroundWindowHandle = Windows.Win32.PInvoke_PeekUI.GetForegroundWindow();
_selectedItem = new SelectedItemByWindowHandle(foregroundWindowHandle);
}
bool firstActivation = false; bool firstActivation = false;
@@ -140,7 +169,38 @@ namespace Peek.UI
Window = new MainWindow(); Window = new MainWindow();
} }
Window.Toggle(firstActivation, foregroundWindowHandle); Window.Toggle(firstActivation, _selectedItem, _launchedFromCli);
_launchedFromCli = false;
_selectedItem = null;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// dispose managed state (managed objects)
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
_disposed = true;
}
}
/* // override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~App()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// } */
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
} }
} }
} }

View File

@@ -18,6 +18,7 @@ using Peek.FilePreviewer.Models;
using Peek.FilePreviewer.Previewers; using Peek.FilePreviewer.Previewers;
using Peek.UI.Extensions; using Peek.UI.Extensions;
using Peek.UI.Helpers; using Peek.UI.Helpers;
using Peek.UI.Models;
using Peek.UI.Telemetry.Events; using Peek.UI.Telemetry.Events;
using Windows.Foundation; using Windows.Foundation;
using WinUIEx; using WinUIEx;
@@ -38,6 +39,7 @@ namespace Peek.UI
/// dialog is open at a time. /// dialog is open at a time.
/// </summary> /// </summary>
private bool _isDeleteInProgress; private bool _isDeleteInProgress;
private bool _exitAfterClose;
public MainWindow() public MainWindow()
{ {
@@ -116,12 +118,17 @@ namespace Peek.UI
/// <summary> /// <summary>
/// Toggling the window visibility and querying files when necessary. /// Toggling the window visibility and querying files when necessary.
/// </summary> /// </summary>
public void Toggle(bool firstActivation, Windows.Win32.Foundation.HWND foregroundWindowHandle) public void Toggle(bool firstActivation, SelectedItem selectedItem, bool exitAfterClose)
{ {
if (exitAfterClose)
{
_exitAfterClose = true;
}
if (firstActivation) if (firstActivation)
{ {
Activate(); Activate();
Initialize(foregroundWindowHandle); Initialize(selectedItem);
return; return;
} }
@@ -132,9 +139,9 @@ namespace Peek.UI
if (AppWindow.IsVisible) if (AppWindow.IsVisible)
{ {
if (IsNewSingleSelectedItem(foregroundWindowHandle)) if (IsNewSingleSelectedItem(selectedItem))
{ {
Initialize(foregroundWindowHandle); Initialize(selectedItem);
Activate(); // Brings existing window into focus in case it was previously minimized Activate(); // Brings existing window into focus in case it was previously minimized
} }
else else
@@ -144,7 +151,7 @@ namespace Peek.UI
} }
else else
{ {
Initialize(foregroundWindowHandle); Initialize(selectedItem);
} }
} }
@@ -182,12 +189,12 @@ namespace Peek.UI
Uninitialize(); Uninitialize();
} }
private void Initialize(Windows.Win32.Foundation.HWND foregroundWindowHandle) private void Initialize(SelectedItem selectedItem)
{ {
var bootTime = new System.Diagnostics.Stopwatch(); var bootTime = new System.Diagnostics.Stopwatch();
bootTime.Start(); bootTime.Start();
ViewModel.Initialize(foregroundWindowHandle); ViewModel.Initialize(selectedItem);
ViewModel.ScalingFactor = this.GetMonitorScale(); ViewModel.ScalingFactor = this.GetMonitorScale();
this.Content.KeyUp += Content_KeyUp; this.Content.KeyUp += Content_KeyUp;
@@ -207,6 +214,11 @@ namespace Peek.UI
this.Content.KeyUp -= Content_KeyUp; this.Content.KeyUp -= Content_KeyUp;
ShellPreviewHandlerPreviewer.ReleaseHandlerFactories(); ShellPreviewHandlerPreviewer.ReleaseHandlerFactories();
if (_exitAfterClose)
{
Environment.Exit(0);
}
} }
/// <summary> /// <summary>
@@ -272,20 +284,11 @@ namespace Peek.UI
Uninitialize(); Uninitialize();
} }
private bool IsNewSingleSelectedItem(Windows.Win32.Foundation.HWND foregroundWindowHandle) private bool IsNewSingleSelectedItem(SelectedItem selectedItem)
{ {
try try
{ {
var selectedItems = FileExplorerHelper.GetSelectedItems(foregroundWindowHandle); return selectedItem.Matches(ViewModel.CurrentItem?.Path);
var selectedItemsCount = selectedItems?.GetCount() ?? 0;
if (selectedItems == null || selectedItemsCount == 0 || selectedItemsCount > 1)
{
return false;
}
var fileExplorerSelectedItemPath = selectedItems.GetItemAt(0).ToIFileSystemItem().Path;
var currentItemPath = ViewModel.CurrentItem?.Path;
return fileExplorerSelectedItemPath != null && currentItemPath != null && fileExplorerSelectedItemPath != currentItemPath;
} }
catch (Exception ex) catch (Exception ex)
{ {