Skip to content

Commit ba6624b

Browse files
authored
Feat(recent-items): sync recent items with file explorer (#9291)
1 parent c64184f commit ba6624b

File tree

13 files changed

+779
-193
lines changed

13 files changed

+779
-193
lines changed

src/Files.FullTrust/Helpers/ShellFolderHelpers.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ public static ShellFileItem GetShellFileItem(ShellItem folderItem)
7878
var recycleDate = fileTime?.ToDateTime().ToLocalTime() ?? DateTime.Now; // This is LocalTime
7979
fileTime = folderItem.Properties.TryGetProperty<System.Runtime.InteropServices.ComTypes.FILETIME?>(
8080
Ole32.PROPERTYKEY.System.DateModified);
81-
var modifiedDate = fileTime?.ToDateTime().ToLocalTime() ?? DateTime.Now; // This is LocalTime
81+
var modifiedDate = fileTime?.ToDateTime().ToLocalTime() ?? folderItem.FileInfo?.LastWriteTime ?? DateTime.Now; // This is LocalTime
8282
fileTime = folderItem.Properties.TryGetProperty<System.Runtime.InteropServices.ComTypes.FILETIME?>(
8383
Ole32.PROPERTYKEY.System.DateCreated);
84-
var createdDate = fileTime?.ToDateTime().ToLocalTime() ?? DateTime.Now; // This is LocalTime
84+
var createdDate = fileTime?.ToDateTime().ToLocalTime() ?? folderItem.FileInfo?.CreationTime ?? DateTime.Now; // This is LocalTime
8585
var fileSizeBytes = folderItem.Properties.TryGetProperty<ulong?>(Ole32.PROPERTYKEY.System.Size);
8686
string fileSize = fileSizeBytes is not null ? folderItem.Properties.GetPropertyString(Ole32.PROPERTYKEY.System.Size) : null;
8787
var fileType = folderItem.Properties.TryGetProperty<string>(Ole32.PROPERTYKEY.System.ItemTypeText);
@@ -143,4 +143,4 @@ public static ShellItem GetShellItemFromPathOrPidl(string pathOrPidl)
143143
return ShellItem.Open(pathOrPidl);
144144
}
145145
}
146-
}
146+
}

src/Files.FullTrust/MessageHandlers/DriveHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
namespace Files.FullTrust.MessageHandlers
1010
{
11-
[SupportedOSPlatform("Windows10.10240")]
11+
[SupportedOSPlatform("Windows10.0.10240")]
1212
public class DriveHandler : Disposable, IMessageHandler
1313
{
1414
public void Initialize(PipeStream connection) {}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
using Files.FullTrust.Helpers;
2+
using Files.Shared;
3+
using Files.Shared.Enums;
4+
using Files.Shared.Extensions;
5+
using Newtonsoft.Json;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.IO;
9+
using System.IO.Pipes;
10+
using System.Linq;
11+
using System.Runtime.Versioning;
12+
using System.Threading.Tasks;
13+
using Vanara.PInvoke;
14+
using Vanara.Windows.Shell;
15+
using Windows.Foundation.Collections;
16+
17+
namespace Files.FullTrust.MessageHandlers
18+
{
19+
[SupportedOSPlatform("Windows10.0.10240")]
20+
public class RecentItemsHandler : Disposable, IMessageHandler
21+
{
22+
private const string QuickAccessJumpListFileName = "5f7b5f1e01b83767.automaticDestinations-ms";
23+
private const string QuickAccessGuid = "::{679f85cb-0220-4080-b29b-5540cc05aab6}";
24+
private static string RecentItemsPath = Environment.GetFolderPath(Environment.SpecialFolder.Recent);
25+
private static string AutomaticDestinationsPath = Path.Combine(RecentItemsPath, "AutomaticDestinations");
26+
27+
private DateTime quickAccessLastReadTime = DateTime.MinValue;
28+
private FileSystemWatcher quickAccessJumpListWatcher;
29+
private PipeStream connection;
30+
31+
public void Initialize(PipeStream connection)
32+
{
33+
this.connection = connection;
34+
35+
StartQuickAccessJumpListWatcher();
36+
}
37+
38+
/// <summary>
39+
/// Watch the quick access jump list file for any changes.
40+
/// Triggered by operations such as: added, accessed, removed from quick access, calls to SHAddToRecentDocs, etc..
41+
/// </summary>
42+
private void StartQuickAccessJumpListWatcher()
43+
{
44+
if (quickAccessJumpListWatcher is not null)
45+
{
46+
return;
47+
}
48+
49+
quickAccessJumpListWatcher = new FileSystemWatcher
50+
{
51+
Path = AutomaticDestinationsPath,
52+
Filter = QuickAccessJumpListFileName,
53+
NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite,
54+
};
55+
quickAccessJumpListWatcher.Changed += QuickAccessJumpList_Changed;
56+
quickAccessJumpListWatcher.Deleted += QuickAccessJumpList_Changed;
57+
quickAccessJumpListWatcher.EnableRaisingEvents = true;
58+
}
59+
60+
public async Task ParseArgumentsAsync(PipeStream connection, Dictionary<string, object> message, string arguments)
61+
{
62+
switch (arguments)
63+
{
64+
case "ShellRecentItems":
65+
await HandleShellRecentItemsMessage(message);
66+
break;
67+
}
68+
}
69+
70+
private async void QuickAccessJumpList_Changed(object sender, FileSystemEventArgs e)
71+
{
72+
System.Diagnostics.Debug.WriteLine($"{nameof(QuickAccessJumpList_Changed)}: {e.ChangeType}, {e.FullPath}");
73+
74+
// skip if multiple events occurred for singular change
75+
var lastWriteTime = File.GetLastWriteTime(e.FullPath);
76+
if (quickAccessLastReadTime >= lastWriteTime)
77+
{
78+
return;
79+
}
80+
else
81+
{
82+
quickAccessLastReadTime = lastWriteTime;
83+
}
84+
85+
if (connection?.IsConnected ?? false)
86+
{
87+
var response = new ValueSet()
88+
{
89+
{ "RecentItems", e.FullPath },
90+
{ "ChangeType", "QuickAccessJumpListChanged" },
91+
};
92+
93+
// send message to UWP app to refresh recent files
94+
await Win32API.SendMessageAsync(connection, response);
95+
}
96+
}
97+
98+
private async Task HandleShellRecentItemsMessage(Dictionary<string, object> message)
99+
{
100+
var action = (string)message["action"];
101+
var response = new ValueSet();
102+
103+
switch (action)
104+
{
105+
// enumerate `\Windows\Recent` for recent folders
106+
// note: files are enumerated using (Win32MessageHandler: "ShellFolder") in RecentItemsManager
107+
case "EnumerateFolders":
108+
var enumerateFoldersResponse = await Win32API.StartSTATask(() =>
109+
{
110+
try
111+
{
112+
var shellLinkItems = new List<ShellLinkItem>();
113+
var excludeMask = FileAttributes.Hidden;
114+
var linkFilePaths = Directory.EnumerateFiles(RecentItemsPath).Where(f => (new FileInfo(f).Attributes & excludeMask) == 0);
115+
116+
foreach (var linkFilePath in linkFilePaths)
117+
{
118+
using var link = new ShellLink(linkFilePath, LinkResolution.NoUIWithMsgPump, null, TimeSpan.FromMilliseconds(100));
119+
120+
try
121+
{
122+
if (!string.IsNullOrEmpty(link.TargetPath) && link.Target.IsFolder)
123+
{
124+
var shellLinkItem = ShellFolderExtensions.GetShellLinkItem(link);
125+
shellLinkItems.Add(shellLinkItem);
126+
}
127+
}
128+
catch (FileNotFoundException)
129+
{
130+
// occurs when shortcut or shortcut target is deleted and accessed (link.Target)
131+
// consequently, we shouldn't include the item as a recent item
132+
}
133+
}
134+
135+
response.Add("EnumerateFolders", JsonConvert.SerializeObject(shellLinkItems));
136+
}
137+
catch (Exception e)
138+
{
139+
Program.Logger.Warn(e);
140+
}
141+
return response;
142+
});
143+
await Win32API.SendMessageAsync(connection, enumerateFoldersResponse, message.Get("RequestID", (string)null));
144+
break;
145+
146+
case "Add":
147+
var addResponse = await Win32API.StartSTATask(() =>
148+
{
149+
try
150+
{
151+
var path = (string) message["Path"];
152+
Shell32.SHAddToRecentDocs(Shell32.SHARD.SHARD_PATHW, path);
153+
}
154+
catch (Exception e)
155+
{
156+
Program.Logger.Warn(e);
157+
}
158+
return response;
159+
});
160+
await Win32API.SendMessageAsync(connection, addResponse, message.Get("RequestID", (string)null));
161+
break;
162+
163+
case "Clear":
164+
var clearResponse = await Win32API.StartSTATask(() =>
165+
{
166+
try
167+
{
168+
Shell32.SHAddToRecentDocs(Shell32.SHARD.SHARD_PIDL, (string)null);
169+
}
170+
catch (Exception e)
171+
{
172+
Program.Logger.Warn(e);
173+
}
174+
return response;
175+
});
176+
await Win32API.SendMessageAsync(connection, clearResponse, message.Get("RequestID", (string)null));
177+
break;
178+
179+
// invoke 'remove' verb on the file to remove it from Quick Access
180+
// note: for folders, we need to use the verb 'unpinfromhome' or 'removefromhome'
181+
case "UnpinFile":
182+
var unpinFileResponse = await Win32API.StartSTATask(() =>
183+
{
184+
try
185+
{
186+
var path = (string)message["Path"];
187+
var command = $"-command \"((New-Object -ComObject Shell.Application).Namespace('shell:{QuickAccessGuid}\').Items() " +
188+
$"| Where-Object {{ $_.Path -eq '{path}' }}).InvokeVerb('remove')\"";
189+
bool success = Win32API.RunPowershellCommand(command, false);
190+
191+
response.Add("UnpinFile", path);
192+
response.Add("Success", success);
193+
}
194+
catch (Exception e)
195+
{
196+
Program.Logger.Warn(e);
197+
}
198+
return response;
199+
});
200+
await Win32API.SendMessageAsync(connection, unpinFileResponse, message.Get("RequestID", (string)null));
201+
break;
202+
}
203+
}
204+
205+
protected override void Dispose(bool disposing)
206+
{
207+
if (disposing)
208+
{
209+
quickAccessJumpListWatcher?.Dispose();
210+
}
211+
}
212+
}
213+
}

src/Files.FullTrust/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ private static async Task Main()
5454
new QuickLookHandler(),
5555
new Win32MessageHandler(),
5656
new InstallOperationsHandler(),
57-
new DesktopWallpaperHandler()
57+
new DesktopWallpaperHandler(),
58+
new RecentItemsHandler(),
5859
};
5960

6061
// Connect to app service and wait until the connection gets closed

src/Files.Uwp/App.xaml.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ sealed partial class App : Application
5959
public static PaneViewModel PaneViewModel { get; private set; }
6060
public static PreviewPaneViewModel PreviewPaneViewModel { get; private set; }
6161
public static JumpListManager JumpList { get; private set; }
62+
public static RecentItemsManager RecentItemsManager { get; private set; }
6263
public static SidebarPinnedController SidebarPinnedController { get; private set; }
6364
public static TerminalController TerminalController { get; private set; }
6465
public static CloudDrivesManager CloudDrivesManager { get; private set; }
@@ -152,6 +153,7 @@ private static async Task EnsureSettingsAndConfigurationAreBootstrapped()
152153
new AppearanceViewModel().SetCompactStyles(updateTheme: false);
153154

154155
JumpList ??= new JumpListManager();
156+
RecentItemsManager ??= new RecentItemsManager();
155157
MainViewModel ??= new MainViewModel();
156158
PaneViewModel ??= new PaneViewModel();
157159
PreviewPaneViewModel ??= new PreviewPaneViewModel();

src/Files.Uwp/Files.Uwp.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@
196196
<Compile Include="Filesystem\FileTagsHelper.cs" />
197197
<Compile Include="Filesystem\FileTagsManager.cs" />
198198
<Compile Include="Filesystem\FtpManager.cs" />
199+
<Compile Include="Filesystem\RecentItem.cs" />
200+
<Compile Include="Filesystem\RecentItemsManager.cs" />
199201
<Compile Include="Filesystem\Permissions\FilePermissionsManager.cs" />
200202
<Compile Include="Filesystem\Permissions\FileSystemAccessRule.cs" />
201203
<Compile Include="Filesystem\Permissions\FileSystemAccessRuleForUI.cs" />
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using CommunityToolkit.Mvvm.ComponentModel;
2+
using Files.Shared;
3+
using Files.Uwp.Helpers;
4+
using System;
5+
using System.IO;
6+
using System.Threading.Tasks;
7+
using Windows.Storage;
8+
using Windows.Storage.FileProperties;
9+
using Windows.UI.Xaml.Media.Imaging;
10+
11+
namespace Files.Uwp.Filesystem
12+
{
13+
public class RecentItem : ObservableObject, IEquatable<RecentItem>
14+
{
15+
private BitmapImage _fileImg;
16+
public BitmapImage FileImg
17+
{
18+
get => _fileImg;
19+
set => SetProperty(ref _fileImg, value);
20+
}
21+
public string LinkPath { get; set; } // path of shortcut item (this is unique)
22+
public string RecentPath { get; set; } // path to target item
23+
public string Name { get; set; }
24+
public StorageItemTypes Type { get; set; }
25+
public bool FolderImg { get; set; }
26+
public bool EmptyImgVis { get; set; }
27+
public bool FileIconVis { get; set; }
28+
public bool IsFile { get => Type == StorageItemTypes.File; }
29+
public DateTime LastModified { get; set; }
30+
31+
public RecentItem()
32+
{
33+
EmptyImgVis = true; // defer icon load to LoadRecentItemIcon()
34+
}
35+
36+
/// <summary>
37+
/// Create a RecentItem instance from a link path.
38+
/// This is usually needed if a shortcut is deleted -- the metadata is lost (i.e. the target item).
39+
/// </summary>
40+
/// <param name="linkPath">The location that shortcut lives/lived in</param>
41+
public RecentItem(string linkPath) : base()
42+
{
43+
LinkPath = linkPath;
44+
}
45+
46+
/// <summary>
47+
/// Create a RecentItem from a ShellLinkItem (usually from shortcuts in `Windows\Recent`)
48+
/// </summary>
49+
public RecentItem(ShellLinkItem linkItem) : base()
50+
{
51+
LinkPath = linkItem.FilePath;
52+
RecentPath = linkItem.TargetPath;
53+
Name = NameOrPathWithoutExtension(linkItem.FileName);
54+
Type = linkItem.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File;
55+
FolderImg = linkItem.IsFolder;
56+
FileIconVis = !linkItem.IsFolder;
57+
LastModified = linkItem.ModifiedDate;
58+
}
59+
60+
/// <summary>
61+
/// Create a RecentItem from a ShellFileItem (usually from enumerating Quick Access directly).
62+
/// </summary>
63+
/// <param name="fileItem">The shell file item</param>
64+
public RecentItem(ShellFileItem fileItem) : base()
65+
{
66+
LinkPath = fileItem.FilePath; // intentionally the same
67+
RecentPath = fileItem.FilePath; // intentionally the same
68+
Name = NameOrPathWithoutExtension(fileItem.FileName);
69+
Type = fileItem.IsFolder ? StorageItemTypes.Folder : StorageItemTypes.File;
70+
FolderImg = fileItem.IsFolder;
71+
FileIconVis = !fileItem.IsFolder;
72+
LastModified = fileItem.ModifiedDate;
73+
}
74+
75+
public async Task LoadRecentItemIcon()
76+
{
77+
var iconData = await FileThumbnailHelper.LoadIconFromPathAsync(RecentPath, 24u, ThumbnailMode.ListView);
78+
if (iconData == null)
79+
{
80+
EmptyImgVis = true;
81+
}
82+
else
83+
{
84+
EmptyImgVis = false;
85+
FileImg = await iconData.ToBitmapAsync();
86+
}
87+
}
88+
89+
/// <summary>
90+
/// Test equality for generic collection methods such as Remove(...)
91+
/// </summary>
92+
public bool Equals(RecentItem other)
93+
{
94+
if (other == null)
95+
{
96+
return false;
97+
}
98+
99+
// do not include LastModified or anything else here; otherwise, Remove(...) will fail since we lose metadata on deletion!
100+
// when constructing a RecentItem from a deleted link, the only thing we have is the LinkPath (where the link use to be)
101+
return LinkPath == other.LinkPath &&
102+
RecentPath == other.RecentPath;
103+
}
104+
105+
/**
106+
* Strips a name from an extension while aware of some edge cases.
107+
*
108+
* example.min.js => example.min
109+
* example.js => example
110+
* .gitignore => .gitignore
111+
*/
112+
private static string NameOrPathWithoutExtension(string nameOrPath)
113+
{
114+
string strippedExtension = Path.GetFileNameWithoutExtension(nameOrPath);
115+
return string.IsNullOrEmpty(strippedExtension) ? Path.GetFileName(nameOrPath) : strippedExtension;
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)