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
60 changes: 60 additions & 0 deletions src/Files.App/Actions/Global/GoToFolderAliasAction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0

using Windows.Win32;

namespace Files.App.Actions
{
[GeneratedRichCommand]
internal sealed class GoToFolderAliasAction : IAction
{
private readonly IContentPageContext context = Ioc.Default.GetRequiredService<IContentPageContext>();

public string Label
=> Strings.GoToFolderAlias.GetLocalizedResource();

public string Description
=> Strings.GoToFolderAliasDescription.GetLocalizedResource();

public ActionCategory Category
=> ActionCategory.Navigation;

public HotKey HotKey
=> GetAtHotKey() is { Modifier: not KeyModifiers.CtrlAlt } hotKey ? hotKey : HotKey.None;

public RichGlyph Glyph
=> new(themedIconStyle: "App.ThemedIcons.Shortcut");

public Task ExecuteAsync(object? parameter = null)
{
return context.ShellPage?.ToolbarViewModel.SwitchToPathModeWithInputAsync(FolderAliasHelpers.Prefix)
?? Task.CompletedTask;
}

/// <summary>
/// Gets whether the hot key types "@" on the current keyboard layout.
/// </summary>
public static bool IsAtHotKey(HotKey hotKey)
=> GetAtHotKey() is { IsNone: false } at && at.Key == hotKey.Key && at.Modifier == hotKey.Modifier;

private static HotKey GetAtHotKey()
{
// The low byte is the virtual key and the high byte the modifiers it needs
var scan = PInvoke.VkKeyScan('@');
if (scan is -1)
return HotKey.None;

var modifier = (scan >> 8) switch
{
0 => KeyModifiers.None,
1 => KeyModifiers.Shift,
6 => KeyModifiers.CtrlAlt,
_ => (KeyModifiers?)null,
};

return modifier is { } m
? new((Keys)(scan & 0xFF), m)
: HotKey.None;
}
}
}
5 changes: 5 additions & 0 deletions src/Files.App/Data/Contracts/IFoldersSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,5 +94,10 @@ public interface IFoldersSettingsService : IBaseSettingsService, INotifyProperty
/// Gets or sets a value indicating which format to use when displaying item sizes.
/// </summary>
SizeUnitTypes SizeUnitFormat { get; set; }

/// <summary>
/// Gets or sets the user defined folder aliases that can be typed in the Omnibar as "@name".
/// </summary>
List<FolderAliasItem>? FolderAliases { get; set; }
}
}
21 changes: 21 additions & 0 deletions src/Files.App/Data/Items/FolderAliasItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0

namespace Files.App.Data.Items
{
public sealed class FolderAliasItem
{
[JsonPropertyName("Name")]
public string Name { get; }

[JsonPropertyName("Path")]
public string Path { get; }

[JsonConstructor]
public FolderAliasItem(string name, string path)
{
Name = name;
Path = path;
}
}
}
22 changes: 21 additions & 1 deletion src/Files.App/Data/Models/OmnibarPathModeSuggestionModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@

namespace Files.App.Data.Models
{
internal record OmnibarPathModeSuggestionModel(string Path, string DisplayName) : IOmnibarTextMemberPathProvider
internal record OmnibarPathModeSuggestionModel(string Path, string DisplayName, bool IsPlaceholder = false) : IOmnibarTextMemberPathProvider
{
public virtual string DisplayNameMatch => string.Empty;

public virtual string DisplayNameAfterMatch => DisplayName;

public virtual string? Description => null;

public virtual double NameMinWidth => 0;

public string GetTextMemberPath(string textMemberPath)
{
return textMemberPath switch
Expand All @@ -17,4 +25,16 @@ public string GetTextMemberPath(string textMemberPath)
};
}
}

internal sealed record FolderAliasSuggestionModel(string Name, string Folder, int MatchLength, double NameWidth)
: OmnibarPathModeSuggestionModel($"{FolderAliasHelpers.Prefix}{Name}", Name)
{
public override string DisplayNameMatch => Name[..Math.Min(MatchLength, Name.Length)];

public override string DisplayNameAfterMatch => Name[DisplayNameMatch.Length..];

public override string? Description => Folder;

public override double NameMinWidth => NameWidth;
}
}
65 changes: 65 additions & 0 deletions src/Files.App/Dialogs/FolderAliasEditorDialog.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<!-- Copyright (c) Files Community. SPDX-License-Identifier: MPL-2.0 -->
<ContentDialog
x:Class="Files.App.Dialogs.FolderAliasEditorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Files.App.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
CloseButtonText="{helpers:ResourceString Name=Cancel}"
DefaultButton="Primary"
PrimaryButtonClick="FolderAliasEditorDialog_PrimaryButtonClick"
Style="{StaticResource DefaultContentDialogStyle}"
mc:Ignorable="d">

<StackPanel Width="360" Spacing="12">

<!-- Alias name -->
<StackPanel Spacing="4">
<TextBox
x:Name="NameTextBox"
Header="{helpers:ResourceString Name=Name}"
IsSpellCheckEnabled="False"
PlaceholderText="{helpers:ResourceString Name=EnterName}"
TextChanged="InputTextBox_TextChanged" />

<TextBlock
x:Name="NameErrorTextBlock"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
TextWrapping="Wrap"
Visibility="Collapsed" />
</StackPanel>

<!-- Target folder -->
<StackPanel Spacing="4">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>

<TextBox
x:Name="PathTextBox"
Header="{helpers:ResourceString Name=Folder}"
IsSpellCheckEnabled="False"
PlaceholderText="{helpers:ResourceString Name=EnterFolderPath}"
TextChanged="InputTextBox_TextChanged" />

<Button
Grid.Column="1"
VerticalAlignment="Bottom"
Click="BrowseButton_Click"
Content="{helpers:ResourceString Name=Browse}" />
</Grid>

<TextBlock
x:Name="PathErrorTextBlock"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
TextWrapping="Wrap"
Visibility="Collapsed" />
</StackPanel>

</StackPanel>
</ContentDialog>
92 changes: 92 additions & 0 deletions src/Files.App/Dialogs/FolderAliasEditorDialog.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0

using System.IO;
using Files.App.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;

namespace Files.App.Dialogs
{
public sealed partial class FolderAliasEditorDialog : ContentDialog
{
private ICommonDialogService CommonDialogService { get; } = Ioc.Default.GetRequiredService<ICommonDialogService>();

private readonly FolderAliasesViewModel _viewModel;
private readonly FolderAliasItem? _editingItem;

private string AliasName
=> NameTextBox.Text.Trim();

private string AliasPath
=> PathTextBox.Text.Trim().Trim('"');

public FolderAliasEditorDialog(FolderAliasesViewModel viewModel, FolderAliasItem? itemToEdit = null)
{
_viewModel = viewModel;
_editingItem = itemToEdit;

InitializeComponent();

Title = itemToEdit is null ? Strings.AddAlias.GetLocalizedResource() : Strings.Edit.GetLocalizedResource();
PrimaryButtonText = itemToEdit is null ? Strings.Add.GetLocalizedResource() : Strings.Save.GetLocalizedResource();

NameTextBox.Text = itemToEdit?.Name ?? string.Empty;
PathTextBox.Text = itemToEdit?.Path ?? string.Empty;
Validate();
}

private void InputTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
Validate();
}

private void BrowseButton_Click(object sender, RoutedEventArgs e)
{
if (CommonDialogService.Open_FileOpenDialog(MainWindow.Instance.WindowHandle, true, [], Environment.SpecialFolder.Desktop, out var folderPath))
PathTextBox.Text = folderPath;
}

private void Validate()
{
var name = AliasName;
var error =
name.Length is 0 ? null :
!FolderAliasHelpers.IsValidName(name) ? Strings.FolderAliasNameInvalid.GetLocalizedResource() :
_viewModel.IsNameInUse(name, _editingItem) ? Strings.FolderAliasNameInUse.GetLocalizedResource() :
null;

PathErrorTextBlock.Visibility = Visibility.Collapsed;

NameErrorTextBlock.Text = error ?? string.Empty;
NameErrorTextBlock.Visibility = error is null ? Visibility.Collapsed : Visibility.Visible;
IsPrimaryButtonEnabled = error is null && name.Length > 0 && AliasPath.Length > 0;
}

private async void FolderAliasEditorDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
var deferral = args.GetDeferral();
try
{
var resolvedPath = StorageFileExtensions.GetResolvedPath(AliasPath, false);
if (!Path.IsPathFullyQualified(resolvedPath) || !await Task.Run(() => Directory.Exists(resolvedPath)))
{
PathErrorTextBlock.Text = Strings.FolderAliasPathNotFound.GetLocalizedResource();
PathErrorTextBlock.Visibility = Visibility.Visible;
args.Cancel = true;
return;
}

var newItem = new FolderAliasItem(AliasName, resolvedPath);
if (_editingItem is null)
_viewModel.Add(newItem);
else
_viewModel.Replace(_editingItem, newItem);
}
finally
{
deferral.Complete();
}
}
}
}
109 changes: 109 additions & 0 deletions src/Files.App/Helpers/Navigation/FolderAliasHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Files Community
// SPDX-License-Identifier: MPL-2.0

using System.Diagnostics.CodeAnalysis;
using System.IO;

namespace Files.App.Helpers
{
public static class FolderAliasHelpers
{
public const string Prefix = "@";

private static readonly char[] Separators = ['\\', '/'];

private static IReadOnlyList<FolderAliasItem>? _aliases;

private static bool _isSubscribed;

/// <summary>
/// Gets the configured aliases, cached until the setting changes.
/// </summary>
public static IReadOnlyList<FolderAliasItem> Aliases
{
get
{
if (_aliases is { } aliases)
return aliases;

var userSettingsService = Ioc.Default.GetRequiredService<IUserSettingsService>();
if (!_isSubscribed)
{
userSettingsService.OnSettingChangedEvent += UserSettingsService_OnSettingChangedEvent;
_isSubscribed = true;
}

return _aliases = userSettingsService.FoldersSettingsService.FolderAliases ?? [];
}
}

public static bool HasPrefix(string input)
=> input.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Gets whether the input is a bare alias query such as "@pro", with no sub path.
/// </summary>
public static bool IsAliasQuery(string input)
=> HasPrefix(input) && input.IndexOfAny(Separators, Prefix.Length) < 0;

/// <summary>
/// Gets the alias name typed after the prefix, ignoring surrounding spaces.
/// </summary>
public static string GetQuery(string input)
=> input[Prefix.Length..].Trim();

/// <summary>
/// Gets the alias name in "@name" or "@name\sub\path".
/// </summary>
public static string GetName(string input)
{
var separatorIndex = input.IndexOfAny(Separators, Prefix.Length);
return (separatorIndex < 0 ? input[Prefix.Length..] : input[Prefix.Length..separatorIndex]).Trim();
}

public static bool IsValidName(string name)
=> !string.IsNullOrWhiteSpace(name) && name.IndexOfAny(Separators) < 0;

public static IEnumerable<FolderAliasItem> GetMatches(IEnumerable<FolderAliasItem> aliases, string input)
{
var query = GetQuery(input);

return aliases
.Where(x => x.Name.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Resolves "@name" or "@name\sub\path" to the aliased folder, keeping any sub path and trailing separator.
/// </summary>
public static bool TryResolve(string input, IReadOnlyList<FolderAliasItem> aliases, [NotNullWhen(true)] out string? resolvedPath)
{
resolvedPath = null;

if (!HasPrefix(input))
return false;

var name = GetName(input);
var alias = aliases.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (alias is null)
return false;

var separatorIndex = input.IndexOfAny(Separators, Prefix.Length);
var subPath = separatorIndex < 0 ? string.Empty : input[(separatorIndex + 1)..].TrimStart(Separators);
if (!string.IsNullOrEmpty(subPath))
resolvedPath = Path.Join(alias.Path, subPath);
else if (separatorIndex >= 0 && !Path.EndsInDirectorySeparator(alias.Path))
resolvedPath = alias.Path + Path.DirectorySeparatorChar;
else
resolvedPath = alias.Path;

return true;
}

private static void UserSettingsService_OnSettingChangedEvent(object? sender, SettingChangedEventArgs e)
{
if (e.SettingName is nameof(IFoldersSettingsService.FolderAliases))
_aliases = null;
}
}
}
Loading