Add settings, cloud places, and optional archive-content indexing.

Keep official clients in charge of sync while Explorer can group locations, persist UI prefs, and list zip/rar/7z members without extracting them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-23 12:16:09 +02:00
parent e9aba73552
commit 09a8cfafa3
57 changed files with 3130 additions and 119 deletions

View File

@@ -42,6 +42,12 @@ public sealed class DuplicateHashWorker : BackgroundService
var path = PathRules.Combine(item.RootPath, item.PathRel);
try
{
if (!File.Exists(path))
{
await _store.Hashes.MarkSkippedAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
continue;
}
if (_hydration.WouldHydrateOnRead(item.Attributes, item.CloudAvailability)
|| await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false))
{

View File

@@ -228,6 +228,15 @@
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="ToolbarIconButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Width" Value="36"/>
<Setter Property="MinWidth" Value="36"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="FontFamily" Value="{StaticResource Symbol}"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="Margin" Value="0,0,4,0"/>
</Style>
<Style TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
@@ -360,6 +369,15 @@
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="RadioButton">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="Menu">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="FontSize" Value="13"/>
</Style>
<Style TargetType="ContextMenu">
<Setter Property="Background" Value="{DynamicResource Panel}"/>
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
@@ -398,7 +416,11 @@
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True"/>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{TemplateBinding InputGestureText}"
Foreground="{DynamicResource FgMuted}" Margin="24,0,0,0" VerticalAlignment="Center"/>
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
@@ -411,6 +433,58 @@
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Role" Value="TopLevelHeader">
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<Grid>
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center"/>
<Popup x:Name="PART_Popup"
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Bottom"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade">
<Border MinWidth="180" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="4">
<StackPanel IsItemsHost="True"/>
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsSubmenuOpen" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="Role" Value="TopLevelItem">
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>

View File

@@ -5,6 +5,8 @@ using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
@@ -34,11 +36,16 @@ public static class AppServices
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();

View File

@@ -29,6 +29,8 @@
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />

View File

@@ -41,6 +41,32 @@
</StackPanel>
</Grid>
</Border>
<Menu DockPanel.Dock="Top" Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="4,0">
<MenuItem Header="_File">
<MenuItem Header="New _tab" InputGestureText="Ctrl+T" Command="{Binding NewTabCommand}"/>
<MenuItem Header="_Split pane" Command="{Binding SplitCommand}"/>
<MenuItem Header="_Close tab" InputGestureText="Ctrl+W" Command="{Binding CloseTabCommand}" CommandParameter="{Binding ActiveTab}"/>
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Details" Command="{Binding SetViewCommand}" CommandParameter="Details"/>
<MenuItem Header="_List" Command="{Binding SetViewCommand}" CommandParameter="List"/>
<MenuItem Header="_Preview" Command="{Binding SetViewCommand}" CommandParameter="Preview"/>
<Separator/>
<MenuItem Header="_Refresh" InputGestureText="F5" Command="{Binding RefreshCommand}"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
<MenuItem Header="_Storage" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
<Separator/>
<MenuItem Header="Add _network…" Click="OnAddNetwork"/>
<MenuItem Header="Add One_Drive…" Click="OnAddOneDrive"/>
<MenuItem Header="Add _Google Drive…" Click="OnAddGoogleDrive"/>
<MenuItem Header="Add Ne_xtcloud…" Click="OnAddNextcloud"/>
</MenuItem>
<MenuItem Header="_Settings" Click="OnOpenSettings"/>
</Menu>
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
<Grid>
<Grid.ColumnDefinitions>
@@ -54,12 +80,49 @@
<Button Content="→" Command="{Binding ForwardCommand}" Width="36" Margin="0,0,4,0" ToolTip="Forward (Alt+Right)"/>
<Button Content="↑" Command="{Binding UpCommand}" Width="36" Margin="0,0,4,0" ToolTip="Up (Alt+Up)"/>
<Button Content="↻" Command="{Binding RefreshCommand}" Width="36" Margin="0,0,12,0" ToolTip="Refresh (F5)"/>
<Button Content="New tab" Command="{Binding NewTabCommand}" Margin="0,0,4,0"/>
<Button Content="Split" Command="{Binding SplitCommand}" Margin="0,0,4,0"/>
<Button Content="Index" Command="{Binding BuildIndexCommand}" Margin="0,0,4,0"/>
<Button Content="Details" Command="{Binding SetViewCommand}" CommandParameter="Details" Margin="8,0,4,0"/>
<Button Content="List" Command="{Binding SetViewCommand}" CommandParameter="List" Margin="0,0,4,0"/>
<Button Content="Preview" Command="{Binding SetViewCommand}" CommandParameter="Preview"/>
<Button Style="{StaticResource ToolbarIconButton}" Content="&#xE8C8;"
Command="{Binding NewTabCommand}" ToolTip="New tab (Ctrl+T)"/>
<Button Style="{StaticResource ToolbarIconButton}" Content="&#xE89F;"
Command="{Binding SplitCommand}" ToolTip="Split pane"/>
<Button Style="{StaticResource ToolbarIconButton}" Content="&#xE721;"
Command="{Binding BuildIndexCommand}" ToolTip="Index this location"/>
<Border Width="1" Height="18" Margin="4,0,8,0" VerticalAlignment="Center" Background="{DynamicResource Stroke}"/>
<Button Content="&#xE8EC;"
Command="{Binding SetViewCommand}" CommandParameter="Details" ToolTip="Details">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource ToolbarIconButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding ActivePane.ViewMode}" Value="Details">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Content="&#xE8FD;"
Command="{Binding SetViewCommand}" CommandParameter="List" ToolTip="List">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource ToolbarIconButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding ActivePane.ViewMode}" Value="List">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Content="&#xE8B9;"
Command="{Binding SetViewCommand}" CommandParameter="Preview" ToolTip="Preview">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource ToolbarIconButton}">
<Style.Triggers>
<DataTrigger Binding="{Binding ActivePane.ViewMode}" Value="Preview">
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
ItemsSource="{Binding PathHistory}"
@@ -69,8 +132,7 @@
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
KeyDown="OnSearchKeyDown"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
<Button Content="Search" Command="{Binding SearchCommand}" Margin="0,0,4,0"/>
<Button Content="Theme" Command="{Binding ToggleThemeCommand}"/>
<Button Content="Search" Command="{Binding SearchCommand}"/>
</StackPanel>
</Grid>
</Border>
@@ -102,7 +164,9 @@
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
<Button Content="Add OneDrive" Click="OnAddOneDrive"/>
<Button Content="Add OneDrive" Click="OnAddOneDrive" Margin="0,0,6,0"/>
<Button Content="Add Google Drive" Click="OnAddGoogleDrive" Margin="0,0,6,0"/>
<Button Content="Add Nextcloud" Click="OnAddNextcloud"/>
</StackPanel>
</Grid>
</Border>
@@ -118,7 +182,13 @@
<TreeView x:Name="NavTree" ItemsSource="{Binding Tree.Roots}"
SelectedItemChanged="OnTreeSelected"
TreeViewItem.Expanded="OnTreeExpanded"
ContextMenuOpening="OnTreeContextOpening"
HorizontalContentAlignment="Stretch">
<TreeView.ContextMenu>
<ContextMenu>
<MenuItem x:Name="RemoveLocationMenu" Header="Remove from Explorer" Click="OnRemoveLocation"/>
</ContextMenu>
</TreeView.ContextMenu>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal">
@@ -206,6 +276,9 @@
<Separator/>
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
<Separator Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
Visibility="{Binding DataContext.ShowForgetSource, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Always keep on this device"
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"

View File

@@ -3,6 +3,7 @@ using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using Explorer.Domain;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
@@ -129,6 +130,70 @@ public partial class MainWindow : Window
}
}
private void OnTreeContextOpening(object sender, ContextMenuEventArgs e)
{
var node = FindTreeNode(e.OriginalSource as DependencyObject);
if (node is null || !node.CanRemove)
{
e.Handled = true;
return;
}
node.IsSelected = true;
RemoveLocationMenu.Tag = node.Path;
}
private async void OnRemoveLocation(object sender, RoutedEventArgs e)
{
var path = (sender as FrameworkElement)?.Tag as string
?? RemoveLocationMenu.Tag as string
?? Vm.ActivePane.SelectedItems.FirstOrDefault()?.FullPath
?? (Vm.Tree.Roots.SelectMany(FlattenTree).FirstOrDefault(n => n.IsSelected && n.CanRemove)?.Path);
if (string.IsNullOrWhiteSpace(path))
{
return;
}
var label = Vm.Tree.Roots.SelectMany(FlattenTree).FirstOrDefault(n => NavigationTreeViewModel.PathsEqual(n.Path, path))?.Label
?? path;
var confirm = MessageBox.Show(
this,
$"Remove “{label}” from Explorer?\n\nThe link and its index data will be deleted. Files on disk or the server are not touched.\n\nIf Windows still has this location, it will appear again.",
"Remove from Explorer",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.Yes)
{
return;
}
await Vm.ForgetSourceAsync(path).ConfigureAwait(true);
}
private static IEnumerable<NavNodeViewModel> FlattenTree(NavNodeViewModel node)
{
yield return node;
foreach (var child in node.Children.Where(c => !c.IsPlaceholder).SelectMany(FlattenTree))
{
yield return child;
}
}
private static NavNodeViewModel? FindTreeNode(DependencyObject? origin)
{
while (origin is not null)
{
if (origin is TreeViewItem { DataContext: NavNodeViewModel node })
{
return node;
}
origin = origin is Visual ? VisualTreeHelper.GetParent(origin) : LogicalTreeHelper.GetParent(origin);
}
return null;
}
private async void OnTreeExpanded(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is TreeViewItem { DataContext: NavNodeViewModel node })
@@ -442,6 +507,13 @@ public partial class MainWindow : Window
}
}
private async void OnOpenSettings(object sender, RoutedEventArgs e)
{
var dlg = new SettingsWindow(Vm) { Owner = this };
dlg.ShowDialog();
await Vm.RefreshForgetActionAsync().ConfigureAwait(true);
}
private void OnAddNetwork(object sender, RoutedEventArgs e)
{
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
@@ -453,10 +525,19 @@ public partial class MainWindow : Window
}
private async void OnAddOneDrive(object sender, RoutedEventArgs e)
=> await AddCloudFolderAsync("Add OneDrive folder", MainViewModel.OneDriveProviderId).ConfigureAwait(true);
private async void OnAddGoogleDrive(object sender, RoutedEventArgs e)
=> await AddCloudFolderAsync("Add Google Drive folder", MainViewModel.GoogleDriveProviderId).ConfigureAwait(true);
private async void OnAddNextcloud(object sender, RoutedEventArgs e)
=> await AddCloudFolderAsync("Add Nextcloud folder", MainViewModel.NextcloudProviderId).ConfigureAwait(true);
private async Task AddCloudFolderAsync(string title, string providerId)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Add OneDrive folder",
Title = title,
Multiselect = false
};
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
@@ -464,7 +545,7 @@ public partial class MainWindow : Window
return;
}
await Vm.AddCloudFolderAsync(picker.FolderName).ConfigureAwait(true);
await Vm.AddCloudFolderAsync(picker.FolderName, providerId).ConfigureAwait(true);
}
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)

View File

@@ -0,0 +1,49 @@
<Window x:Class="Explorer.App.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Settings"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="620" Width="560"
MinHeight="520" MinWidth="480"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
<DockPanel Margin="20">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,20,0,0">
<Button Content="OK" MinWidth="88" Height="32" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
<Button Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Click="OnCancel"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Appearance" FontSize="16" FontWeight="SemiBold" Margin="0,0,0,10"/>
<TextBlock Text="Theme" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,20">
<RadioButton x:Name="ThemeDark" Content="Dark" GroupName="Theme" Margin="0,0,16,0"
Checked="OnThemeChanged"/>
<RadioButton x:Name="ThemeLight" Content="Light" GroupName="Theme"
Checked="OnThemeChanged"/>
</StackPanel>
<TextBlock Text="Locations tree" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,12"
Text="Choose whether network and cloud locations appear as their own top-level items, or are collected under a group next to This PC."/>
<CheckBox x:Name="GroupNetwork" Margin="0,0,0,6"
Content="Group network drives under Network"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,14" FontSize="12"
Text="Mapped letters and UNC shares become children of a Network item. This PC then shows only local and removable drives."/>
<CheckBox x:Name="GroupCloud" Margin="0,0,0,6"
Content="Group cloud locations under Cloud"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="OneDrive, Google Drive, and Nextcloud become children of a Cloud item. The two options are independent."/>
<TextBlock Text="Indexing" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<CheckBox x:Name="IndexArchives" Margin="0,0,0,6"
Content="Include archive contents in the index"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,0" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>

View File

@@ -0,0 +1,51 @@
using System.Windows;
using Explorer.Application;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class SettingsWindow : Window
{
private readonly MainViewModel _vm;
private readonly string _originalTheme;
public SettingsWindow(MainViewModel vm)
{
InitializeComponent();
_vm = vm;
var prefs = vm.CurrentPreferences();
_originalTheme = prefs.Theme;
ThemeDark.IsChecked = prefs.Theme != "Light";
ThemeLight.IsChecked = prefs.Theme == "Light";
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents;
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
{
if (!IsLoaded)
{
return;
}
_vm.Theme = ThemeLight.IsChecked == true ? "Light" : "Dark";
}
private async void OnOk(object sender, RoutedEventArgs e)
{
var prefs = new UiPreferences(
ThemeLight.IsChecked == true ? "Light" : "Dark",
GroupNetwork.IsChecked == true,
GroupCloud.IsChecked == true,
IndexArchives.IsChecked == true);
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
DialogResult = true;
Close();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
_vm.Theme = _originalTheme;
}
}

View File

@@ -10,26 +10,68 @@ public sealed class BrowseService
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
public BrowseService(
IFileSystemEnumerator enumerator,
IVolumeService volumes,
IIndexStore store,
SourceManager sources,
StorageProviderRegistry providers)
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{
_enumerator = enumerator;
_volumes = volumes;
_store = store;
_sources = sources;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
}
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
{
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
return await ListSourcesAsync(
LocationRoots.ThisPc,
source => !groupNetwork || !source.Kind.IsNetwork(),
cancellationToken).ConfigureAwait(false);
}
public Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
=> ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken);
public Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
{
var items = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(place =>
{
var exists = Directory.Exists(place.Path);
return new FileSystemItem
{
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory
};
})
.ToList();
return Task.FromResult(new FolderListing { Path = LocationRoots.Cloud, Items = items });
}
private async Task<FolderListing> ListSourcesAsync(
string path,
Func<Source, bool> include,
CancellationToken cancellationToken)
{
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
var items = new List<FileSystemItem>();
foreach (var source in sources.OrderBy(s => s.Kind).ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
foreach (var source in sources.Where(include)
.OrderBy(s => s.Kind)
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
IndexEntry? root = null;
if (source.IsIndexed)
@@ -49,12 +91,21 @@ public sealed class BrowseService
});
}
return new FolderListing { Path = "This PC", Items = items };
return new FolderListing { Path = path, Items = items };
}
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
{
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
return archiveListing;
}
}
var reachable = _volumes.IsPathReachable(path);
if (reachable)
@@ -106,6 +157,73 @@ public sealed class BrowseService
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
public bool CanBrowseArchive(string name)
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
private async Task<FolderListing?> TryListArchiveAsync(Source source, string path, CancellationToken cancellationToken)
{
if (source.LastRootPath is null)
{
return null;
}
var rel = PathRules.MakeRelative(source.LastRootPath, path);
var entry = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false);
if (entry is null)
{
return null;
}
var isArchiveFile = !entry.IsDirectory && ArchiveFormats.IsArchive(entry.Name);
if (!isArchiveFile && Directory.Exists(path))
{
return null;
}
if (!isArchiveFile && !await IsUnderArchiveAsync(source.Id, rel, cancellationToken).ConfigureAwait(false))
{
return null;
}
var children = await _store.Entries.GetChildrenAsync(source.Id, entry.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var items = children.Select(c => new FileSystemItem
{
FullPath = PathRules.Combine(source.LastRootPath, c.PathRel),
Name = c.Name,
IsDirectory = c.IsDirectory,
SizeBytes = c.IsDirectory ? c.AggregateSize : c.SizeBytes,
CreatedUtc = c.CreatedUtc,
ModifiedUtc = c.ModifiedUtc,
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes
}).ToList();
var hint = isArchiveFile && items.Count == 0
? "Archive contents appear after the next scan."
: null;
return new FolderListing { Path = path, IsOffline = false, Items = items, Error = hint };
}
private async Task<bool> IsUnderArchiveAsync(long sourceId, string pathRel, CancellationToken cancellationToken)
{
var current = pathRel;
while (!string.IsNullOrEmpty(current))
{
var entry = await _store.Entries.GetByPathAsync(sourceId, current, cancellationToken).ConfigureAwait(false);
if (entry is { IsDirectory: false } && ArchiveFormats.IsArchive(entry.Name))
{
return true;
}
current = PathRules.RelativeParent(current);
}
return false;
}
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
Source source,
string path,

View File

@@ -32,7 +32,7 @@ public sealed class CloudPlaceStore
public IReadOnlyList<ProviderPlace> Add(string providerId, string path, string? displayName = null)
{
var places = Load().ToList();
var trimmed = path.Trim().TrimEnd('\\');
var trimmed = CloudPath.NormalizePlace(path);
if (string.IsNullOrWhiteSpace(trimmed))
{
return places;
@@ -42,7 +42,13 @@ public sealed class CloudPlaceStore
var label = string.IsNullOrWhiteSpace(displayName) ? Path.GetFileName(trimmed) : displayName.Trim();
if (string.IsNullOrWhiteSpace(label))
{
label = "OneDrive";
label = providerId switch
{
"googledrive" => "Google Drive",
"nextcloud" => "Nextcloud",
"onedrive" => "OneDrive",
_ => "Cloud folder"
};
}
places.Add(new ProviderPlace(providerId, label, trimmed));
@@ -75,7 +81,7 @@ public sealed class CloudPlaceStore
continue;
}
var path = place.Path.TrimEnd('\\');
var path = CloudPath.NormalizePlace(place.Path);
if (list.Exists(p => p.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
{
continue;
@@ -104,7 +110,7 @@ public sealed class CloudPlaceStore
continue;
}
list.Add(new ProviderPlace(parts[0], Unescape(parts[1]), parts[2].TrimEnd('\\')));
list.Add(new ProviderPlace(parts[0], Unescape(parts[1]), CloudPath.NormalizePlace(parts[2])));
}
return list;

View File

@@ -0,0 +1,8 @@
namespace Explorer.Application;
public sealed record ArchiveMember(string RelativePath, bool IsDirectory, long SizeBytes);
public interface IArchiveCatalog
{
IReadOnlyList<ArchiveMember> TryList(string archivePath, CancellationToken cancellationToken = default);
}

View File

@@ -1,3 +1,4 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
@@ -54,7 +55,7 @@ public sealed class PathHistoryStore
foreach (var raw in paths)
{
var path = raw.Trim();
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
continue;
}

View File

@@ -162,6 +162,53 @@ public sealed class SourceManager
return source;
}
public bool IsPresentInWindows(Source source)
{
var online = _volumes.EnumerateOnlineVolumes();
foreach (var fp in online)
{
var match = VolumeIdentityMatcher.Match(fp, [source]);
if (match.Source is not null)
{
return true;
}
if (source.LastRootPath is not null && RootsEqual(fp.RootPath, source.LastRootPath))
{
return true;
}
}
return source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
}
public bool CanForget(Source source) => !IsPresentInWindows(source);
public async Task<bool> CanForgetPathAsync(string path, CancellationToken cancellationToken = default)
{
var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false);
return source is not null && CanForget(source);
}
public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default)
{
var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false);
if (source is null || !CanForget(source))
{
return false;
}
await _store.RunWriteAsync(store => store.Sources.DeleteAsync(source.Id, cancellationToken), cancellationToken)
.ConfigureAwait(false);
if (source.LastRootPath is not null && PathRules.IsUnc(source.LastRootPath))
{
ForgetUnc(PathRules.CanonicalUncRoot(source.LastRootPath));
}
_logger.LogInformation("Forgot disconnected source {DisplayName} ({Path})", source.DisplayName, source.LastRootPath);
return true;
}
public async Task<Source?> FindByPathAsync(string path, CancellationToken cancellationToken = default)
{
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
@@ -194,7 +241,7 @@ public sealed class SourceManager
public async Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return null;
}
@@ -255,6 +302,50 @@ public sealed class SourceManager
return created;
}
private async Task<Source?> FindSourceRootAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return null;
}
var source = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source?.LastRootPath is null)
{
return null;
}
return RootsEqual(source.LastRootPath, path) ? source : null;
}
private static bool RootsEqual(string a, string b)
{
var left = PathRules.EnsureDirectoryTrailingSlashIfRoot(PathRules.FromExtended(a).TrimEnd('\\'));
var right = PathRules.EnsureDirectoryTrailingSlashIfRoot(PathRules.FromExtended(b).TrimEnd('\\'));
return left.Equals(right, StringComparison.OrdinalIgnoreCase);
}
private void ForgetUnc(string root)
{
try
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");
if (!File.Exists(file))
{
return;
}
var lines = LoadRecents()
.Where(l => !PathRules.CanonicalUncRoot(l).Equals(root, StringComparison.OrdinalIgnoreCase))
.ToList();
File.WriteAllLines(file, lines);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed updating recents");
}
}
private IReadOnlyList<string> LoadRecents()
{
var file = Path.Combine(_env.DataDirectory, "recents.txt");

View File

@@ -0,0 +1,109 @@
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed record UiPreferences(
string Theme,
bool GroupNetworkPlaces,
bool GroupCloudPlaces,
bool IndexArchiveContents = false)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false);
}
public sealed class UiPreferencesStore
{
public const string FileName = "ui-preferences.txt";
private readonly IAppEnvironment _env;
public UiPreferencesStore(IAppEnvironment env) => _env = env;
public UiPreferences Load()
{
var file = Path.Combine(_env.DataDirectory, FileName);
try
{
if (!File.Exists(file))
{
return UiPreferences.Default;
}
return Parse(File.ReadAllLines(file));
}
catch
{
return UiPreferences.Default;
}
}
public void Save(UiPreferences preferences)
{
try
{
Directory.CreateDirectory(_env.DataDirectory);
File.WriteAllLines(Path.Combine(_env.DataDirectory, FileName),
[
"theme=" + NormalizeTheme(preferences.Theme),
"group-network=" + (preferences.GroupNetworkPlaces ? "true" : "false"),
"group-cloud=" + (preferences.GroupCloudPlaces ? "true" : "false"),
"index-archives=" + (preferences.IndexArchiveContents ? "true" : "false")
]);
}
catch
{
// preferences are convenience-only
}
}
public static UiPreferences Parse(IEnumerable<string> lines)
{
var theme = UiPreferences.Default.Theme;
var groupNetwork = false;
var groupCloud = false;
var indexArchives = false;
foreach (var raw in lines)
{
var line = raw.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
var eq = line.IndexOf('=');
if (eq <= 0)
{
continue;
}
var key = line[..eq].Trim();
var value = line[(eq + 1)..].Trim();
if (key.Equals("theme", StringComparison.OrdinalIgnoreCase))
{
theme = NormalizeTheme(value);
}
else if (key.Equals("group-network", StringComparison.OrdinalIgnoreCase))
{
groupNetwork = IsTrue(value);
}
else if (key.Equals("group-cloud", StringComparison.OrdinalIgnoreCase))
{
groupCloud = IsTrue(value);
}
else if (key.Equals("index-archives", StringComparison.OrdinalIgnoreCase))
{
indexArchives = IsTrue(value);
}
}
return new UiPreferences(theme, groupNetwork, groupCloud, indexArchives);
}
public static string NormalizeTheme(string? theme)
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
private static bool IsTrue(string value)
=> value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)
|| value.Equals("yes", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -30,6 +30,7 @@ public interface ISourceStore
Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default);
Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default);
Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}
public interface IEntryStore
@@ -48,6 +49,7 @@ public interface IEntryStore
Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default);
Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default);
Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default);
Task TombstoneByPathPrefixAsync(long sourceId, string pathRelPrefix, DateTimeOffset utc, CancellationToken cancellationToken = default);
Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default);
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);

View File

@@ -18,4 +18,5 @@ public static class AppConstants
public const int LocalScanParallelism = 4;
public const int NetworkScanParallelism = 1;
public const int ProgressHzMilliseconds = 100;
public const int MaxArchiveEntries = 8000;
}

View File

@@ -0,0 +1,71 @@
namespace Explorer.Domain;
public static class ArchiveFormats
{
private static readonly HashSet<string> Extensions = new(StringComparer.OrdinalIgnoreCase)
{
"zip", "zipx", "cbz", "epub",
"rar", "cbr",
"7z", "cb7",
"tar", "tgz", "tbz", "tbz2", "txz",
"gz", "bz2", "xz",
"cab"
};
private static readonly HashSet<string> ZipFamily = new(StringComparer.OrdinalIgnoreCase)
{
"zip", "zipx", "cbz", "epub"
};
public static bool IsArchive(string name)
{
var ext = NameNormalizer.Extension(name);
if (ext is not null && Extensions.Contains(ext))
{
return true;
}
var lower = name.ToLowerInvariant();
return lower.EndsWith(".tar.gz", StringComparison.Ordinal)
|| lower.EndsWith(".tar.bz2", StringComparison.Ordinal)
|| lower.EndsWith(".tar.xz", StringComparison.Ordinal);
}
public static bool IsZipFamily(string name)
{
var ext = NameNormalizer.Extension(name);
return ext is not null && ZipFamily.Contains(ext);
}
public static bool TryNormalizeEntryPath(string raw, out string relative)
{
relative = string.Empty;
if (string.IsNullOrWhiteSpace(raw))
{
return false;
}
var n = raw.Replace('/', '\\').Trim().TrimStart('\\');
if (string.IsNullOrEmpty(n))
{
return false;
}
if (n.Contains(':', StringComparison.Ordinal) || Path.IsPathRooted(n))
{
return false;
}
var parts = n.Split('\\', StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
if (part is "." or "..")
{
return false;
}
}
relative = string.Join('\\', parts);
return relative.Length > 0;
}
}

View File

@@ -9,6 +9,11 @@ public enum SourceKind
Cloud
}
public static class SourceKinds
{
public static bool IsNetwork(this SourceKind kind) => kind is SourceKind.Smb or SourceKind.Nfs;
}
public enum SourceStatus
{
Online,

View File

@@ -0,0 +1,11 @@
namespace Explorer.Domain;
public static class LocationRoots
{
public const string ThisPc = "This PC";
public const string Network = "Network";
public const string Cloud = "Cloud";
public static bool IsVirtual(string? path)
=> path is ThisPc or Network or Cloud;
}

View File

@@ -150,6 +150,18 @@ public static class PathRules
return idx < 0 ? p : p[(idx + 1)..];
}
public static string RelativeParent(string pathRel)
{
if (string.IsNullOrEmpty(pathRel))
{
return string.Empty;
}
var n = NormalizeDirectorySeparators(pathRel).TrimEnd('\\');
var idx = n.LastIndexOf('\\');
return idx < 0 ? string.Empty : n[..idx];
}
public static string JoinDisplay(string? root, string pathRel)
{
if (string.IsNullOrWhiteSpace(root))

View File

@@ -0,0 +1,96 @@
using System.IO.Compression;
using Explorer.Application;
using Explorer.Domain;
using SharpCompress.Archives;
using SharpCompress.Readers;
namespace Explorer.Indexing;
public sealed class ArchiveCatalog : IArchiveCatalog
{
public IReadOnlyList<ArchiveMember> TryList(string archivePath, CancellationToken cancellationToken = default)
{
try
{
if (!File.Exists(archivePath))
{
return [];
}
var name = Path.GetFileName(archivePath);
if (ArchiveFormats.IsZipFamily(name))
{
return ListZip(archivePath, cancellationToken);
}
return ListGeneric(archivePath, cancellationToken);
}
catch
{
return [];
}
}
private static IReadOnlyList<ArchiveMember> ListZip(string archivePath, CancellationToken cancellationToken)
{
using var stream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
return Collect(zip.Entries.Select(e => (
e.FullName,
e.FullName.EndsWith('/') || e.FullName.EndsWith('\\'),
e.Length)), cancellationToken);
}
private static IReadOnlyList<ArchiveMember> ListGeneric(string archivePath, CancellationToken cancellationToken)
{
try
{
using var archive = ArchiveFactory.OpenArchive(archivePath);
return Collect(archive.Entries.Select(e => (
e.Key ?? "",
e.IsDirectory,
e.Size)), cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
using var reader = ReaderFactory.OpenReader(archivePath);
var raw = new List<(string Path, bool IsDirectory, long Size)>();
while (reader.MoveToNextEntry())
{
cancellationToken.ThrowIfCancellationRequested();
raw.Add((reader.Entry.Key ?? "", reader.Entry.IsDirectory, reader.Entry.Size));
}
return Collect(raw, cancellationToken);
}
}
private static IReadOnlyList<ArchiveMember> Collect(
IEnumerable<(string Path, bool IsDirectory, long Size)> entries,
CancellationToken cancellationToken)
{
var seen = new Dictionary<string, ArchiveMember>(StringComparer.OrdinalIgnoreCase);
foreach (var (path, isDirectory, size) in entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (seen.Count >= AppConstants.MaxArchiveEntries)
{
break;
}
if (!ArchiveFormats.TryNormalizeEntryPath(path, out var relative))
{
continue;
}
var dir = isDirectory || path.EndsWith('/') || path.EndsWith('\\');
seen[relative] = new ArchiveMember(relative, dir, dir ? 0 : Math.Max(0, size));
}
return seen.Values.ToList();
}
}

View File

@@ -0,0 +1,111 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
public sealed class ArchiveContentsIndexer
{
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly IArchiveCatalog _catalog;
private readonly UiPreferencesStore _preferences;
private readonly ILogger<ArchiveContentsIndexer> _logger;
public ArchiveContentsIndexer(
IIndexStore store,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration,
IArchiveCatalog catalog,
UiPreferencesStore preferences,
ILogger<ArchiveContentsIndexer> logger)
{
_store = store;
_enumerator = enumerator;
_hydration = hydration;
_catalog = catalog;
_preferences = preferences;
_logger = logger;
}
public bool IsEnabled => _preferences.Load().IndexArchiveContents;
public Task TombstoneInnerAsync(long sourceId, string archivePathRel, DateTimeOffset utc, CancellationToken cancellationToken)
=> _store.Entries.TombstoneByPathPrefixAsync(sourceId, archivePathRel, utc, cancellationToken);
public async Task ExpandIfNeededAsync(
Source source,
IndexEntry archive,
DateTimeOffset now,
long generation,
CancellationToken cancellationToken)
{
if (!IsEnabled
|| archive.IsDirectory
|| archive.Id <= 0
|| string.IsNullOrEmpty(source.LastRootPath)
|| !ArchiveFormats.IsArchive(archive.Name))
{
return;
}
var full = PathRules.Combine(source.LastRootPath, archive.PathRel);
var item = _enumerator.GetItem(full);
if (item is null)
{
return;
}
if (_hydration.WouldHydrateOnRead(item)
|| await _hydration.WouldHydrateOnReadAsync(full, cancellationToken).ConfigureAwait(false))
{
return;
}
IReadOnlyList<ArchiveMember> members;
try
{
members = _catalog.TryList(full, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Archive listing failed for {Path}", full);
return;
}
var nodes = ArchiveTreeBuilder.Build(archive, members);
await _store.RunWriteAsync(async s =>
{
await s.Entries.TombstoneByPathPrefixAsync(source.Id, archive.PathRel, now, cancellationToken)
.ConfigureAwait(false);
var ids = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase)
{
[archive.PathRel] = archive.Id
};
foreach (var node in nodes)
{
if (!ids.TryGetValue(node.ParentPathRel, out var parentId))
{
continue;
}
var entry = node.ToEntry(source, parentId, now, generation);
entry.Id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
ids[node.PathRel] = entry.Id;
if (node.IsDirectory)
{
await s.Entries.UpdateAggregatesAsync(
entry.Id, node.AggregateSize, node.ChildFiles, node.ChildDirs, cancellationToken)
.ConfigureAwait(false);
}
}
}, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,139 @@
using Explorer.Application;
using Explorer.Domain;
namespace Explorer.Indexing;
internal sealed record ArchiveNode(
string PathRel,
string ParentPathRel,
string Name,
bool IsDirectory,
long SizeBytes,
long AggregateSize,
int ChildFiles,
int ChildDirs);
internal static class ArchiveTreeBuilder
{
public static IReadOnlyList<ArchiveNode> Build(
IndexEntry archive,
IReadOnlyList<ArchiveMember> members)
{
var dirs = new Dictionary<string, DirAcc>(StringComparer.OrdinalIgnoreCase);
var files = new List<(string PathRel, string Name, long Size)>();
foreach (var member in members)
{
var parts = member.RelativePath.Split('\\', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
continue;
}
var dirCount = member.IsDirectory ? parts.Length : parts.Length - 1;
var prefix = "";
for (var i = 0; i < dirCount; i++)
{
prefix = prefix.Length == 0 ? parts[i] : prefix + "\\" + parts[i];
dirs.TryAdd(prefix, new DirAcc(parts[i]));
}
if (!member.IsDirectory)
{
files.Add((member.RelativePath, parts[^1], member.SizeBytes));
}
}
foreach (var file in files)
{
var parent = PathRules.RelativeParent(file.PathRel);
while (parent.Length > 0)
{
if (dirs.TryGetValue(parent, out var acc))
{
acc.Size += file.Size;
acc.Files++;
}
parent = PathRules.RelativeParent(parent);
}
}
foreach (var dirRel in dirs.Keys)
{
var parent = PathRules.RelativeParent(dirRel);
while (parent.Length > 0)
{
if (dirs.TryGetValue(parent, out var acc))
{
acc.Dirs++;
}
parent = PathRules.RelativeParent(parent);
}
}
var nodes = new List<ArchiveNode>(dirs.Count + files.Count);
foreach (var (rel, acc) in dirs)
{
nodes.Add(ToNode(archive.PathRel, rel, acc.Name, true, 0, acc.Size, acc.Files, acc.Dirs));
}
foreach (var file in files)
{
nodes.Add(ToNode(archive.PathRel, file.PathRel, file.Name, false, file.Size, file.Size, 0, 0));
}
return nodes
.OrderBy(n => n.PathRel.Count(c => c == '\\'))
.ThenBy(n => n.PathRel, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static IndexEntry ToEntry(this ArchiveNode node, Source source, long parentId, DateTimeOffset now, long generation)
=> new()
{
SourceId = source.Id,
ParentId = parentId,
Name = node.Name,
NameNorm = NameNormalizer.Normalize(node.Name),
Extension = node.IsDirectory ? null : NameNormalizer.Extension(node.Name),
IsDirectory = node.IsDirectory,
SizeBytes = node.IsDirectory ? 0 : node.SizeBytes,
AggregateSize = node.AggregateSize,
ChildFileCount = node.ChildFiles,
ChildDirCount = node.ChildDirs,
LastSeenUtc = now,
LastIndexedUtc = now,
Status = EntryStatus.Present,
PathRel = node.PathRel,
ScanGeneration = generation,
HashState = HashState.Skipped
};
private static ArchiveNode ToNode(
string archivePathRel,
string innerRel,
string name,
bool isDir,
long size,
long aggregate,
int files,
int dirs)
{
var pathRel = string.IsNullOrEmpty(archivePathRel) ? innerRel : archivePathRel + "\\" + innerRel;
var parentInner = PathRules.RelativeParent(innerRel);
var parentPathRel = parentInner.Length == 0
? archivePathRel
: (string.IsNullOrEmpty(archivePathRel) ? parentInner : archivePathRel + "\\" + parentInner);
return new ArchiveNode(pathRel, parentPathRel, name, isDir, size, aggregate, files, dirs);
}
private sealed class DirAcc(string name)
{
public string Name { get; } = name;
public long Size { get; set; }
public int Files { get; set; }
public int Dirs { get; set; }
}
}

View File

@@ -5,6 +5,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="SharpCompress" Version="0.50.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />

View File

@@ -11,17 +11,20 @@ public sealed class FilesystemScanner
private readonly IFileSystemEnumerator _enumerator;
private readonly StorageProviderRegistry _providers;
private readonly ILogger<FilesystemScanner> _logger;
private readonly ArchiveContentsIndexer? _archives;
public FilesystemScanner(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
ILogger<FilesystemScanner> logger)
ILogger<FilesystemScanner> logger,
ArchiveContentsIndexer? archives = null)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
_logger = logger;
_archives = archives;
}
public async Task<ScanJob> ScanAsync(
@@ -155,7 +158,8 @@ public sealed class FilesystemScanner
}
}
await FlushAsync(pending, childDirs, cancellationToken).ConfigureAwait(false);
await FlushAsync(pending, childDirs, source, now, generation, expandArchives: true, cancellationToken)
.ConfigureAwait(false);
frame.Expanded = true;
for (var i = childDirs.Count - 1; i >= 0; i--)
{
@@ -184,7 +188,8 @@ public sealed class FilesystemScanner
}
}
await FlushAsync(pending, [], cancellationToken).ConfigureAwait(false);
await FlushAsync(pending, [], source, now, generation, expandArchives: true, cancellationToken)
.ConfigureAwait(false);
await _store.Entries.MarkMissingAsDeletedAsync(source.Id, generation, DateTimeOffset.UtcNow, string.IsNullOrEmpty(startRel) ? null : startRel, cancellationToken)
.ConfigureAwait(false);
await _store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, generation, cancellationToken)
@@ -198,7 +203,8 @@ public sealed class FilesystemScanner
}
catch (OperationCanceledException)
{
await FlushAsync(pending, [], CancellationToken.None).ConfigureAwait(false);
await FlushAsync(pending, [], source, now, generation, expandArchives: true, CancellationToken.None)
.ConfigureAwait(false);
job.Status = ScanJobStatus.Cancelled;
job.FinishedUtc = DateTimeOffset.UtcNow;
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
@@ -219,7 +225,14 @@ public sealed class FilesystemScanner
}
}
private async Task FlushAsync(List<IndexEntry> pending, List<Frame> dirs, CancellationToken cancellationToken)
private async Task FlushAsync(
List<IndexEntry> pending,
List<Frame> dirs,
Source source,
DateTimeOffset now,
long generation,
bool expandArchives,
CancellationToken cancellationToken)
{
if (pending.Count == 0)
{
@@ -244,7 +257,24 @@ public sealed class FilesystemScanner
}
}
var flushed = pending.ToList();
pending.Clear();
if (!expandArchives || _archives is null)
{
return;
}
foreach (var entry in flushed)
{
if (entry.IsDirectory || !ArchiveFormats.IsArchive(entry.Name))
{
continue;
}
await _archives.ExpandIfNeededAsync(source, entry, now, generation, cancellationToken)
.ConfigureAwait(false);
}
}
private static ScanProgress ToProgress(ScanJob job, string path) => new()

View File

@@ -1,7 +1,6 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Indexing;
@@ -10,12 +9,18 @@ public sealed class FolderReconciler
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly StorageProviderRegistry _providers;
private readonly ArchiveContentsIndexer? _archives;
public FolderReconciler(IIndexStore store, IFileSystemEnumerator enumerator, StorageProviderRegistry providers)
public FolderReconciler(
IIndexStore store,
IFileSystemEnumerator enumerator,
StorageProviderRegistry providers,
ArchiveContentsIndexer? archives = null)
{
_store = store;
_enumerator = enumerator;
_providers = providers;
_archives = archives;
}
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
@@ -32,12 +37,38 @@ public sealed class FolderReconciler
return;
}
if (!parent.IsDirectory)
{
if (ArchiveFormats.IsArchive(parent.Name))
{
if (_archives is { IsEnabled: true } && File.Exists(full))
{
await _archives.ExpandIfNeededAsync(source, parent, DateTimeOffset.UtcNow, source.ScanGeneration, cancellationToken)
.ConfigureAwait(false);
}
else
{
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
}
}
return;
}
if (!Directory.Exists(full))
{
return;
}
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
.ConfigureAwait(false);
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var now = DateTimeOffset.UtcNow;
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
var archivesToExpand = new List<IndexEntry>();
var archivesToTomb = new List<string>();
await _store.RunWriteAsync(async s =>
{
@@ -71,6 +102,7 @@ public sealed class FolderReconciler
var existing = indexed.FirstOrDefault(e => e.NameNorm == entry.NameNorm);
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
var id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
entry.Id = id;
if (!item.IsDirectory)
{
var delta = item.SizeBytes - oldSize;
@@ -84,14 +116,17 @@ public sealed class FolderReconciler
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
.ConfigureAwait(false);
}
if (ArchiveFormats.IsArchive(item.Name))
{
archivesToExpand.Add(entry);
}
}
else if (existing is null)
{
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
.ConfigureAwait(false);
}
_ = id;
}
foreach (var old in indexed)
@@ -99,8 +134,27 @@ public sealed class FolderReconciler
if (!liveNames.Contains(old.NameNorm))
{
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
if (!old.IsDirectory && ArchiveFormats.IsArchive(old.Name))
{
archivesToTomb.Add(old.PathRel);
}
}
}
}, cancellationToken).ConfigureAwait(false);
foreach (var path in archivesToTomb)
{
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, path, now, cancellationToken)
.ConfigureAwait(false);
}
if (_archives is { IsEnabled: true })
{
foreach (var archive in archivesToExpand)
{
await _archives.ExpandIfNeededAsync(source, archive, now, source.ScanGeneration, cancellationToken)
.ConfigureAwait(false);
}
}
}
}

View File

@@ -9,12 +9,18 @@ public sealed class UsnChangeApplier
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly ILogger<UsnChangeApplier> _logger;
private readonly ArchiveContentsIndexer? _archives;
public UsnChangeApplier(IIndexStore store, IFileSystemEnumerator enumerator, ILogger<UsnChangeApplier> logger)
public UsnChangeApplier(
IIndexStore store,
IFileSystemEnumerator enumerator,
ILogger<UsnChangeApplier> logger,
ArchiveContentsIndexer? archives = null)
{
_store = store;
_enumerator = enumerator;
_logger = logger;
_archives = archives;
}
public async Task<UsnReadStatus> ApplyAsync(Source source, IUsnJournal journal, CancellationToken cancellationToken)
@@ -64,6 +70,7 @@ public sealed class UsnChangeApplier
var coalesced = Coalesce(records);
var now = DateTimeOffset.UtcNow;
var expand = new List<IndexEntry>();
await _store.RunWriteAsync(async s =>
{
foreach (var rec in coalesced)
@@ -71,7 +78,11 @@ public sealed class UsnChangeApplier
cancellationToken.ThrowIfCancellationRequested();
try
{
await ApplyRecord(s, source, rec, now, cancellationToken).ConfigureAwait(false);
var updated = await ApplyRecord(s, source, rec, now, cancellationToken).ConfigureAwait(false);
if (updated is not null)
{
expand.Add(updated);
}
}
catch (Exception ex)
{
@@ -82,10 +93,19 @@ public sealed class UsnChangeApplier
await s.Sources.UpdateUsnAsync(source.Id, next.JournalId, next.NextUsn, cancellationToken).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);
if (_archives is { IsEnabled: true })
{
foreach (var archive in expand)
{
await _archives.ExpandIfNeededAsync(source, archive, now, source.ScanGeneration, cancellationToken)
.ConfigureAwait(false);
}
}
return UsnReadStatus.Ok;
}
private async Task ApplyRecord(IIndexStore store, Source source, UsnRecord rec, DateTimeOffset now, CancellationToken cancellationToken)
private async Task<IndexEntry?> ApplyRecord(IIndexStore store, Source source, UsnRecord rec, DateTimeOffset now, CancellationToken cancellationToken)
{
var existing = rec.FileReferenceNumber != 0
? await store.Entries.GetByFileIdAsync(source.Id, rec.FileReferenceNumber, cancellationToken).ConfigureAwait(false)
@@ -96,9 +116,14 @@ public sealed class UsnChangeApplier
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
{
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
.ConfigureAwait(false);
}
}
return;
return null;
}
var parent = rec.ParentFileReferenceNumber != 0
@@ -115,9 +140,14 @@ public sealed class UsnChangeApplier
if (existing is not null)
{
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
{
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
.ConfigureAwait(false);
}
}
return;
return null;
}
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
@@ -150,7 +180,7 @@ public sealed class UsnChangeApplier
.ConfigureAwait(false);
}
await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
entry.Id = await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
if (!live.IsDirectory)
{
var delta = live.SizeBytes - oldSize;
@@ -164,7 +194,14 @@ public sealed class UsnChangeApplier
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, delta, 0, 0, cancellationToken)
.ConfigureAwait(false);
}
if (ArchiveFormats.IsArchive(live.Name))
{
return entry;
}
}
return null;
}
private static List<UsnRecord> Coalesce(IReadOnlyList<UsnRecord> records)

View File

@@ -80,7 +80,38 @@ public sealed record ProviderQuota(
public sealed record ProviderPlace(
string ProviderId,
string DisplayName,
string Path);
string Path,
string? Glyph = null);
public static class CloudPath
{
public static string NormalizePlace(string path)
{
var n = path.Replace('/', '\\').Trim();
if (n.Length >= 2 && n[1] == ':')
{
if (n.Length == 2 || (n.Length == 3 && n[2] == '\\'))
{
return char.ToUpperInvariant(n[0]) + @":\";
}
}
return n.TrimEnd('\\');
}
public static bool IsUnder(string root, string path)
{
if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(path))
{
return false;
}
var a = NormalizePlace(root).TrimEnd('\\');
var b = NormalizePlace(path).TrimEnd('\\');
return b.Equals(a, StringComparison.OrdinalIgnoreCase)
|| b.StartsWith(a + "\\", StringComparison.OrdinalIgnoreCase);
}
}
public interface IStorageProvider
{

View File

@@ -0,0 +1,143 @@
using Microsoft.Data.Sqlite;
using Explorer.Plugin.Abstractions;
namespace Explorer.Plugin.GoogleDrive;
public sealed record DriveFsMediaRow(string Name, string LastMountPoint, int FileSystemType);
public sealed record DriveFsRootRow(string Title, string RootPath, string LastSeenAbsolutePath, bool IsMyDrive);
public static class DriveFsPreferenceReader
{
public const string DatabaseFileName = "root_preference_sqlite.db";
public static string DefaultDatabasePath
=> Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Google",
"DriveFS",
DatabaseFileName);
public static IReadOnlyList<ProviderPlace> ReadPlaces(string? databasePath = null)
{
var db = string.IsNullOrWhiteSpace(databasePath) ? DefaultDatabasePath : databasePath;
try
{
if (!File.Exists(db))
{
return [];
}
return PlacesFromTables(ReadMedia(db), ReadRoots(db));
}
catch
{
return [];
}
}
public static IReadOnlyList<ProviderPlace> PlacesFromTables(
IEnumerable<DriveFsMediaRow> media,
IEnumerable<DriveFsRootRow>? roots = null)
{
var found = new List<ProviderPlace>();
foreach (var row in media)
{
if (!IsGoogleDriveVolume(row))
{
continue;
}
var path = CloudPath.NormalizePlace(row.LastMountPoint);
if (path.Length == 0 || found.Exists(p => CloudPath.IsUnder(p.Path, path) || CloudPath.IsUnder(path, p.Path)))
{
continue;
}
found.Add(new ProviderPlace(GoogleDriveStorageProvider.ProviderId, "Google Drive", path, GoogleDrivePlaceDiscovery.Glyph));
}
foreach (var row in roots ?? [])
{
var path = FirstNonEmpty(row.LastSeenAbsolutePath, row.RootPath);
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
var normalized = CloudPath.NormalizePlace(path);
if (normalized.Length == 0
|| found.Exists(p => CloudPath.IsUnder(p.Path, normalized)))
{
continue;
}
var title = string.IsNullOrWhiteSpace(row.Title) ? null : row.Title.Trim();
var label = string.IsNullOrWhiteSpace(title) || title.Equals("My Drive", StringComparison.OrdinalIgnoreCase)
? "Google Drive"
: $"Google Drive - {title}";
found.Add(new ProviderPlace(GoogleDriveStorageProvider.ProviderId, label, normalized, GoogleDrivePlaceDiscovery.Glyph));
}
return found;
}
public static bool IsGoogleDriveVolume(DriveFsMediaRow row)
=> row.Name.Equals("Google Drive", StringComparison.OrdinalIgnoreCase)
|| row.Name.Contains("Google Drive", StringComparison.OrdinalIgnoreCase);
private static IReadOnlyList<DriveFsMediaRow> ReadMedia(string databasePath)
{
using var connection = OpenReadOnly(databasePath);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT name, last_mount_point, fs_type FROM media;";
using var reader = cmd.ExecuteReader();
var rows = new List<DriveFsMediaRow>();
while (reader.Read())
{
rows.Add(new DriveFsMediaRow(
reader.IsDBNull(0) ? "" : reader.GetString(0),
reader.IsDBNull(1) ? "" : reader.GetString(1),
reader.IsDBNull(2) ? 0 : reader.GetInt32(2)));
}
return rows;
}
private static IReadOnlyList<DriveFsRootRow> ReadRoots(string databasePath)
{
using var connection = OpenReadOnly(databasePath);
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT title, root_path, last_seen_absolute_path, is_my_drive FROM roots;";
using var reader = cmd.ExecuteReader();
var rows = new List<DriveFsRootRow>();
while (reader.Read())
{
rows.Add(new DriveFsRootRow(
reader.IsDBNull(0) ? "" : reader.GetString(0),
reader.IsDBNull(1) ? "" : reader.GetString(1),
reader.IsDBNull(2) ? "" : reader.GetString(2),
!reader.IsDBNull(3) && reader.GetBoolean(3)));
}
return rows;
}
private static SqliteConnection OpenReadOnly(string databasePath)
{
var builder = new SqliteConnectionStringBuilder
{
DataSource = databasePath,
Mode = SqliteOpenMode.ReadOnly,
Cache = SqliteCacheMode.Shared,
Pooling = false
};
var connection = new SqliteConnection(builder.ConnectionString);
connection.Open();
return connection;
}
private static string? FirstNonEmpty(params string?[] values)
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Plugin.GoogleDrive</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,118 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.GoogleDrive;
public static class GoogleDrivePlaceDiscovery
{
public const string Glyph = "\uE753";
public static IReadOnlyList<ProviderPlace> Discover()
{
var found = new List<ProviderPlace>();
void Add(string providerPath, string label)
{
if (string.IsNullOrWhiteSpace(providerPath))
{
return;
}
var trimmed = CloudPath.NormalizePlace(providerPath);
if (trimmed.Length == 0
|| found.Exists(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase)))
{
return;
}
found.Add(new ProviderPlace(GoogleDriveStorageProvider.ProviderId, label, trimmed, Glyph));
}
foreach (var place in DriveFsPreferenceReader.ReadPlaces())
{
Add(place.Path, place.DisplayName);
}
foreach (var root in WindowsSyncRootDiscovery.Matching("GoogleDrive", "DriveFS", "Google Drive", "Google.Drive"))
{
Add(root.Path, BuildLabel(root.Path, root.DisplayName));
}
foreach (var drive in DriveInfo.GetDrives().Where(d => d.IsReady))
{
try
{
var letter = CloudPath.NormalizePlace(drive.RootDirectory.FullName);
if (found.Exists(p => CloudPath.IsUnder(p.Path, letter) || CloudPath.IsUnder(letter, p.Path)))
{
continue;
}
if (drive.VolumeLabel.Equals("Google Drive", StringComparison.OrdinalIgnoreCase))
{
Add(letter, "Google Drive");
continue;
}
var myDrive = Path.Combine(drive.RootDirectory.FullName, "My Drive");
var shared = Path.Combine(drive.RootDirectory.FullName, "Shared drives");
if (Directory.Exists(myDrive))
{
Add(myDrive, "Google Drive");
}
if (Directory.Exists(shared))
{
Add(shared, "Shared drives");
}
}
catch
{
// skip inaccessible volumes
}
}
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
AddIfExists(Path.Combine(profile, "Google Drive"), "Google Drive");
AddIfExists(Path.Combine(profile, "My Drive"), "Google Drive");
return found;
void AddIfExists(string providerPath, string label)
{
try
{
if (Directory.Exists(providerPath))
{
Add(providerPath, label);
}
}
catch
{
// guessed roots are optional
}
}
}
public static string BuildLabel(string path, string? displayName)
{
var folder = Path.GetFileName(path.TrimEnd('\\'));
if (folder.Equals("My Drive", StringComparison.OrdinalIgnoreCase)
|| folder.Equals("Google Drive", StringComparison.OrdinalIgnoreCase))
{
return "Google Drive";
}
if (folder.Equals("Shared drives", StringComparison.OrdinalIgnoreCase)
|| folder.Equals("Shared Drives", StringComparison.OrdinalIgnoreCase))
{
return "Shared drives";
}
if (!string.IsNullOrWhiteSpace(displayName)
&& displayName.Contains("Google", StringComparison.OrdinalIgnoreCase))
{
return displayName;
}
return string.IsNullOrWhiteSpace(folder) ? "Google Drive" : $"Google Drive - {folder}";
}
}

View File

@@ -0,0 +1,101 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.GoogleDrive;
public static class GoogleDrivePlaceholderState
{
public const int Offline = 0x1000;
public const int RecallOnOpen = 0x40000;
public const int Pinned = 0x80000;
public const int Unpinned = 0x100000;
public const int RecallOnDataAccess = 0x400000;
public static ProviderItemState FromLocalSignals(
string providerId,
string path,
int attributes,
int reparseTag,
long logicalSize,
long? allocatedSize,
uint? placeholderState)
{
var availability = MapAvailability(attributes, placeholderState);
var hydrateOnRead = availability == CloudAvailability.OnlineOnly
|| (attributes & (RecallOnDataAccess | Offline)) != 0
|| (placeholderState is uint cf && (cf & CloudFilesNative.PlaceholderPartial) != 0
&& (attributes & RecallOnDataAccess) != 0);
var hydrateOnOpen = hydrateOnRead || (attributes & RecallOnOpen) != 0;
return new ProviderItemState(
providerId,
path,
availability,
logicalSize,
allocatedSize,
hydrateOnOpen,
hydrateOnRead,
StatusText(availability),
availability == CloudAvailability.Error ? "Cloud placeholder reported an error" : null);
}
public static CloudAvailability MapAvailability(int attributes, uint? placeholderState)
{
if (placeholderState == CloudFilesNative.PlaceholderInvalid)
{
return CloudAvailability.Error;
}
if ((attributes & Pinned) != 0 && (attributes & Unpinned) == 0)
{
return CloudAvailability.Pinned;
}
if ((attributes & RecallOnDataAccess) != 0 || (attributes & Offline) != 0)
{
return CloudAvailability.OnlineOnly;
}
if (placeholderState is uint cf)
{
if ((cf & CloudFilesNative.PlaceholderPartial) != 0
&& (cf & CloudFilesNative.PlaceholderInSync) == 0)
{
return CloudAvailability.Syncing;
}
if ((cf & CloudFilesNative.Placeholder) != 0
&& (cf & CloudFilesNative.PlaceholderPartial) != 0)
{
return CloudAvailability.OnlineOnly;
}
}
if ((attributes & Unpinned) != 0 && (attributes & RecallOnDataAccess) == 0)
{
return CloudAvailability.LocallyAvailable;
}
if (placeholderState is uint known && (known & CloudFilesNative.Placeholder) != 0)
{
return CloudAvailability.LocallyAvailable;
}
if ((attributes & (Pinned | Unpinned | RecallOnDataAccess | Offline | RecallOnOpen)) != 0)
{
return CloudAvailability.LocallyAvailable;
}
return CloudAvailability.Unknown;
}
public static string StatusText(CloudAvailability availability) => availability switch
{
CloudAvailability.OnlineOnly => "Online-only",
CloudAvailability.LocallyAvailable => "Available on this device",
CloudAvailability.Pinned => "Always available on this device",
CloudAvailability.Syncing => "Syncing",
CloudAvailability.Error => "Sync error",
_ => ""
};
}

View File

@@ -0,0 +1,148 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.GoogleDrive;
public sealed class GoogleDriveStorageProvider : IStorageProvider
{
public const string ProviderId = "googledrive";
private readonly object _gate = new();
private IReadOnlyList<ProviderPlace>? _places;
public ProviderManifest Manifest { get; } = new(
ProviderId,
"Google Drive",
"1.0.0",
ProviderIsolation.InProcess);
public ProviderCapability GetCapabilities()
{
var caps = ProviderCapability.CloudState;
if (GetPlaces().Any(p => CloudFilesNative.IsCloudSyncRoot(p.Path)))
{
caps |= ProviderCapability.Pin | ProviderCapability.Dehydrate;
}
return caps;
}
public bool TryMatchRoot(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
foreach (var root in GetPlaces())
{
if (CloudPath.IsUnder(root.Path, path))
{
return true;
}
}
return LooksLikeGoogleDrive(path) && CloudFilesNative.IsCloudSyncRoot(path);
}
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
{
var results = new List<ProviderItemState>(paths.Count);
foreach (var path in paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryMatchRoot(path))
{
continue;
}
try
{
var attrs = (int)File.GetAttributes(path);
var isDir = (attrs & (int)FileAttributes.Directory) != 0;
long logical = 0;
if (!isDir)
{
try { logical = new FileInfo(path).Length; } catch { /* locked */ }
}
var allocated = CloudFilesNative.TryGetAllocatedSize(path);
var placeholder = CloudFilesNative.TryGetPlaceholderState(attrs, 0);
results.Add(GoogleDrivePlaceholderState.FromLocalSignals(
ProviderId, path, attrs, 0, logical, allocated, placeholder));
}
catch
{
// fail-open for this item
}
}
return Task.FromResult<IReadOnlyList<ProviderItemState>>(results);
}
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
{
var capabilities = GetCapabilities();
var needed = request.Action switch
{
ProviderAction.Pin or ProviderAction.Unpin => ProviderCapability.Pin,
ProviderAction.Dehydrate => ProviderCapability.Dehydrate,
_ => ProviderCapability.None
};
if (needed == ProviderCapability.None || (capabilities & needed) == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "This action is not available."));
}
var pin = request.Action switch
{
ProviderAction.Pin => CloudFilesNative.PinPinned,
ProviderAction.Unpin or ProviderAction.Dehydrate => CloudFilesNative.PinUnpinned,
_ => CloudFilesNative.PinUnspecified
};
var failed = 0;
foreach (var path in request.Paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!CloudFilesNative.TrySetPinState(path, pin))
{
failed++;
}
}
if (failed == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Succeeded));
}
if (failed == request.Paths.Count)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, "Google Drive could not change the pin state."));
}
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, $"Google Drive could not change the pin state for {failed} item(s)."));
}
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderQuota?>(null);
public IReadOnlyList<ProviderPlace> GetPlaces()
{
lock (_gate)
{
return _places ??= GoogleDrivePlaceDiscovery.Discover();
}
}
public static bool LooksLikeGoogleDrive(string path)
{
var n = path.Replace('/', '\\');
return n.Contains(@"\Google Drive", StringComparison.OrdinalIgnoreCase)
|| n.Contains(@"\My Drive", StringComparison.OrdinalIgnoreCase)
|| n.Contains(@"\Shared drives", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>Explorer.Plugin.Nextcloud</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,198 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.Nextcloud;
public static class NextcloudPlaceDiscovery
{
public const string Glyph = "\uE753";
public static IReadOnlyList<ProviderPlace> Discover()
{
var found = new List<ProviderPlace>();
void Add(string providerPath, string label)
{
if (string.IsNullOrWhiteSpace(providerPath))
{
return;
}
var trimmed = NormalizePath(providerPath);
if (trimmed.Length == 0
|| found.Exists(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase)))
{
return;
}
found.Add(new ProviderPlace(NextcloudStorageProvider.ProviderId, label, trimmed, Glyph));
}
foreach (var file in ConfigFiles())
{
try
{
if (!File.Exists(file))
{
continue;
}
foreach (var place in ParseCfg(File.ReadAllText(file)))
{
Add(place.Path, place.DisplayName);
}
}
catch
{
// config is optional
}
}
foreach (var root in WindowsSyncRootDiscovery.Matching("Nextcloud", "ownCloud"))
{
Add(root.Path, BuildLabel(root.Path, null, root.DisplayName));
}
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
AddIfExists(Path.Combine(profile, "Nextcloud"), "Nextcloud");
AddIfExists(Path.Combine(profile, "ownCloud"), "ownCloud");
return found;
void AddIfExists(string providerPath, string label)
{
try
{
if (Directory.Exists(providerPath))
{
Add(providerPath, label);
}
}
catch
{
// guessed roots are optional
}
}
}
public static IReadOnlyList<ProviderPlace> ParseCfg(string text)
{
var values = ParseIni(text);
var places = new List<ProviderPlace>();
var accounts = values.Keys
.Select(ParseAccountFolder)
.Where(t => t is not null)
.Select(t => t!.Value)
.Distinct()
.ToList();
foreach (var (account, folder) in accounts)
{
var prefix = $"{account}\\Folders\\{folder}\\";
if (!values.TryGetValue(prefix + "localPath", out var local) || string.IsNullOrWhiteSpace(local))
{
continue;
}
values.TryGetValue($"{account}\\displayName", out var user);
values.TryGetValue(prefix + "targetPath", out var target);
var path = NormalizePath(local);
places.Add(new ProviderPlace(
NextcloudStorageProvider.ProviderId,
BuildLabel(path, target, user),
path,
Glyph));
}
return places;
}
public static string BuildLabel(string localPath, string? targetPath, string? displayName)
{
var targetName = LastSegment(targetPath);
if (!string.IsNullOrWhiteSpace(targetName)
&& !targetName.Equals("/", StringComparison.Ordinal))
{
return $"Nextcloud - {targetName}";
}
var folder = Path.GetFileName(localPath.TrimEnd('\\'));
if (!string.IsNullOrWhiteSpace(folder)
&& !folder.Equals("Nextcloud", StringComparison.OrdinalIgnoreCase)
&& !folder.Equals("ownCloud", StringComparison.OrdinalIgnoreCase))
{
return $"Nextcloud - {folder}";
}
if (!string.IsNullOrWhiteSpace(displayName))
{
return $"Nextcloud - {displayName}";
}
return "Nextcloud";
}
private static IEnumerable<string> ConfigFiles()
{
var roaming = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(roaming, "Nextcloud", "nextcloud.cfg");
yield return Path.Combine(local, "Nextcloud", "nextcloud.cfg");
yield return Path.Combine(roaming, "ownCloud", "owncloud.cfg");
yield return Path.Combine(local, "ownCloud", "owncloud.cfg");
}
private static Dictionary<string, string> ParseIni(string text)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith('[') || line.StartsWith('#') || line.StartsWith(';'))
{
continue;
}
var eq = line.IndexOf('=');
if (eq <= 0)
{
continue;
}
values[line[..eq].Trim()] = line[(eq + 1)..].Trim();
}
return values;
}
private static (string Account, string Folder)? ParseAccountFolder(string key)
{
// 0\Folders\1\localPath
var parts = key.Split('\\');
if (parts.Length >= 4
&& parts[1].Equals("Folders", StringComparison.OrdinalIgnoreCase))
{
return (parts[0], parts[2]);
}
return null;
}
private static string NormalizePath(string path)
=> path.Replace('/', '\\').Trim().TrimEnd('\\');
private static string? LastSegment(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
var n = path.Replace('\\', '/').Trim().Trim('/');
if (n.Length == 0)
{
return null;
}
var idx = n.LastIndexOf('/');
return idx < 0 ? n : n[(idx + 1)..];
}
}

View File

@@ -0,0 +1,101 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.Nextcloud;
public static class NextcloudPlaceholderState
{
public const int Offline = 0x1000;
public const int RecallOnOpen = 0x40000;
public const int Pinned = 0x80000;
public const int Unpinned = 0x100000;
public const int RecallOnDataAccess = 0x400000;
public static ProviderItemState FromLocalSignals(
string providerId,
string path,
int attributes,
int reparseTag,
long logicalSize,
long? allocatedSize,
uint? placeholderState)
{
var availability = MapAvailability(attributes, placeholderState);
var hydrateOnRead = availability == CloudAvailability.OnlineOnly
|| (attributes & (RecallOnDataAccess | Offline)) != 0
|| (placeholderState is uint cf && (cf & CloudFilesNative.PlaceholderPartial) != 0
&& (attributes & RecallOnDataAccess) != 0);
var hydrateOnOpen = hydrateOnRead || (attributes & RecallOnOpen) != 0;
return new ProviderItemState(
providerId,
path,
availability,
logicalSize,
allocatedSize,
hydrateOnOpen,
hydrateOnRead,
StatusText(availability),
availability == CloudAvailability.Error ? "Cloud placeholder reported an error" : null);
}
public static CloudAvailability MapAvailability(int attributes, uint? placeholderState)
{
if (placeholderState == CloudFilesNative.PlaceholderInvalid)
{
return CloudAvailability.Error;
}
if ((attributes & Pinned) != 0 && (attributes & Unpinned) == 0)
{
return CloudAvailability.Pinned;
}
if ((attributes & RecallOnDataAccess) != 0 || (attributes & Offline) != 0)
{
return CloudAvailability.OnlineOnly;
}
if (placeholderState is uint cf)
{
if ((cf & CloudFilesNative.PlaceholderPartial) != 0
&& (cf & CloudFilesNative.PlaceholderInSync) == 0)
{
return CloudAvailability.Syncing;
}
if ((cf & CloudFilesNative.Placeholder) != 0
&& (cf & CloudFilesNative.PlaceholderPartial) != 0)
{
return CloudAvailability.OnlineOnly;
}
}
if ((attributes & Unpinned) != 0 && (attributes & RecallOnDataAccess) == 0)
{
return CloudAvailability.LocallyAvailable;
}
if (placeholderState is uint known && (known & CloudFilesNative.Placeholder) != 0)
{
return CloudAvailability.LocallyAvailable;
}
if ((attributes & (Pinned | Unpinned | RecallOnDataAccess | Offline | RecallOnOpen)) != 0)
{
return CloudAvailability.LocallyAvailable;
}
return CloudAvailability.Unknown;
}
public static string StatusText(CloudAvailability availability) => availability switch
{
CloudAvailability.OnlineOnly => "Online-only",
CloudAvailability.LocallyAvailable => "Available on this device",
CloudAvailability.Pinned => "Always available on this device",
CloudAvailability.Syncing => "Syncing",
CloudAvailability.Error => "Sync error",
_ => ""
};
}

View File

@@ -0,0 +1,140 @@
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.Nextcloud;
public sealed class NextcloudStorageProvider : IStorageProvider
{
public const string ProviderId = "nextcloud";
private readonly object _gate = new();
private IReadOnlyList<ProviderPlace>? _places;
public ProviderManifest Manifest { get; } = new(
ProviderId,
"Nextcloud",
"1.0.0",
ProviderIsolation.InProcess);
public ProviderCapability GetCapabilities()
{
var caps = ProviderCapability.CloudState;
if (GetPlaces().Any(p => CloudFilesNative.IsCloudSyncRoot(p.Path)))
{
caps |= ProviderCapability.Pin | ProviderCapability.Dehydrate;
}
return caps;
}
public bool TryMatchRoot(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
foreach (var root in GetPlaces())
{
if (CloudPath.IsUnder(root.Path, path))
{
return true;
}
}
return false;
}
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
{
var results = new List<ProviderItemState>(paths.Count);
foreach (var path in paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryMatchRoot(path))
{
continue;
}
try
{
var attrs = (int)File.GetAttributes(path);
var isDir = (attrs & (int)FileAttributes.Directory) != 0;
long logical = 0;
if (!isDir)
{
try { logical = new FileInfo(path).Length; } catch { /* locked */ }
}
var allocated = CloudFilesNative.TryGetAllocatedSize(path);
var placeholder = CloudFilesNative.TryGetPlaceholderState(attrs, 0);
results.Add(NextcloudPlaceholderState.FromLocalSignals(
ProviderId, path, attrs, 0, logical, allocated, placeholder));
}
catch
{
// fail-open for this item
}
}
return Task.FromResult<IReadOnlyList<ProviderItemState>>(results);
}
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
{
var capabilities = GetCapabilities();
var needed = request.Action switch
{
ProviderAction.Pin or ProviderAction.Unpin => ProviderCapability.Pin,
ProviderAction.Dehydrate => ProviderCapability.Dehydrate,
_ => ProviderCapability.None
};
if (needed == ProviderCapability.None || (capabilities & needed) == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "This action is not available."));
}
var pin = request.Action switch
{
ProviderAction.Pin => CloudFilesNative.PinPinned,
ProviderAction.Unpin or ProviderAction.Dehydrate => CloudFilesNative.PinUnpinned,
_ => CloudFilesNative.PinUnspecified
};
var failed = 0;
foreach (var path in request.Paths)
{
cancellationToken.ThrowIfCancellationRequested();
if (!CloudFilesNative.TrySetPinState(path, pin))
{
failed++;
}
}
if (failed == 0)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Succeeded));
}
if (failed == request.Paths.Count)
{
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, "Nextcloud could not change the pin state."));
}
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, $"Nextcloud could not change the pin state for {failed} item(s)."));
}
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderQuota?>(null);
public IReadOnlyList<ProviderPlace> GetPlaces()
{
lock (_gate)
{
return _places ??= NextcloudPlaceDiscovery.Discover();
}
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.Win32;
using Explorer.Plugin.Abstractions;
using Explorer.Windows;
namespace Explorer.Plugin.OneDrive;
@@ -22,7 +23,7 @@ public static class OneDrivePlaceDiscovery
return;
}
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed));
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed, "\uE753"));
}
try
@@ -57,6 +58,12 @@ public static class OneDrivePlaceDiscovery
// registry is optional
}
foreach (var root in WindowsSyncRootDiscovery.Matching("OneDrive"))
{
var display = root.DisplayName is { } d && !d.StartsWith('@') ? d : null;
Add(root.Path, BuildLabel("", root.Path, display, null));
}
Add(Environment.GetEnvironmentVariable("OneDriveConsumer") ?? "", "OneDrive");
Add(Environment.GetEnvironmentVariable("OneDrive") ?? "", "OneDrive");
Add(Environment.GetEnvironmentVariable("OneDriveCommercial") ?? "", "OneDrive - Work");

View File

@@ -28,13 +28,13 @@ public sealed class OneDriveStorageProvider : IStorageProvider
foreach (var root in GetPlaces())
{
if (IsUnder(root.Path, path))
if (CloudPath.IsUnder(root.Path, path))
{
return true;
}
}
return CloudFilesNative.IsCloudSyncRoot(path);
return LooksLikeOneDrive(path) && CloudFilesNative.IsCloudSyncRoot(path);
}
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
@@ -130,11 +130,10 @@ public sealed class OneDriveStorageProvider : IStorageProvider
}
}
private static bool IsUnder(string root, string path)
public static bool LooksLikeOneDrive(string path)
{
var a = root.TrimEnd('\\');
var b = path.TrimEnd('\\');
return b.Equals(a, StringComparison.OrdinalIgnoreCase)
|| b.StartsWith(a + "\\", StringComparison.OrdinalIgnoreCase);
var n = path.Replace('/', '\\');
return n.Contains(@"\OneDrive", StringComparison.OrdinalIgnoreCase)
|| n.Contains("OneDrive - ", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -48,7 +48,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
public bool CanGoBack => _history.CanGoBack;
public bool CanGoForward => _history.CanGoForward;
public bool CanGoUp => CurrentPath is not "This PC";
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath) || CurrentPath is LocationRoots.Network or LocationRoots.Cloud;
public async Task NavigateAsync(string path, bool addHistory = true)
{
@@ -69,9 +69,9 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
OnPropertyChanged(nameof(CanGoUp));
OnPropertyChanged(nameof(Breadcrumb));
if (path == "This PC")
if (LocationRoots.IsVirtual(path))
{
await LoadThisPcAsync(ct).ConfigureAwait(true);
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
return;
}
@@ -108,7 +108,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
}
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src)
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
@@ -124,13 +125,18 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
}
}
private async Task LoadThisPcAsync(CancellationToken cancellationToken)
private async Task LoadVirtualRootAsync(string path, CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
var listing = await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true);
var listing = path switch
{
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
};
foreach (var item in listing.Items)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
@@ -158,12 +164,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[RelayCommand]
public Task UpAsync()
{
if (CurrentPath == "This PC")
if (CurrentPath == LocationRoots.ThisPc)
{
return Task.CompletedTask;
}
if (PathRules.IsDriveRoot(CurrentPath)
if (LocationRoots.IsVirtual(CurrentPath)
|| PathRules.IsDriveRoot(CurrentPath)
|| (PathRules.IsUnc(CurrentPath)
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
{
@@ -178,7 +185,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public Task OpenItemAsync(FolderItemViewModel item)
{
if (item.IsDirectory)
if (item.IsDirectory || _browse.CanBrowseArchive(item.Item.Name))
{
return NavigateAsync(item.FullPath);
}
@@ -202,7 +209,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
public void RescanFolder()
{
if (CurrentSource is null || CurrentPath == "This PC")
if (CurrentSource is null || LocationRoots.IsVirtual(CurrentPath))
{
return;
}
@@ -216,12 +223,21 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
{
if (path == "This PC")
if (path == LocationRoots.ThisPc)
{
return [new BreadcrumbSegment("This PC", "This PC", IsLast: true)];
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
}
var parts = new List<BreadcrumbSegment> { new("This PC", "This PC") };
if (path is LocationRoots.Network or LocationRoots.Cloud)
{
return
[
new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc),
new BreadcrumbSegment(path, path, IsLast: true)
];
}
var parts = new List<BreadcrumbSegment> { new(LocationRoots.ThisPc, LocationRoots.ThisPc) };
var p = PathRules.FromExtended(path);
if (PathRules.IsUnc(p))
{

View File

@@ -1,5 +1,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
using Explorer.Indexing;
@@ -33,7 +34,9 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
{
if (e.PropertyName == nameof(ExplorerPaneViewModel.CurrentPath))
{
Title = Left.CurrentPath == "This PC" ? "This PC" : Path.GetFileName(Left.CurrentPath.TrimEnd('\\'));
Title = LocationRoots.IsVirtual(Left.CurrentPath)
? Left.CurrentPath
: Path.GetFileName(Left.CurrentPath.TrimEnd('\\'));
if (string.IsNullOrEmpty(Title))
{
Title = Left.CurrentPath;

View File

@@ -21,6 +21,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly PathHistoryStore _pathHistory;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -31,6 +32,7 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private string? _promptUnc;
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
[ObservableProperty] private bool _showForgetSource;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
@@ -47,7 +49,8 @@ public sealed partial class MainViewModel : ObservableObject
IOsClipboard clipboard,
PathHistoryStore pathHistory,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{
_browse = browse;
_ops = ops;
@@ -56,8 +59,11 @@ public sealed partial class MainViewModel : ObservableObject
_pathHistory = pathHistory;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
@@ -252,7 +258,7 @@ public sealed partial class MainViewModel : ObservableObject
_clipboardIsCut = cut;
}
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
if (_clipboard.Count == 0 || LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
{
return;
}
@@ -279,7 +285,7 @@ public sealed partial class MainViewModel : ObservableObject
public void CopyPath()
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
if (paths.Count == 0 && !LocationRoots.IsVirtual(ActivePane.CurrentPath))
{
paths = [ActivePane.CurrentPath];
}
@@ -290,7 +296,7 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public void NewFolder()
{
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
if (LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
{
return;
}
@@ -317,12 +323,12 @@ public sealed partial class MainViewModel : ObservableObject
public async Task BuildIndexAsync()
{
var path = ActivePane.CurrentPath;
if (path == "This PC")
if (LocationRoots.IsVirtual(path))
{
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
}
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
Footer = "Select a drive or folder to index.";
return;
@@ -351,6 +357,59 @@ public sealed partial class MainViewModel : ObservableObject
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
_ = RefreshForgetActionAsync();
}
public async Task RefreshForgetActionAsync()
{
ShowForgetSource = ActivePane.CurrentPath is LocationRoots.ThisPc or LocationRoots.Network
&& ActivePane.SelectedItems.Count == 1
&& await _sources.CanForgetPathAsync(ActivePane.SelectedItems[0].FullPath).ConfigureAwait(true);
}
public async Task<bool> ForgetSourceAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return false;
}
var name = Path.GetFileName(path.TrimEnd('\\'));
if (string.IsNullOrWhiteSpace(name))
{
name = path;
}
var removed = await _sources.ForgetDisconnectedAsync(path).ConfigureAwait(true);
if (!removed)
{
Footer = "This location is still connected in Windows, so Explorer keeps it.";
return false;
}
foreach (var tab in Tabs.ToList())
{
foreach (var pane in new[] { tab.Left, tab.Right })
{
if (pane.CurrentPath != "This PC"
&& (NavigationTreeViewModel.PathsEqual(pane.CurrentPath, path)
|| CloudPath.IsUnder(path, pane.CurrentPath)))
{
await pane.NavigateAsync("This PC").ConfigureAwait(true);
}
}
}
await Tree.ReloadAsync("This PC").ConfigureAwait(true);
if (ActivePane.CurrentPath == "This PC")
{
await ActivePane.RefreshAsync().ConfigureAwait(true);
}
PathText = ActivePane.CurrentPath;
Footer = $"Removed {name} and its index data.";
ShowForgetSource = false;
return true;
}
[RelayCommand]
@@ -365,7 +424,7 @@ public sealed partial class MainViewModel : ObservableObject
private async Task InvokeCloudAsync(ProviderAction action)
{
var paths = SelectedPaths();
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
if (paths.Count == 0 && !LocationRoots.IsVirtual(ActivePane.CurrentPath))
{
paths = [ActivePane.CurrentPath];
}
@@ -483,20 +542,51 @@ public sealed partial class MainViewModel : ObservableObject
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
}
public async Task AddCloudFolderAsync(string path, string? displayName = null)
public async Task AddCloudFolderAsync(string path, string? providerId = null, string? displayName = null)
{
if (string.IsNullOrWhiteSpace(path))
{
return;
}
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
Footer = "Added OneDrive folder to the navigation tree.";
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
var trimmed = path.Trim().TrimEnd('\\');
var id = providerId
?? _providers.Find(trimmed)?.Manifest.Id
?? GuessCloudProvider(trimmed);
var name = string.IsNullOrWhiteSpace(displayName) ? CloudProviderLabel(id) : displayName;
_cloudPlaces.Add(id, trimmed, name);
Footer = $"Added {name} to the navigation tree.";
await Tree.ReloadAsync(trimmed).ConfigureAwait(true);
await ActivePane.NavigateAsync(trimmed).ConfigureAwait(true);
}
private const string OneDriveProviderId = "onedrive";
public const string OneDriveProviderId = "onedrive";
public const string GoogleDriveProviderId = "googledrive";
public const string NextcloudProviderId = "nextcloud";
private static string GuessCloudProvider(string path)
{
if (path.Contains("Google Drive", StringComparison.OrdinalIgnoreCase)
|| path.Contains(@"\My Drive", StringComparison.OrdinalIgnoreCase))
{
return GoogleDriveProviderId;
}
if (path.Contains("Nextcloud", StringComparison.OrdinalIgnoreCase)
|| path.Contains("ownCloud", StringComparison.OrdinalIgnoreCase))
{
return NextcloudProviderId;
}
return OneDriveProviderId;
}
private static string CloudProviderLabel(string providerId) => providerId switch
{
GoogleDriveProviderId => "Google Drive",
NextcloudProviderId => "Nextcloud",
_ => "OneDrive"
};
[RelayCommand]
public Task SearchAsync()
@@ -513,6 +603,41 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
public UiPreferences CurrentPreferences()
{
var stored = _preferences.Load();
return stored with { Theme = UiPreferencesStore.NormalizeTheme(Theme) };
}
public async Task ApplyPreferencesAsync(UiPreferences preferences)
{
var normalized = preferences with { Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme) };
_preferences.Save(normalized);
Theme = normalized.Theme;
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
foreach (var tab in Tabs)
{
foreach (var pane in new[] { tab.Left, tab.Right })
{
if (LocationRoots.IsVirtual(pane.CurrentPath))
{
await pane.RefreshAsync().ConfigureAwait(true);
}
}
}
Footer = "Settings saved.";
}
partial void OnThemeChanged(string value)
{
var stored = _preferences.Load();
if (!stored.Theme.Equals(value, StringComparison.OrdinalIgnoreCase))
{
_preferences.Save(stored with { Theme = UiPreferencesStore.NormalizeTheme(value) });
}
}
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
{
if (files.Count == 0)

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Presentation.ViewModels;
@@ -18,6 +19,9 @@ public sealed partial class NavNodeViewModel : ObservableObject
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
}
public sealed class NavigationTreeViewModel
@@ -26,17 +30,20 @@ public sealed class NavigationTreeViewModel
private readonly BrowseService _browse;
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
public NavigationTreeViewModel(
SourceManager sources,
BrowseService browse,
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces)
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences)
{
_sources = sources;
_browse = browse;
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
Roots = [];
}
@@ -55,47 +62,80 @@ public sealed class NavigationTreeViewModel
try
{
Roots.Clear();
var thisPc = new NavNodeViewModel { Label = "This PC", Path = "This PC", Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true };
var prefs = _preferences.Load();
var thisPc = new NavNodeViewModel
{
Label = LocationRoots.ThisPc,
Path = LocationRoots.ThisPc,
Glyph = "\uE977",
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
};
Roots.Add(thisPc);
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
foreach (var source in sources)
foreach (var source in sources.Where(s => !s.Kind.IsNetwork()))
{
var node = new NavNodeViewModel
{
Label = source.DisplayName,
Path = source.LastRootPath ?? source.DisplayName,
Status = source.Status switch
{
SourceStatus.Offline => source.LastSeenUtc is null
? "Offline"
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "May be out of date",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? "Indexed" : ""
},
IsOffline = source.Status == SourceStatus.Offline,
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind == SourceKind.Smb ? "\uE968" : "\uEDA2"
};
AddPlaceholder(node);
thisPc.Children.Add(node);
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
var network = sources.Where(s => s.Kind.IsNetwork()).ToList();
if (prefs.GroupNetworkPlaces && network.Count > 0)
{
var exists = Directory.Exists(place.Path);
var node = new NavNodeViewModel
var group = new NavNodeViewModel
{
Label = place.DisplayName,
Path = place.Path,
Glyph = "\uE753",
Status = exists ? "" : "Offline",
IsOffline = !exists
Label = LocationRoots.Network,
Path = LocationRoots.Network,
Glyph = "\uE968",
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
};
AddPlaceholder(node);
Roots.Add(node);
foreach (var source in network)
{
group.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
Roots.Add(group);
}
else
{
foreach (var source in network)
{
Roots.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
}
var places = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => ProviderOrder(p.ProviderId))
.ThenBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(CreateCloudNode)
.ToList();
if (prefs.GroupCloudPlaces && places.Count > 0)
{
var group = new NavNodeViewModel
{
Label = LocationRoots.Cloud,
Path = LocationRoots.Cloud,
Glyph = "\uE753",
IsExpanded = true,
ChildrenLoaded = true,
IsGroup = true
};
foreach (var place in places)
{
group.Children.Add(place);
}
Roots.Add(group);
}
else
{
foreach (var place in places)
{
Roots.Add(place);
}
}
foreach (var path in expanded)
@@ -123,7 +163,7 @@ public sealed class NavigationTreeViewModel
public async Task EnsureChildrenAsync(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.ChildrenLoaded || node.Path == "This PC")
if (node.IsPlaceholder || node.ChildrenLoaded || node.IsGroup || LocationRoots.IsVirtual(node.Path))
{
return;
}
@@ -155,13 +195,13 @@ public sealed class NavigationTreeViewModel
IsRevealing = true;
try
{
if (PathsEqual(path, "This PC"))
if (LocationRoots.IsVirtual(path))
{
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
if (thisPc is not null)
var virtualRoot = Roots.FirstOrDefault(r => r.Path == path);
if (virtualRoot is not null)
{
thisPc.IsExpanded = true;
SelectOnly(thisPc);
virtualRoot.IsExpanded = true;
SelectOnly(virtualRoot);
}
return;
}
@@ -224,6 +264,61 @@ public sealed class NavigationTreeViewModel
StringComparison.OrdinalIgnoreCase);
}
private static NavNodeViewModel CreateCloudNode(ProviderPlace place)
{
var exists = Directory.Exists(place.Path);
var node = new NavNodeViewModel
{
Label = place.DisplayName,
Path = place.Path,
Glyph = place.Glyph ?? CloudGlyph(place.ProviderId),
Status = exists ? "" : "Offline",
IsOffline = !exists
};
AddPlaceholder(node);
return node;
}
private static NavNodeViewModel CreateSourceNode(Source source, bool canRemove)
{
var node = new NavNodeViewModel
{
Label = source.DisplayName,
Path = source.LastRootPath ?? source.DisplayName,
Status = source.Status switch
{
SourceStatus.Offline => source.LastSeenUtc is null
? "Offline"
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
SourceStatus.Scanning => "Indexing",
SourceStatus.Stale => "May be out of date",
SourceStatus.Error => "Error",
_ => source.IsIndexed ? "Indexed" : ""
},
IsOffline = source.Status == SourceStatus.Offline,
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind.IsNetwork() ? "\uE968" : "\uEDA2",
SourceId = source.Id,
CanRemove = canRemove
};
AddPlaceholder(node);
return node;
}
private static int ProviderOrder(string providerId) => providerId switch
{
"onedrive" => 0,
"googledrive" => 1,
"nextcloud" => 2,
_ => 9
};
private static string CloudGlyph(string providerId) => providerId switch
{
"googledrive" => "\uE753",
"nextcloud" => "\uE753",
_ => "\uE753"
};
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
{
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
@@ -232,7 +327,7 @@ public sealed class NavigationTreeViewModel
void Consider(NavNodeViewModel node)
{
if (node.IsPlaceholder || node.Path == "This PC")
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
{
return;
}
@@ -251,11 +346,11 @@ public sealed class NavigationTreeViewModel
foreach (var root in roots)
{
if (root.Path == "This PC")
if (root.IsGroup || LocationRoots.IsVirtual(root.Path))
{
foreach (var drive in root.Children.Where(c => !c.IsPlaceholder))
foreach (var child in root.Children.Where(c => !c.IsPlaceholder))
{
Consider(drive);
Consider(child);
}
}
else

View File

@@ -176,6 +176,25 @@ internal sealed class EntryStore : IEntryStore
conn, "UPDATE entries SET status=0 WHERE source_id=@sourceId AND status=1",
new { sourceId }), cancellationToken);
public Task TombstoneByPathPrefixAsync(long sourceId, string pathRelPrefix, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn =>
{
if (string.IsNullOrEmpty(pathRelPrefix))
{
return Task.CompletedTask;
}
return SqliteExec.ExecuteAsync(conn, """
UPDATE entries SET status=2, deleted_utc=@utc
WHERE source_id=@sourceId AND status=0 AND path_rel LIKE @like ESCAPE '\'
""", new
{
sourceId,
utc = utc.ToString("O"),
like = EscapeLike(pathRelPrefix) + "\\\\%"
});
}, cancellationToken);
public Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{

View File

@@ -5,6 +5,7 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -111,6 +111,26 @@ internal sealed class SourceStore : ISourceStore
"UPDATE sources SET last_root_path=@rootPath, last_seen_utc=@utc WHERE id=@id",
new { id, rootPath, utc = utc.ToString("O") }), cancellationToken);
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
await SqliteExec.ExecuteAsync(conn, "PRAGMA defer_foreign_keys = ON").ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, """
DELETE FROM hash_queue
WHERE entry_id IN (SELECT id FROM entries WHERE source_id = @id)
""", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM entries WHERE source_id = @id", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM excludes WHERE source_id = @id", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, """
DELETE FROM scan_errors
WHERE job_id IN (SELECT id FROM scan_jobs WHERE source_id = @id)
""", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM scan_jobs WHERE source_id = @id", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM source_stats_history WHERE source_id = @id", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM directory_stats_history WHERE source_id = @id", new { id }).ConfigureAwait(false);
await SqliteExec.ExecuteAsync(conn, "DELETE FROM sources WHERE id = @id", new { id }).ConfigureAwait(false);
}, cancellationToken);
private static object ToArgs(Source s) => new
{
s.Id,

View File

@@ -0,0 +1,59 @@
using Microsoft.Win32;
namespace Explorer.Windows;
public sealed record WindowsSyncRoot(string Id, string Path, string? DisplayName);
public static class WindowsSyncRootDiscovery
{
public static IReadOnlyList<WindowsSyncRoot> Enumerate()
{
var found = new List<WindowsSyncRoot>();
foreach (var hive in new[] { Registry.CurrentUser, Registry.LocalMachine })
{
try
{
using var root = hive.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Explorer\SyncRootManager");
if (root is null)
{
continue;
}
foreach (var id in root.GetSubKeyNames())
{
using var key = root.OpenSubKey(id);
if (key is null)
{
continue;
}
var display = key.GetValue("DisplayNameResource") as string;
using var userRoots = key.OpenSubKey("UserSyncRoots");
if (userRoots is null)
{
continue;
}
foreach (var valueName in userRoots.GetValueNames())
{
if (userRoots.GetValue(valueName) is string path && !string.IsNullOrWhiteSpace(path))
{
found.Add(new WindowsSyncRoot(id, path.Trim().TrimEnd('\\'), display));
}
}
}
}
catch
{
// registry is optional
}
}
return found;
}
public static IEnumerable<WindowsSyncRoot> Matching(params string[] tokens)
=> Enumerate().Where(root => tokens.Any(token =>
root.Id.Contains(token, StringComparison.OrdinalIgnoreCase)
|| (root.DisplayName?.Contains(token, StringComparison.OrdinalIgnoreCase) ?? false)));
}