Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/Filee.App/Services/RadialController.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Opens, fills and closes the live donut toolbar.
//
// Drag mode (modifier + drag in Explorer):
// gesture detected → empty ring appears at the cursor (not activated) → DragEnter brings the file list →
// slices pop out → drop on a slice converts / drop in the hole or release elsewhere closes.
// gesture detected → an invisible window waits at the cursor (not activated) → DragEnter brings the file list →
// the donut pops out → drop on a slice converts / drop in the hole or release elsewhere closes. Without files
// (text, a rubber-band selection) nothing ever shows.
// Click mode (keyboard shortcut, context menu, drop zone):
// files are known up front → donut appears activated → click a slice to convert, right-click to edit,
// Esc / click outside closes.
Expand Down Expand Up @@ -45,7 +46,11 @@ public sealed class RadialController(

public bool IsVisible => _visible;

/// <summary>Drag gesture started at the given physical screen point.</summary>
/// <summary>
/// Drag gesture started at the given physical screen point. The window is placed under the cursor but stays
/// invisible until files are dragged into it (FilesEntered); a gesture without files (text, a rubber-band
/// selection, nothing at all) never shows anything and closes when the button is released.
/// </summary>
public void ShowForDrag(int x, int y)
{
ReleaseProgressToToast();
Expand All @@ -54,7 +59,6 @@ public void ShowForDrag(int x, int y)
viewModel.ResetForDrag();
window.DonutControl.AllowContextEdit = false;
PlaceAndShow(window, x, y, activate: false);
window.DonutControl.PlayOpenAnimation();
}

/// <summary>Opens the donut for known files (click mode).</summary>
Expand Down
11 changes: 10 additions & 1 deletion src/Filee.App/ViewModels/Pages/GeneralPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,16 @@ private async Task ChangeModernMenu(bool enabled)
return;
ModernMenuBusy = true;
ModernMenuStatus = _loc["general.modern_menu_busy"];
var result = await _platform.SetModernContextMenuAsync(enabled, exe);
ModernContextMenuResult result;
try
{
result = await _platform.SetModernContextMenuAsync(enabled, exe);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// A command that throws would end the app (async void path): report it on the page instead.
result = new ModernContextMenuResult(false, Error: ex.Message);
}
ModernMenuBusy = false;
RefreshModernMenu();
if (result.Cancelled)
Expand Down
16 changes: 16 additions & 0 deletions src/Filee.App/ViewModels/RadialViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ public sealed partial class RadialViewModel(UserDataStore store, ILocalizer loc,
[ObservableProperty] private double _holeRatio = 0.42;
[ObservableProperty] private double _sliceOpacity = 0.96;

/// <summary>
/// 0 while a drag gesture waits for files, 1 otherwise. The gesture starts before anyone knows what is being
/// dragged (a file, text, or nothing: Ctrl + a rubber-band selection), so the donut only appears when files enter.
/// </summary>
[ObservableProperty] private double _donutOpacity = 1;

/// <summary>
/// Background of the window while the donut is hidden: practically invisible, but not fully transparent, so
/// Windows still delivers the drag to this window and the files can arrive.
/// </summary>
[ObservableProperty] private string _hitBackground = "Transparent";

/// <summary>Files the donut will convert (empty while waiting for a drag to enter).</summary>
public IReadOnlyList<string> Files { get; private set; } = [];

Expand All @@ -41,13 +53,17 @@ public void ResetForDrag()
Items = [];
CenterTitle = loc["donut.center_drag"];
CenterSubtitle = null;
DonutOpacity = 0;
HitBackground = "#01000000";
}

/// <summary>Fills the donut for the given files.</summary>
public void Load(IReadOnlyList<string> files, bool clickMode)
{
IsClickMode = clickMode;
Files = files;
DonutOpacity = 1;
HitBackground = "Transparent";
ApplyAppearance();

var profile = ProfileSelector.Select(store.Profiles, files);
Expand Down
3 changes: 2 additions & 1 deletion src/Filee.App/Views/RadialWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@
ShowActivated="False"
CanResize="False"
SizeToContent="WidthAndHeight">
<Panel x:Name="Root" Background="Transparent" DragDrop.AllowDrop="True">
<Panel x:Name="Root" Background="{Binding HitBackground}" DragDrop.AllowDrop="True">
<c:DonutMenu x:Name="Donut"
Opacity="{Binding DonutOpacity}"
Items="{Binding Items}"
CenterTitle="{Binding CenterTitle}"
CenterSubtitle="{Binding CenterSubtitle}"
Expand Down
15 changes: 14 additions & 1 deletion src/Filee.Platform.Windows/ExplorerMenuRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,20 @@ public static string ShortenDeploymentError(string message)
return string.Join(' ', message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}

private static unsafe string? ExternalLocationOf(string fullName)
/// <summary>The install folder the package points to, or null when Windows can't tell.</summary>
public static unsafe string? ExternalLocationOf(string fullName)
{
try
{
return ExternalLocationOfCore(fullName);
}
catch (Exception ex) when (ex is EntryPointNotFoundException or DllNotFoundException)
{
return null; // older Windows without the API: the folder is then not compared
}
}

private static unsafe string? ExternalLocationOfCore(string fullName)
{
uint length = 0;
if (NativeMethods.GetPackagePathByFullName2(fullName, NativeMethods.PackagePathType_EffectiveExternal, ref length, null)
Expand Down
3 changes: 2 additions & 1 deletion src/Filee.Platform.Windows/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ public unsafe struct PACKAGE_ID
[LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)]
public static unsafe partial int GetPackagesByPackageFamily(string packageFamilyName, ref uint count, char** packageFullNames, ref uint bufferLength, char* buffer);

[LibraryImport("kernel32.dll", StringMarshalling = StringMarshalling.Utf16)]
// Not exported by kernel32.dll (only by kernelbase.dll): import it from its API set, Windows 10 1903 and later.
[LibraryImport("api-ms-win-appmodel-runtime-l1-1-3.dll", StringMarshalling = StringMarshalling.Utf16)]
public static unsafe partial int GetPackagePathByFullName2(string packageFullName, int packagePathType, ref uint pathLength, char* path);

[LibraryImport("kernel32.dll")]
Expand Down
33 changes: 26 additions & 7 deletions src/Filee.Platform.Windows/WindowsPlatformServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,39 @@ public void SetContextMenu(bool enabled, string executablePath, string label)
CreateShortcut(SendToShortcutPath, executablePath, "--convert", label);
}

public ModernContextMenuState GetModernContextMenuState(string executablePath) =>
Path.GetDirectoryName(executablePath) is { Length: > 0 } folder
? ExplorerMenuRegistration.GetState(folder)
: ModernContextMenuState.Unsupported;
public ModernContextMenuState GetModernContextMenuState(string executablePath)
{
try
{
return Path.GetDirectoryName(executablePath) is { Length: > 0 } folder
? ExplorerMenuRegistration.GetState(folder)
: ModernContextMenuState.Unsupported;
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
// Asking Windows about packages must never take the app or the settings page down.
Trace.TraceWarning($"Reading the Explorer menu state failed: {ex}");
return ModernContextMenuState.Unsupported;
}
}

public Task<ModernContextMenuResult> SetModernContextMenuAsync(bool enabled, string executablePath) => Task.Run(async () =>
{
var folder = Path.GetDirectoryName(executablePath);
if (string.IsNullOrEmpty(folder))
return new ModernContextMenuResult(false, Error: "Unknown install folder.");

var result = enabled
? await ExplorerMenuRegistration.AddAsync(folder)
: await ExplorerMenuRegistration.RemoveAsync(allowElevation: true);
ModernContextMenuResult result;
try
{
result = enabled
? await ExplorerMenuRegistration.AddAsync(folder)
: await ExplorerMenuRegistration.RemoveAsync(allowElevation: true);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
result = new ModernContextMenuResult(false, Error: ex.Message);
}

// Keep exactly one entry: the title file exists while the Explorer menu setting is on.
if (ExplorerMenuRegistration.ReadTitle() is { Length: > 0 } title)
Expand Down
29 changes: 29 additions & 0 deletions tests/Filee.App.Tests/ExplorerMenuTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ public void Family_name_matches_what_Windows_computes_from_the_publisher()
Assert.Equal(ExplorerMenuPackage.FamilyName, ExplorerMenuRegistration.ComputeFamilyName());
}

[Fact]
public void Every_native_import_exists_in_the_dll_it_names()
{
// GetPackagePathByFullName2 was imported from kernel32.dll, which doesn't export it: the call only failed
// once a package was registered and crashed the app right after "Add to the main menu" (1.1.0).
var nativeMethods = typeof(ExplorerMenuRegistration).Assembly.GetType("Filee.Platform.Windows.NativeMethods", throwOnError: true)!;
var imports = nativeMethods.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)
.Select(m => (Method: m, Import: m.GetCustomAttributes(typeof(LibraryImportAttribute), false).OfType<LibraryImportAttribute>().FirstOrDefault()))
.Where(m => m.Import is not null)
.ToList();
Assert.NotEmpty(imports);
Assert.All(imports, m =>
{
var entryPoint = m.Import!.EntryPoint ?? m.Method.Name;
Assert.True(NativeLibrary.TryLoad(m.Import.LibraryName, out var library), $"{m.Import.LibraryName} can't be loaded");
Assert.True(NativeLibrary.TryGetExport(library, entryPoint, out _), $"{m.Import.LibraryName} has no {entryPoint}");
});
}

[Fact]
public void Reading_the_registered_package_never_throws()
{
Assert.Null(ExplorerMenuRegistration.ExternalLocationOf("Filee.NotInstalled_1.0.0.0_x64__0000000000000"));
// Whatever is registered on this machine (nothing on CI): a registered sparse package has an external location.
if (ExplorerMenuRegistration.Find() is { } package)
Assert.False(string.IsNullOrEmpty(package.ExternalLocation), package.FullName);
_ = ExplorerMenuRegistration.GetState(BuiltFolder);
}

[Fact]
public void PowerShell_literals_escape_straight_and_typographic_single_quotes()
{
Expand Down
4 changes: 4 additions & 0 deletions tests/Filee.App.Tests/RenderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ public void Donut_toolbar_waits_for_files_in_drag_mode()
Pump();

Assert.Empty(vm.Items);
// Nothing shows until files are dragged in (a Ctrl + drag without files must not open anything).
Assert.Equal(0, vm.DonutOpacity);
Save(window, "donut-drag-waiting.png");
vm.Load([@"C:\photos\a.jpg"], clickMode: false);
Assert.Equal(1, vm.DonutOpacity);
window.Close();
}

Expand Down
Loading