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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,6 +19,7 @@ TestResults/
|
|||||||
*.coverage
|
*.coverage
|
||||||
*.nupkg
|
*.nupkg
|
||||||
project.lock.json
|
project.lock.json
|
||||||
|
publish/
|
||||||
|
|
||||||
## IDE
|
## IDE
|
||||||
.idea/
|
.idea/
|
||||||
@@ -35,3 +36,4 @@ Desktop.ini
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" />
|
<Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" />
|
||||||
<Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" />
|
<Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" />
|
||||||
<Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" />
|
<Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<Project Path="src/Explorer.Plugin.GoogleDrive/Explorer.Plugin.GoogleDrive.csproj" />
|
||||||
|
<Project Path="src/Explorer.Plugin.Nextcloud/Explorer.Plugin.Nextcloud.csproj" />
|
||||||
<Project Path="src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj" />
|
<Project Path="src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj" />
|
||||||
<Project Path="src/Explorer.Presentation/Explorer.Presentation.csproj" />
|
<Project Path="src/Explorer.Presentation/Explorer.Presentation.csproj" />
|
||||||
<Project Path="src/Explorer.Search/Explorer.Search.csproj" />
|
<Project Path="src/Explorer.Search/Explorer.Search.csproj" />
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ Windows-NFS-Client (optional Feature) erscheint als Laufwerk oder Mount. Behande
|
|||||||
|
|
||||||
### 5.4 Cloud (Plugin-Overlay)
|
### 5.4 Cloud (Plugin-Overlay)
|
||||||
|
|
||||||
Explorer **implementiert keine Synchronisation**. Offizielle Clients (OneDrive, später Google Drive / Dropbox) bleiben zuständig für Sync, Hydration, Konflikte und Anmeldung.
|
Explorer **implementiert keine Synchronisation**. Offizielle Clients (OneDrive, Google Drive, Nextcloud; später Dropbox) bleiben zuständig für Sync, Hydration, Konflikte und Anmeldung.
|
||||||
|
|
||||||
Cloud-Ordner werden wie jedes andere gemountete Dateisystem über Win32 gelistet. `Explorer.Plugin.Abstractions` / `IStorageProvider` **reichern** Einträge an (Status, logische vs. allokierte Größe, Pin/Free-up), sie **ersetzen nicht** Enumeration, Watcher oder Source-Identität.
|
Cloud-Ordner werden wie jedes andere gemountete Dateisystem über Win32 gelistet. `Explorer.Plugin.Abstractions` / `IStorageProvider` **reichern** Einträge an (Status, logische vs. allokierte Größe, Pin/Free-up), sie **ersetzen nicht** Enumeration, Watcher oder Source-Identität.
|
||||||
|
|
||||||
@@ -763,7 +763,7 @@ Ziel-SLO (Richtwerte, kein Contract):
|
|||||||
- Historien-Charts (Schema ja)
|
- Historien-Charts (Schema ja)
|
||||||
- Pause/Resume Transfers, Konflikt-UI, Undo
|
- Pause/Resume Transfers, Konflikt-UI, Undo
|
||||||
- Icon-View, Treemap, Preview-Pane
|
- Icon-View, Treemap, Preview-Pane
|
||||||
- Weitere Cloud-Provider (Google Drive, Dropbox) und PluginHost/IPC
|
- Weitere Cloud-Provider (Dropbox) und PluginHost/IPC
|
||||||
- Explorer.exe ersetzen
|
- Explorer.exe ersetzen
|
||||||
- Volltext in Dateiinhalten
|
- Volltext in Dateiinhalten
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ public sealed class DuplicateHashWorker : BackgroundService
|
|||||||
var path = PathRules.Combine(item.RootPath, item.PathRel);
|
var path = PathRules.Combine(item.RootPath, item.PathRel);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await _store.Hashes.MarkSkippedAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (_hydration.WouldHydrateOnRead(item.Attributes, item.CloudAvailability)
|
if (_hydration.WouldHydrateOnRead(item.Attributes, item.CloudAvailability)
|
||||||
|| await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false))
|
|| await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -228,6 +228,15 @@
|
|||||||
</Trigger>
|
</Trigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</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">
|
<Style TargetType="TextBox">
|
||||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
@@ -360,6 +369,15 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
</Style>
|
</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">
|
<Style TargetType="ContextMenu">
|
||||||
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
@@ -398,7 +416,11 @@
|
|||||||
<Setter.Value>
|
<Setter.Value>
|
||||||
<ControlTemplate TargetType="MenuItem">
|
<ControlTemplate TargetType="MenuItem">
|
||||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
|
<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>
|
</Border>
|
||||||
<ControlTemplate.Triggers>
|
<ControlTemplate.Triggers>
|
||||||
<Trigger Property="IsHighlighted" Value="True">
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
@@ -411,6 +433,58 @@
|
|||||||
</ControlTemplate>
|
</ControlTemplate>
|
||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</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>
|
||||||
<Style TargetType="ProgressBar">
|
<Style TargetType="ProgressBar">
|
||||||
<Setter Property="Height" Value="4"/>
|
<Setter Property="Height" Value="4"/>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ using Explorer.Domain.Abstractions;
|
|||||||
using Explorer.FileOperations;
|
using Explorer.FileOperations;
|
||||||
using Explorer.Indexing;
|
using Explorer.Indexing;
|
||||||
using Explorer.Plugin.Abstractions;
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Plugin.GoogleDrive;
|
||||||
|
using Explorer.Plugin.Nextcloud;
|
||||||
using Explorer.Plugin.OneDrive;
|
using Explorer.Plugin.OneDrive;
|
||||||
using Explorer.Presentation.ViewModels;
|
using Explorer.Presentation.ViewModels;
|
||||||
using Explorer.Search;
|
using Explorer.Search;
|
||||||
@@ -34,11 +36,16 @@ public static class AppServices
|
|||||||
return new SqliteIndexStore(env.DatabasePath, logger);
|
return new SqliteIndexStore(env.DatabasePath, logger);
|
||||||
});
|
});
|
||||||
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
||||||
|
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
|
||||||
|
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
|
||||||
services.AddSingleton<StorageProviderRegistry>();
|
services.AddSingleton<StorageProviderRegistry>();
|
||||||
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
services.AddSingleton<SourceManager>();
|
services.AddSingleton<SourceManager>();
|
||||||
services.AddSingleton<PathHistoryStore>();
|
services.AddSingleton<PathHistoryStore>();
|
||||||
services.AddSingleton<CloudPlaceStore>();
|
services.AddSingleton<CloudPlaceStore>();
|
||||||
|
services.AddSingleton<UiPreferencesStore>();
|
||||||
|
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
|
||||||
|
services.AddSingleton<ArchiveContentsIndexer>();
|
||||||
services.AddSingleton<BrowseService>();
|
services.AddSingleton<BrowseService>();
|
||||||
services.AddSingleton<FilesystemScanner>();
|
services.AddSingleton<FilesystemScanner>();
|
||||||
services.AddSingleton<FolderReconciler>();
|
services.AddSingleton<FolderReconciler>();
|
||||||
|
|||||||
@@ -29,6 +29,8 @@
|
|||||||
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||||
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||||
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.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.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
|
||||||
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
|
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
|
||||||
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
|
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
|
||||||
|
|||||||
@@ -41,6 +41,32 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</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">
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<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 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 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="↻" 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 Style="{StaticResource ToolbarIconButton}" Content=""
|
||||||
<Button Content="Split" Command="{Binding SplitCommand}" Margin="0,0,4,0"/>
|
Command="{Binding NewTabCommand}" ToolTip="New tab (Ctrl+T)"/>
|
||||||
<Button Content="Index" Command="{Binding BuildIndexCommand}" Margin="0,0,4,0"/>
|
<Button Style="{StaticResource ToolbarIconButton}" Content=""
|
||||||
<Button Content="Details" Command="{Binding SetViewCommand}" CommandParameter="Details" Margin="8,0,4,0"/>
|
Command="{Binding SplitCommand}" ToolTip="Split pane"/>
|
||||||
<Button Content="List" Command="{Binding SetViewCommand}" CommandParameter="List" Margin="0,0,4,0"/>
|
<Button Style="{StaticResource ToolbarIconButton}" Content=""
|
||||||
<Button Content="Preview" Command="{Binding SetViewCommand}" CommandParameter="Preview"/>
|
Command="{Binding BuildIndexCommand}" ToolTip="Index this location"/>
|
||||||
|
<Border Width="1" Height="18" Margin="4,0,8,0" VerticalAlignment="Center" Background="{DynamicResource Stroke}"/>
|
||||||
|
<Button Content=""
|
||||||
|
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=""
|
||||||
|
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=""
|
||||||
|
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>
|
</StackPanel>
|
||||||
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
|
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
|
||||||
ItemsSource="{Binding PathHistory}"
|
ItemsSource="{Binding PathHistory}"
|
||||||
@@ -69,8 +132,7 @@
|
|||||||
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
||||||
KeyDown="OnSearchKeyDown"/>
|
KeyDown="OnSearchKeyDown"/>
|
||||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
|
||||||
<Button Content="Search" Command="{Binding SearchCommand}" Margin="0,0,4,0"/>
|
<Button Content="Search" Command="{Binding SearchCommand}"/>
|
||||||
<Button Content="Theme" Command="{Binding ToggleThemeCommand}"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -102,7 +164,9 @@
|
|||||||
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
|
<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="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
|
||||||
<Button Content="Add network" Click="OnAddNetwork" 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>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -118,7 +182,13 @@
|
|||||||
<TreeView x:Name="NavTree" ItemsSource="{Binding Tree.Roots}"
|
<TreeView x:Name="NavTree" ItemsSource="{Binding Tree.Roots}"
|
||||||
SelectedItemChanged="OnTreeSelected"
|
SelectedItemChanged="OnTreeSelected"
|
||||||
TreeViewItem.Expanded="OnTreeExpanded"
|
TreeViewItem.Expanded="OnTreeExpanded"
|
||||||
|
ContextMenuOpening="OnTreeContextOpening"
|
||||||
HorizontalContentAlignment="Stretch">
|
HorizontalContentAlignment="Stretch">
|
||||||
|
<TreeView.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem x:Name="RemoveLocationMenu" Header="Remove from Explorer" Click="OnRemoveLocation"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</TreeView.ContextMenu>
|
||||||
<TreeView.ItemTemplate>
|
<TreeView.ItemTemplate>
|
||||||
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
|
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
|
||||||
<StackPanel Orientation="Horizontal">
|
<StackPanel Orientation="Horizontal">
|
||||||
@@ -206,6 +276,9 @@
|
|||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, 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}}"/>
|
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
<MenuItem Header="Always keep on this device"
|
<MenuItem Header="Always keep on this device"
|
||||||
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Windows;
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Controls.Primitives;
|
using System.Windows.Controls.Primitives;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Presentation;
|
using Explorer.Presentation;
|
||||||
using Explorer.Presentation.ViewModels;
|
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)
|
private async void OnTreeExpanded(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.OriginalSource is TreeViewItem { DataContext: NavNodeViewModel node })
|
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)
|
private void OnAddNetwork(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
|
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)
|
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
|
var picker = new Microsoft.Win32.OpenFolderDialog
|
||||||
{
|
{
|
||||||
Title = "Add OneDrive folder",
|
Title = title,
|
||||||
Multiselect = false
|
Multiselect = false
|
||||||
};
|
};
|
||||||
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
|
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
|
||||||
@@ -464,7 +545,7 @@ public partial class MainWindow : Window
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Vm.AddCloudFolderAsync(picker.FolderName).ConfigureAwait(true);
|
await Vm.AddCloudFolderAsync(picker.FolderName, providerId).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
|||||||
49
src/Explorer.App/SettingsWindow.xaml
Normal file
49
src/Explorer.App/SettingsWindow.xaml
Normal 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 archive’s size on disk. Online-only cloud archives are skipped."/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
51
src/Explorer.App/SettingsWindow.xaml.cs
Normal file
51
src/Explorer.App/SettingsWindow.xaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,26 +10,68 @@ public sealed class BrowseService
|
|||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly SourceManager _sources;
|
private readonly SourceManager _sources;
|
||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
|
private readonly UiPreferencesStore _preferences;
|
||||||
|
|
||||||
public BrowseService(
|
public BrowseService(
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
IVolumeService volumes,
|
IVolumeService volumes,
|
||||||
IIndexStore store,
|
IIndexStore store,
|
||||||
SourceManager sources,
|
SourceManager sources,
|
||||||
StorageProviderRegistry providers)
|
StorageProviderRegistry providers,
|
||||||
|
CloudPlaceStore cloudPlaces,
|
||||||
|
UiPreferencesStore preferences)
|
||||||
{
|
{
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_volumes = volumes;
|
_volumes = volumes;
|
||||||
_store = store;
|
_store = store;
|
||||||
_sources = sources;
|
_sources = sources;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
|
_cloudPlaces = cloudPlaces;
|
||||||
|
_preferences = preferences;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
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 sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
var items = new List<FileSystemItem>();
|
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;
|
IndexEntry? root = null;
|
||||||
if (source.IsIndexed)
|
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)
|
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
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);
|
var reachable = _volumes.IsPathReachable(path);
|
||||||
|
|
||||||
if (reachable)
|
if (reachable)
|
||||||
@@ -106,6 +157,73 @@ public sealed class BrowseService
|
|||||||
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
|
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(
|
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
|
||||||
Source source,
|
Source source,
|
||||||
string path,
|
string path,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public sealed class CloudPlaceStore
|
|||||||
public IReadOnlyList<ProviderPlace> Add(string providerId, string path, string? displayName = null)
|
public IReadOnlyList<ProviderPlace> Add(string providerId, string path, string? displayName = null)
|
||||||
{
|
{
|
||||||
var places = Load().ToList();
|
var places = Load().ToList();
|
||||||
var trimmed = path.Trim().TrimEnd('\\');
|
var trimmed = CloudPath.NormalizePlace(path);
|
||||||
if (string.IsNullOrWhiteSpace(trimmed))
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
{
|
{
|
||||||
return places;
|
return places;
|
||||||
@@ -42,7 +42,13 @@ public sealed class CloudPlaceStore
|
|||||||
var label = string.IsNullOrWhiteSpace(displayName) ? Path.GetFileName(trimmed) : displayName.Trim();
|
var label = string.IsNullOrWhiteSpace(displayName) ? Path.GetFileName(trimmed) : displayName.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(label))
|
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));
|
places.Add(new ProviderPlace(providerId, label, trimmed));
|
||||||
@@ -75,7 +81,7 @@ public sealed class CloudPlaceStore
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var path = place.Path.TrimEnd('\\');
|
var path = CloudPath.NormalizePlace(place.Path);
|
||||||
if (list.Exists(p => p.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
|
if (list.Exists(p => p.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -104,7 +110,7 @@ public sealed class CloudPlaceStore
|
|||||||
continue;
|
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;
|
return list;
|
||||||
|
|||||||
8
src/Explorer.Application/IArchiveCatalog.cs
Normal file
8
src/Explorer.Application/IArchiveCatalog.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Application;
|
namespace Explorer.Application;
|
||||||
@@ -54,7 +55,7 @@ public sealed class PathHistoryStore
|
|||||||
foreach (var raw in paths)
|
foreach (var raw in paths)
|
||||||
{
|
{
|
||||||
var path = raw.Trim();
|
var path = raw.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,53 @@ public sealed class SourceManager
|
|||||||
return source;
|
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)
|
public async Task<Source?> FindByPathAsync(string path, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
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)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -255,6 +302,50 @@ public sealed class SourceManager
|
|||||||
return created;
|
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()
|
private IReadOnlyList<string> LoadRecents()
|
||||||
{
|
{
|
||||||
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||||
|
|||||||
109
src/Explorer.Application/UiPreferencesStore.cs
Normal file
109
src/Explorer.Application/UiPreferencesStore.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ public interface ISourceStore
|
|||||||
Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default);
|
Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default);
|
||||||
Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, 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 SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IEntryStore
|
public interface IEntryStore
|
||||||
@@ -48,6 +49,7 @@ public interface IEntryStore
|
|||||||
Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default);
|
Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
Task TombstoneAsync(long id, DateTimeOffset utc, 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 DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default);
|
||||||
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
|
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
|
||||||
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ public static class AppConstants
|
|||||||
public const int LocalScanParallelism = 4;
|
public const int LocalScanParallelism = 4;
|
||||||
public const int NetworkScanParallelism = 1;
|
public const int NetworkScanParallelism = 1;
|
||||||
public const int ProgressHzMilliseconds = 100;
|
public const int ProgressHzMilliseconds = 100;
|
||||||
|
public const int MaxArchiveEntries = 8000;
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/Explorer.Domain/ArchiveFormats.cs
Normal file
71
src/Explorer.Domain/ArchiveFormats.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ public enum SourceKind
|
|||||||
Cloud
|
Cloud
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class SourceKinds
|
||||||
|
{
|
||||||
|
public static bool IsNetwork(this SourceKind kind) => kind is SourceKind.Smb or SourceKind.Nfs;
|
||||||
|
}
|
||||||
|
|
||||||
public enum SourceStatus
|
public enum SourceStatus
|
||||||
{
|
{
|
||||||
Online,
|
Online,
|
||||||
|
|||||||
11
src/Explorer.Domain/LocationRoots.cs
Normal file
11
src/Explorer.Domain/LocationRoots.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -150,6 +150,18 @@ public static class PathRules
|
|||||||
return idx < 0 ? p : p[(idx + 1)..];
|
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)
|
public static string JoinDisplay(string? root, string pathRel)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(root))
|
if (string.IsNullOrWhiteSpace(root))
|
||||||
|
|||||||
96
src/Explorer.Indexing/ArchiveCatalog.cs
Normal file
96
src/Explorer.Indexing/ArchiveCatalog.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Explorer.Indexing/ArchiveContentsIndexer.cs
Normal file
111
src/Explorer.Indexing/ArchiveContentsIndexer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/Explorer.Indexing/ArchiveTreeBuilder.cs
Normal file
139
src/Explorer.Indexing/ArchiveTreeBuilder.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="SharpCompress" Version="0.50.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
|||||||
@@ -11,17 +11,20 @@ public sealed class FilesystemScanner
|
|||||||
private readonly IFileSystemEnumerator _enumerator;
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly ILogger<FilesystemScanner> _logger;
|
private readonly ILogger<FilesystemScanner> _logger;
|
||||||
|
private readonly ArchiveContentsIndexer? _archives;
|
||||||
|
|
||||||
public FilesystemScanner(
|
public FilesystemScanner(
|
||||||
IIndexStore store,
|
IIndexStore store,
|
||||||
IFileSystemEnumerator enumerator,
|
IFileSystemEnumerator enumerator,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
ILogger<FilesystemScanner> logger)
|
ILogger<FilesystemScanner> logger,
|
||||||
|
ArchiveContentsIndexer? archives = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_archives = archives;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ScanJob> ScanAsync(
|
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;
|
frame.Expanded = true;
|
||||||
for (var i = childDirs.Count - 1; i >= 0; i--)
|
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)
|
await _store.Entries.MarkMissingAsDeletedAsync(source.Id, generation, DateTimeOffset.UtcNow, string.IsNullOrEmpty(startRel) ? null : startRel, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await _store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, generation, cancellationToken)
|
await _store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, generation, cancellationToken)
|
||||||
@@ -198,7 +203,8 @@ public sealed class FilesystemScanner
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
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.Status = ScanJobStatus.Cancelled;
|
||||||
job.FinishedUtc = DateTimeOffset.UtcNow;
|
job.FinishedUtc = DateTimeOffset.UtcNow;
|
||||||
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
|
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)
|
if (pending.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -244,7 +257,24 @@ public sealed class FilesystemScanner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var flushed = pending.ToList();
|
||||||
pending.Clear();
|
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()
|
private static ScanProgress ToProgress(ScanJob job, string path) => new()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Domain.Abstractions;
|
using Explorer.Domain.Abstractions;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Explorer.Indexing;
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
@@ -10,12 +9,18 @@ public sealed class FolderReconciler
|
|||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly IFileSystemEnumerator _enumerator;
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
private readonly StorageProviderRegistry _providers;
|
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;
|
_store = store;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
|
_archives = archives;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||||
@@ -32,12 +37,38 @@ public sealed class FolderReconciler
|
|||||||
return;
|
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)
|
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
|
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 =>
|
await _store.RunWriteAsync(async s =>
|
||||||
{
|
{
|
||||||
@@ -71,6 +102,7 @@ public sealed class FolderReconciler
|
|||||||
var existing = indexed.FirstOrDefault(e => e.NameNorm == entry.NameNorm);
|
var existing = indexed.FirstOrDefault(e => e.NameNorm == entry.NameNorm);
|
||||||
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
|
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
|
||||||
var id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
var id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
entry.Id = id;
|
||||||
if (!item.IsDirectory)
|
if (!item.IsDirectory)
|
||||||
{
|
{
|
||||||
var delta = item.SizeBytes - oldSize;
|
var delta = item.SizeBytes - oldSize;
|
||||||
@@ -84,14 +116,17 @@ public sealed class FolderReconciler
|
|||||||
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
|
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ArchiveFormats.IsArchive(item.Name))
|
||||||
|
{
|
||||||
|
archivesToExpand.Add(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (existing is null)
|
else if (existing is null)
|
||||||
{
|
{
|
||||||
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
|
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var old in indexed)
|
foreach (var old in indexed)
|
||||||
@@ -99,8 +134,27 @@ public sealed class FolderReconciler
|
|||||||
if (!liveNames.Contains(old.NameNorm))
|
if (!liveNames.Contains(old.NameNorm))
|
||||||
{
|
{
|
||||||
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
|
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!old.IsDirectory && ArchiveFormats.IsArchive(old.Name))
|
||||||
|
{
|
||||||
|
archivesToTomb.Add(old.PathRel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
}, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ public sealed class UsnChangeApplier
|
|||||||
private readonly IIndexStore _store;
|
private readonly IIndexStore _store;
|
||||||
private readonly IFileSystemEnumerator _enumerator;
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
private readonly ILogger<UsnChangeApplier> _logger;
|
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;
|
_store = store;
|
||||||
_enumerator = enumerator;
|
_enumerator = enumerator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_archives = archives;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UsnReadStatus> ApplyAsync(Source source, IUsnJournal journal, CancellationToken cancellationToken)
|
public async Task<UsnReadStatus> ApplyAsync(Source source, IUsnJournal journal, CancellationToken cancellationToken)
|
||||||
@@ -64,6 +70,7 @@ public sealed class UsnChangeApplier
|
|||||||
|
|
||||||
var coalesced = Coalesce(records);
|
var coalesced = Coalesce(records);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expand = new List<IndexEntry>();
|
||||||
await _store.RunWriteAsync(async s =>
|
await _store.RunWriteAsync(async s =>
|
||||||
{
|
{
|
||||||
foreach (var rec in coalesced)
|
foreach (var rec in coalesced)
|
||||||
@@ -71,7 +78,11 @@ public sealed class UsnChangeApplier
|
|||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -82,10 +93,19 @@ public sealed class UsnChangeApplier
|
|||||||
await s.Sources.UpdateUsnAsync(source.Id, next.JournalId, next.NextUsn, cancellationToken).ConfigureAwait(false);
|
await s.Sources.UpdateUsnAsync(source.Id, next.JournalId, next.NextUsn, cancellationToken).ConfigureAwait(false);
|
||||||
}, 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;
|
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
|
var existing = rec.FileReferenceNumber != 0
|
||||||
? await store.Entries.GetByFileIdAsync(source.Id, rec.FileReferenceNumber, cancellationToken).ConfigureAwait(false)
|
? await store.Entries.GetByFileIdAsync(source.Id, rec.FileReferenceNumber, cancellationToken).ConfigureAwait(false)
|
||||||
@@ -96,9 +116,14 @@ public sealed class UsnChangeApplier
|
|||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
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
|
var parent = rec.ParentFileReferenceNumber != 0
|
||||||
@@ -115,9 +140,14 @@ public sealed class UsnChangeApplier
|
|||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
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;
|
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
|
||||||
@@ -150,7 +180,7 @@ public sealed class UsnChangeApplier
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
entry.Id = await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
if (!live.IsDirectory)
|
if (!live.IsDirectory)
|
||||||
{
|
{
|
||||||
var delta = live.SizeBytes - oldSize;
|
var delta = live.SizeBytes - oldSize;
|
||||||
@@ -164,9 +194,16 @@ public sealed class UsnChangeApplier
|
|||||||
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, delta, 0, 0, cancellationToken)
|
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, delta, 0, 0, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ArchiveFormats.IsArchive(live.Name))
|
||||||
|
{
|
||||||
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<UsnRecord> Coalesce(IReadOnlyList<UsnRecord> records)
|
private static List<UsnRecord> Coalesce(IReadOnlyList<UsnRecord> records)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<long, UsnRecord>();
|
var map = new Dictionary<long, UsnRecord>();
|
||||||
|
|||||||
@@ -80,7 +80,38 @@ public sealed record ProviderQuota(
|
|||||||
public sealed record ProviderPlace(
|
public sealed record ProviderPlace(
|
||||||
string ProviderId,
|
string ProviderId,
|
||||||
string DisplayName,
|
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
|
public interface IStorageProvider
|
||||||
{
|
{
|
||||||
|
|||||||
143
src/Explorer.Plugin.GoogleDrive/DriveFsPreferenceReader.cs
Normal file
143
src/Explorer.Plugin.GoogleDrive/DriveFsPreferenceReader.cs
Normal 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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
118
src/Explorer.Plugin.GoogleDrive/GoogleDrivePlaceDiscovery.cs
Normal file
118
src/Explorer.Plugin.GoogleDrive/GoogleDrivePlaceDiscovery.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/Explorer.Plugin.GoogleDrive/GoogleDrivePlaceholderState.cs
Normal file
101
src/Explorer.Plugin.GoogleDrive/GoogleDrivePlaceholderState.cs
Normal 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",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
148
src/Explorer.Plugin.GoogleDrive/GoogleDriveStorageProvider.cs
Normal file
148
src/Explorer.Plugin.GoogleDrive/GoogleDriveStorageProvider.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
198
src/Explorer.Plugin.Nextcloud/NextcloudPlaceDiscovery.cs
Normal file
198
src/Explorer.Plugin.Nextcloud/NextcloudPlaceDiscovery.cs
Normal 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)..];
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/Explorer.Plugin.Nextcloud/NextcloudPlaceholderState.cs
Normal file
101
src/Explorer.Plugin.Nextcloud/NextcloudPlaceholderState.cs
Normal 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",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
140
src/Explorer.Plugin.Nextcloud/NextcloudStorageProvider.cs
Normal file
140
src/Explorer.Plugin.Nextcloud/NextcloudStorageProvider.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using Explorer.Plugin.Abstractions;
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Windows;
|
||||||
|
|
||||||
namespace Explorer.Plugin.OneDrive;
|
namespace Explorer.Plugin.OneDrive;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ public static class OneDrivePlaceDiscovery
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed));
|
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed, "\uE753"));
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -57,6 +58,12 @@ public static class OneDrivePlaceDiscovery
|
|||||||
// registry is optional
|
// 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("OneDriveConsumer") ?? "", "OneDrive");
|
||||||
Add(Environment.GetEnvironmentVariable("OneDrive") ?? "", "OneDrive");
|
Add(Environment.GetEnvironmentVariable("OneDrive") ?? "", "OneDrive");
|
||||||
Add(Environment.GetEnvironmentVariable("OneDriveCommercial") ?? "", "OneDrive - Work");
|
Add(Environment.GetEnvironmentVariable("OneDriveCommercial") ?? "", "OneDrive - Work");
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ public sealed class OneDriveStorageProvider : IStorageProvider
|
|||||||
|
|
||||||
foreach (var root in GetPlaces())
|
foreach (var root in GetPlaces())
|
||||||
{
|
{
|
||||||
if (IsUnder(root.Path, path))
|
if (CloudPath.IsUnder(root.Path, path))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return CloudFilesNative.IsCloudSyncRoot(path);
|
return LooksLikeOneDrive(path) && CloudFilesNative.IsCloudSyncRoot(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
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 n = path.Replace('/', '\\');
|
||||||
var b = path.TrimEnd('\\');
|
return n.Contains(@"\OneDrive", StringComparison.OrdinalIgnoreCase)
|
||||||
return b.Equals(a, StringComparison.OrdinalIgnoreCase)
|
|| n.Contains("OneDrive - ", StringComparison.OrdinalIgnoreCase);
|
||||||
|| b.StartsWith(a + "\\", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
|
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
|
||||||
public bool CanGoBack => _history.CanGoBack;
|
public bool CanGoBack => _history.CanGoBack;
|
||||||
public bool CanGoForward => _history.CanGoForward;
|
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)
|
public async Task NavigateAsync(string path, bool addHistory = true)
|
||||||
{
|
{
|
||||||
@@ -69,9 +69,9 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
OnPropertyChanged(nameof(CanGoUp));
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
OnPropertyChanged(nameof(Breadcrumb));
|
OnPropertyChanged(nameof(Breadcrumb));
|
||||||
|
|
||||||
if (path == "This PC")
|
if (LocationRoots.IsVirtual(path))
|
||||||
{
|
{
|
||||||
await LoadThisPcAsync(ct).ConfigureAwait(true);
|
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
|
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);
|
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
|
||||||
_indexing.EnqueueReconcile(src.Id, rel);
|
_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;
|
IsOffline = false;
|
||||||
ShowIndexBanner = false;
|
ShowIndexBanner = false;
|
||||||
CurrentSource = null;
|
CurrentSource = null;
|
||||||
Items.Clear();
|
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)
|
foreach (var item in listing.Items)
|
||||||
{
|
{
|
||||||
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
|
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
|
||||||
@@ -158,12 +164,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public Task UpAsync()
|
public Task UpAsync()
|
||||||
{
|
{
|
||||||
if (CurrentPath == "This PC")
|
if (CurrentPath == LocationRoots.ThisPc)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PathRules.IsDriveRoot(CurrentPath)
|
if (LocationRoots.IsVirtual(CurrentPath)
|
||||||
|
|| PathRules.IsDriveRoot(CurrentPath)
|
||||||
|| (PathRules.IsUnc(CurrentPath)
|
|| (PathRules.IsUnc(CurrentPath)
|
||||||
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
|
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
@@ -178,7 +185,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
public Task OpenItemAsync(FolderItemViewModel item)
|
public Task OpenItemAsync(FolderItemViewModel item)
|
||||||
{
|
{
|
||||||
if (item.IsDirectory)
|
if (item.IsDirectory || _browse.CanBrowseArchive(item.Item.Name))
|
||||||
{
|
{
|
||||||
return NavigateAsync(item.FullPath);
|
return NavigateAsync(item.FullPath);
|
||||||
}
|
}
|
||||||
@@ -202,7 +209,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
public void RescanFolder()
|
public void RescanFolder()
|
||||||
{
|
{
|
||||||
if (CurrentSource is null || CurrentPath == "This PC")
|
if (CurrentSource is null || LocationRoots.IsVirtual(CurrentPath))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -216,12 +223,21 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
|||||||
|
|
||||||
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
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);
|
var p = PathRules.FromExtended(path);
|
||||||
if (PathRules.IsUnc(p))
|
if (PathRules.IsUnc(p))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
using Explorer.FileOperations;
|
using Explorer.FileOperations;
|
||||||
using Explorer.Indexing;
|
using Explorer.Indexing;
|
||||||
|
|
||||||
@@ -33,7 +34,9 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(ExplorerPaneViewModel.CurrentPath))
|
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))
|
if (string.IsNullOrEmpty(Title))
|
||||||
{
|
{
|
||||||
Title = Left.CurrentPath;
|
Title = Left.CurrentPath;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private readonly PathHistoryStore _pathHistory;
|
private readonly PathHistoryStore _pathHistory;
|
||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly CloudPlaceStore _cloudPlaces;
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
|
private readonly UiPreferencesStore _preferences;
|
||||||
private List<string> _clipboard = [];
|
private List<string> _clipboard = [];
|
||||||
private bool _clipboardIsCut;
|
private bool _clipboardIsCut;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[ObservableProperty] private string? _promptUnc;
|
[ObservableProperty] private string? _promptUnc;
|
||||||
[ObservableProperty] private bool _showCloudPin;
|
[ObservableProperty] private bool _showCloudPin;
|
||||||
[ObservableProperty] private bool _showCloudDehydrate;
|
[ObservableProperty] private bool _showCloudDehydrate;
|
||||||
|
[ObservableProperty] private bool _showForgetSource;
|
||||||
|
|
||||||
private readonly IOsClipboard Clipboard;
|
private readonly IOsClipboard Clipboard;
|
||||||
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||||
@@ -47,7 +49,8 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
IOsClipboard clipboard,
|
IOsClipboard clipboard,
|
||||||
PathHistoryStore pathHistory,
|
PathHistoryStore pathHistory,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
CloudPlaceStore cloudPlaces)
|
CloudPlaceStore cloudPlaces,
|
||||||
|
UiPreferencesStore preferences)
|
||||||
{
|
{
|
||||||
_browse = browse;
|
_browse = browse;
|
||||||
_ops = ops;
|
_ops = ops;
|
||||||
@@ -56,8 +59,11 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_pathHistory = pathHistory;
|
_pathHistory = pathHistory;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
_cloudPlaces = cloudPlaces;
|
_cloudPlaces = cloudPlaces;
|
||||||
|
_preferences = preferences;
|
||||||
|
var prefs = preferences.Load();
|
||||||
|
Theme = prefs.Theme;
|
||||||
PathHistory = [];
|
PathHistory = [];
|
||||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
|
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
|
||||||
Search = new SearchViewModel(search, sources);
|
Search = new SearchViewModel(search, sources);
|
||||||
Analysis = new AnalysisViewModel(analysis);
|
Analysis = new AnalysisViewModel(analysis);
|
||||||
Duplicates = new DuplicateViewModel(store, sources);
|
Duplicates = new DuplicateViewModel(store, sources);
|
||||||
@@ -252,7 +258,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
_clipboardIsCut = cut;
|
_clipboardIsCut = cut;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
|
if (_clipboard.Count == 0 || LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -279,7 +285,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
public void CopyPath()
|
public void CopyPath()
|
||||||
{
|
{
|
||||||
var paths = SelectedPaths();
|
var paths = SelectedPaths();
|
||||||
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
|
if (paths.Count == 0 && !LocationRoots.IsVirtual(ActivePane.CurrentPath))
|
||||||
{
|
{
|
||||||
paths = [ActivePane.CurrentPath];
|
paths = [ActivePane.CurrentPath];
|
||||||
}
|
}
|
||||||
@@ -290,7 +296,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void NewFolder()
|
public void NewFolder()
|
||||||
{
|
{
|
||||||
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
|
if (LocationRoots.IsVirtual(ActivePane.CurrentPath) || ActivePane.IsOffline)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -317,12 +323,12 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
public async Task BuildIndexAsync()
|
public async Task BuildIndexAsync()
|
||||||
{
|
{
|
||||||
var path = ActivePane.CurrentPath;
|
var path = ActivePane.CurrentPath;
|
||||||
if (path == "This PC")
|
if (LocationRoots.IsVirtual(path))
|
||||||
{
|
{
|
||||||
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
|
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.";
|
Footer = "Select a drive or folder to index.";
|
||||||
return;
|
return;
|
||||||
@@ -351,6 +357,59 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
|
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
|
||||||
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
|
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
|
||||||
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
|
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]
|
[RelayCommand]
|
||||||
@@ -365,7 +424,7 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
private async Task InvokeCloudAsync(ProviderAction action)
|
private async Task InvokeCloudAsync(ProviderAction action)
|
||||||
{
|
{
|
||||||
var paths = SelectedPaths();
|
var paths = SelectedPaths();
|
||||||
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
|
if (paths.Count == 0 && !LocationRoots.IsVirtual(ActivePane.CurrentPath))
|
||||||
{
|
{
|
||||||
paths = [ActivePane.CurrentPath];
|
paths = [ActivePane.CurrentPath];
|
||||||
}
|
}
|
||||||
@@ -483,20 +542,51 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
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))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
|
var trimmed = path.Trim().TrimEnd('\\');
|
||||||
Footer = "Added OneDrive folder to the navigation tree.";
|
var id = providerId
|
||||||
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
|
?? _providers.Find(trimmed)?.Manifest.Id
|
||||||
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
?? 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]
|
[RelayCommand]
|
||||||
public Task SearchAsync()
|
public Task SearchAsync()
|
||||||
@@ -513,6 +603,41 @@ public sealed partial class MainViewModel : ObservableObject
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
|
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)
|
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
|
||||||
{
|
{
|
||||||
if (files.Count == 0)
|
if (files.Count == 0)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace Explorer.Presentation.ViewModels;
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ public sealed partial class NavNodeViewModel : ObservableObject
|
|||||||
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
||||||
public string Glyph { get; init; } = "\uE8B7";
|
public string Glyph { get; init; } = "\uE8B7";
|
||||||
public bool IsPlaceholder { get; init; }
|
public bool IsPlaceholder { get; init; }
|
||||||
|
public bool IsGroup { get; init; }
|
||||||
|
public long? SourceId { get; init; }
|
||||||
|
public bool CanRemove { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class NavigationTreeViewModel
|
public sealed class NavigationTreeViewModel
|
||||||
@@ -26,17 +30,20 @@ public sealed class NavigationTreeViewModel
|
|||||||
private readonly BrowseService _browse;
|
private readonly BrowseService _browse;
|
||||||
private readonly StorageProviderRegistry _providers;
|
private readonly StorageProviderRegistry _providers;
|
||||||
private readonly CloudPlaceStore _cloudPlaces;
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
|
private readonly UiPreferencesStore _preferences;
|
||||||
|
|
||||||
public NavigationTreeViewModel(
|
public NavigationTreeViewModel(
|
||||||
SourceManager sources,
|
SourceManager sources,
|
||||||
BrowseService browse,
|
BrowseService browse,
|
||||||
StorageProviderRegistry providers,
|
StorageProviderRegistry providers,
|
||||||
CloudPlaceStore cloudPlaces)
|
CloudPlaceStore cloudPlaces,
|
||||||
|
UiPreferencesStore preferences)
|
||||||
{
|
{
|
||||||
_sources = sources;
|
_sources = sources;
|
||||||
_browse = browse;
|
_browse = browse;
|
||||||
_providers = providers;
|
_providers = providers;
|
||||||
_cloudPlaces = cloudPlaces;
|
_cloudPlaces = cloudPlaces;
|
||||||
|
_preferences = preferences;
|
||||||
Roots = [];
|
Roots = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,47 +62,80 @@ public sealed class NavigationTreeViewModel
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Roots.Clear();
|
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);
|
Roots.Add(thisPc);
|
||||||
|
|
||||||
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
|
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
|
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
|
var network = sources.Where(s => s.Kind.IsNetwork()).ToList();
|
||||||
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
if (prefs.GroupNetworkPlaces && network.Count > 0)
|
||||||
{
|
{
|
||||||
var exists = Directory.Exists(place.Path);
|
var group = new NavNodeViewModel
|
||||||
var node = new NavNodeViewModel
|
|
||||||
{
|
{
|
||||||
Label = place.DisplayName,
|
Label = LocationRoots.Network,
|
||||||
Path = place.Path,
|
Path = LocationRoots.Network,
|
||||||
Glyph = "\uE753",
|
Glyph = "\uE968",
|
||||||
Status = exists ? "" : "Offline",
|
IsExpanded = true,
|
||||||
IsOffline = !exists
|
ChildrenLoaded = true,
|
||||||
|
IsGroup = true
|
||||||
};
|
};
|
||||||
AddPlaceholder(node);
|
foreach (var source in network)
|
||||||
Roots.Add(node);
|
{
|
||||||
|
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)
|
foreach (var path in expanded)
|
||||||
@@ -123,7 +163,7 @@ public sealed class NavigationTreeViewModel
|
|||||||
|
|
||||||
public async Task EnsureChildrenAsync(NavNodeViewModel node)
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,13 +195,13 @@ public sealed class NavigationTreeViewModel
|
|||||||
IsRevealing = true;
|
IsRevealing = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (PathsEqual(path, "This PC"))
|
if (LocationRoots.IsVirtual(path))
|
||||||
{
|
{
|
||||||
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
|
var virtualRoot = Roots.FirstOrDefault(r => r.Path == path);
|
||||||
if (thisPc is not null)
|
if (virtualRoot is not null)
|
||||||
{
|
{
|
||||||
thisPc.IsExpanded = true;
|
virtualRoot.IsExpanded = true;
|
||||||
SelectOnly(thisPc);
|
SelectOnly(virtualRoot);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -224,6 +264,61 @@ public sealed class NavigationTreeViewModel
|
|||||||
StringComparison.OrdinalIgnoreCase);
|
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)
|
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
|
||||||
{
|
{
|
||||||
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
|
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
|
||||||
@@ -232,7 +327,7 @@ public sealed class NavigationTreeViewModel
|
|||||||
|
|
||||||
void Consider(NavNodeViewModel node)
|
void Consider(NavNodeViewModel node)
|
||||||
{
|
{
|
||||||
if (node.IsPlaceholder || node.Path == "This PC")
|
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,11 +346,11 @@ public sealed class NavigationTreeViewModel
|
|||||||
|
|
||||||
foreach (var root in roots)
|
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
|
else
|
||||||
|
|||||||
@@ -176,6 +176,25 @@ internal sealed class EntryStore : IEntryStore
|
|||||||
conn, "UPDATE entries SET status=0 WHERE source_id=@sourceId AND status=1",
|
conn, "UPDATE entries SET status=0 WHERE source_id=@sourceId AND status=1",
|
||||||
new { sourceId }), cancellationToken);
|
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)
|
public Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||||
=> _store.WriteAsync(async conn =>
|
=> _store.WriteAsync(async conn =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
<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" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -111,6 +111,26 @@ internal sealed class SourceStore : ISourceStore
|
|||||||
"UPDATE sources SET last_root_path=@rootPath, last_seen_utc=@utc WHERE id=@id",
|
"UPDATE sources SET last_root_path=@rootPath, last_seen_utc=@utc WHERE id=@id",
|
||||||
new { id, rootPath, utc = utc.ToString("O") }), cancellationToken);
|
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
|
private static object ToArgs(Source s) => new
|
||||||
{
|
{
|
||||||
s.Id,
|
s.Id,
|
||||||
|
|||||||
59
src/Explorer.Windows/WindowsSyncRootDiscovery.cs
Normal file
59
src/Explorer.Windows/WindowsSyncRootDiscovery.cs
Normal 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)));
|
||||||
|
}
|
||||||
@@ -95,7 +95,9 @@ public class BrowseServiceTests
|
|||||||
volumes,
|
volumes,
|
||||||
store,
|
store,
|
||||||
sources,
|
sources,
|
||||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||||
|
new CloudPlaceStore(env),
|
||||||
|
new UiPreferencesStore(env));
|
||||||
return (browse, store);
|
return (browse, store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Explorer.Application;
|
using Explorer.Application;
|
||||||
using Explorer.Domain;
|
using Explorer.Domain;
|
||||||
using Explorer.Plugin.Abstractions;
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Plugin.GoogleDrive;
|
||||||
|
using Explorer.Plugin.Nextcloud;
|
||||||
using Explorer.Plugin.OneDrive;
|
using Explorer.Plugin.OneDrive;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
@@ -104,6 +106,111 @@ public class CloudProviderTests
|
|||||||
Assert.Equal("Dominique - Personal", merged[0].DisplayName);
|
Assert.Equal("Dominique - Personal", merged[0].DisplayName);
|
||||||
Assert.Equal(@"C:\Users\x\OneDrive - Contoso", merged[1].Path);
|
Assert.Equal(@"C:\Users\x\OneDrive - Contoso", merged[1].Path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cloud_path_is_under_is_prefix_safe()
|
||||||
|
{
|
||||||
|
Assert.True(CloudPath.IsUnder(@"F:\OneDrive - wyniger", @"F:\OneDrive - wyniger\Obsidian\Notes"));
|
||||||
|
Assert.False(CloudPath.IsUnder(@"F:\OneDrive", @"F:\OneDrive - wyniger"));
|
||||||
|
Assert.True(CloudPath.IsUnder(@"J:\", @"J:\My Drive\Photos"));
|
||||||
|
Assert.Equal(@"J:\", CloudPath.NormalizePlace("j:"));
|
||||||
|
Assert.Equal(@"J:\", CloudPath.NormalizePlace(@"J:\"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriveFS_media_named_google_drive_becomes_a_place()
|
||||||
|
{
|
||||||
|
var places = DriveFsPreferenceReader.PlacesFromTables(
|
||||||
|
[
|
||||||
|
new("Games", @"D:\", 4),
|
||||||
|
new("Google Drive", @"J:\", 10),
|
||||||
|
new("MP3", @"G:\", 4)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
new("My Drive", @"My Drive", @"J:\My Drive", true)
|
||||||
|
]);
|
||||||
|
Assert.Single(places);
|
||||||
|
Assert.Equal("googledrive", places[0].ProviderId);
|
||||||
|
Assert.Equal("Google Drive", places[0].DisplayName);
|
||||||
|
Assert.Equal(@"J:\", places[0].Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriveFS_mirrored_folder_becomes_a_place_when_no_mount()
|
||||||
|
{
|
||||||
|
var places = DriveFsPreferenceReader.PlacesFromTables(
|
||||||
|
[new("Data", @"F:\", 4)],
|
||||||
|
[new("Photos", @"Photos", @"F:\Google Photos", false)]);
|
||||||
|
Assert.Single(places);
|
||||||
|
Assert.Equal("Google Drive - Photos", places[0].DisplayName);
|
||||||
|
Assert.Equal(@"F:\Google Photos", places[0].Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DriveFS_local_preference_db_exposes_configured_mount()
|
||||||
|
{
|
||||||
|
if (!File.Exists(DriveFsPreferenceReader.DefaultDatabasePath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var places = DriveFsPreferenceReader.ReadPlaces();
|
||||||
|
Assert.Contains(places, p => p.DisplayName == "Google Drive" && p.Path.Length >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(@"C:\Users\x\My Drive", "Google Drive")]
|
||||||
|
[InlineData(@"G:\Shared drives", "Shared drives")]
|
||||||
|
[InlineData(@"D:\Work", "Google Drive - Work")]
|
||||||
|
public void Google_drive_place_label(string path, string expected)
|
||||||
|
=> Assert.Equal(expected, GoogleDrivePlaceDiscovery.BuildLabel(path, null));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Nextcloud_parses_account_folders_from_cfg()
|
||||||
|
{
|
||||||
|
const string cfg = """
|
||||||
|
[Accounts]
|
||||||
|
0\url=https://cloud.example.com
|
||||||
|
0\displayName=Dominique Wyniger
|
||||||
|
0\Folders\1\localPath=F:/OneDrive - wyniger/Obsidian/Notes/
|
||||||
|
0\Folders\1\targetPath=/Notizen/Notes
|
||||||
|
0\Folders\1\virtualFilesMode=off
|
||||||
|
""";
|
||||||
|
var places = NextcloudPlaceDiscovery.ParseCfg(cfg);
|
||||||
|
Assert.Single(places);
|
||||||
|
Assert.Equal("nextcloud", places[0].ProviderId);
|
||||||
|
Assert.Equal(@"F:\OneDrive - wyniger\Obsidian\Notes", places[0].Path);
|
||||||
|
Assert.Equal("Nextcloud - Notes", places[0].DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OneDrive_does_not_claim_google_drive_paths()
|
||||||
|
{
|
||||||
|
Assert.False(OneDriveStorageProvider.LooksLikeOneDrive(@"G:\My Drive\Photos"));
|
||||||
|
Assert.True(GoogleDriveStorageProvider.LooksLikeGoogleDrive(@"G:\My Drive\Photos"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Discover_skips_missing_default_profile_folders()
|
||||||
|
{
|
||||||
|
var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
var google = GoogleDrivePlaceDiscovery.Discover();
|
||||||
|
var nextcloud = NextcloudPlaceDiscovery.Discover();
|
||||||
|
AssertMissingGuess(google, Path.Combine(profile, "Google Drive"));
|
||||||
|
AssertMissingGuess(google, Path.Combine(profile, "My Drive"));
|
||||||
|
AssertMissingGuess(nextcloud, Path.Combine(profile, "Nextcloud"));
|
||||||
|
AssertMissingGuess(nextcloud, Path.Combine(profile, "ownCloud"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertMissingGuess(IReadOnlyList<ProviderPlace> places, string path)
|
||||||
|
{
|
||||||
|
if (Directory.Exists(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.DoesNotContain(places, p => p.Path.Equals(path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file static class CloudFilesNativeStates
|
file static class CloudFilesNativeStates
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||||
|
|||||||
@@ -90,6 +90,68 @@ public class SourceManagerTests
|
|||||||
var found = await mgr.FindByPathAsync(@"Z:\");
|
var found = await mgr.FindByPathAsync(@"Z:\");
|
||||||
Assert.Equal(source.Id, found!.Id);
|
Assert.Equal(source.Id, found!.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Forget_removes_disconnected_source_and_index_rows()
|
||||||
|
{
|
||||||
|
var db = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"), "index.db");
|
||||||
|
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||||
|
await store.OpenAsync();
|
||||||
|
var volumes = new FakeVolumes
|
||||||
|
{
|
||||||
|
Online =
|
||||||
|
[
|
||||||
|
new VolumeFingerprint
|
||||||
|
{
|
||||||
|
Kind = SourceKind.Smb,
|
||||||
|
RootPath = @"Z:\",
|
||||||
|
DisplayName = "Z: (Network)",
|
||||||
|
Filesystem = "SMB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||||
|
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||||
|
await mgr.InitializeAsync();
|
||||||
|
var source = (await store.Sources.GetAllAsync()).Single();
|
||||||
|
await store.Entries.UpsertAsync(new IndexEntry
|
||||||
|
{
|
||||||
|
SourceId = source.Id,
|
||||||
|
Name = "Photos",
|
||||||
|
NameNorm = "photos",
|
||||||
|
IsDirectory = true,
|
||||||
|
PathRel = "Photos",
|
||||||
|
LastSeenUtc = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(mgr.CanForget(source));
|
||||||
|
Assert.False(await mgr.ForgetDisconnectedAsync(@"Z:\"));
|
||||||
|
|
||||||
|
volumes.Online = [];
|
||||||
|
await mgr.RefreshOnlineStateAsync();
|
||||||
|
source = (await store.Sources.GetAllAsync()).Single();
|
||||||
|
Assert.True(mgr.CanForget(source));
|
||||||
|
Assert.True(await mgr.ForgetDisconnectedAsync(@"Z:\"));
|
||||||
|
Assert.Empty(await store.Sources.GetAllAsync());
|
||||||
|
Assert.Null(await store.Entries.GetRootAsync(source.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Forget_removes_unc_from_recents()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "ew-app", Guid.NewGuid().ToString("N"));
|
||||||
|
var db = Path.Combine(dir, "index.db");
|
||||||
|
await using var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||||
|
var volumes = new FakeVolumes();
|
||||||
|
var env = new FakeEnv(dir);
|
||||||
|
File.WriteAllLines(Path.Combine(dir, "recents.txt"), [@"\\old-server\share"]);
|
||||||
|
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||||
|
await mgr.InitializeAsync();
|
||||||
|
Assert.Single(await store.Sources.GetAllAsync());
|
||||||
|
Assert.True(await mgr.ForgetDisconnectedAsync(@"\\old-server\share"));
|
||||||
|
Assert.Empty(await store.Sources.GetAllAsync());
|
||||||
|
Assert.Empty(File.ReadAllLines(Path.Combine(dir, "recents.txt")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file sealed class FakeVolumes : IVolumeService
|
file sealed class FakeVolumes : IVolumeService
|
||||||
|
|||||||
69
tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
Normal file
69
tests/Explorer.Application.Tests/UiPreferencesStoreTests.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Application.Tests;
|
||||||
|
|
||||||
|
public class UiPreferencesStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Parse_reads_theme_and_independent_group_flags()
|
||||||
|
{
|
||||||
|
var prefs = UiPreferencesStore.Parse(
|
||||||
|
[
|
||||||
|
"theme=Light",
|
||||||
|
"group-network=true",
|
||||||
|
"group-cloud=false",
|
||||||
|
"index-archives=true"
|
||||||
|
]);
|
||||||
|
Assert.Equal("Light", prefs.Theme);
|
||||||
|
Assert.True(prefs.GroupNetworkPlaces);
|
||||||
|
Assert.False(prefs.GroupCloudPlaces);
|
||||||
|
Assert.True(prefs.IndexArchiveContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Parse_defaults_missing_keys()
|
||||||
|
{
|
||||||
|
var prefs = UiPreferencesStore.Parse(["theme=dark"]);
|
||||||
|
Assert.Equal("Dark", prefs.Theme);
|
||||||
|
Assert.False(prefs.GroupNetworkPlaces);
|
||||||
|
Assert.False(prefs.GroupCloudPlaces);
|
||||||
|
Assert.False(prefs.IndexArchiveContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_and_save_roundtrip()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "ew-ui-prefs", Guid.NewGuid().ToString("N"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new UiPreferencesStore(new PrefsEnv(dir));
|
||||||
|
store.Save(new UiPreferences("Light", true, false, true));
|
||||||
|
var loaded = store.Load();
|
||||||
|
Assert.Equal("Light", loaded.Theme);
|
||||||
|
Assert.True(loaded.GroupNetworkPlaces);
|
||||||
|
Assert.False(loaded.GroupCloudPlaces);
|
||||||
|
Assert.True(loaded.IndexArchiveContents);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, true); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class PrefsEnv : IAppEnvironment
|
||||||
|
{
|
||||||
|
public PrefsEnv(string dir)
|
||||||
|
{
|
||||||
|
DataDirectory = dir;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
DatabasePath = Path.Combine(dir, "index.db");
|
||||||
|
LogDirectory = Path.Combine(dir, "logs");
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
public string DatabasePath { get; }
|
||||||
|
public string LogDirectory { get; }
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ public class PathRulesTests
|
|||||||
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
|
Assert.Equal(@"Windows\System32", PathRules.MakeRelative(@"C:\", @"C:\Windows\System32"));
|
||||||
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
|
Assert.Equal(@"C:\Windows", PathRules.Parent(@"C:\Windows\System32"));
|
||||||
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
|
Assert.Equal(@"C:\", PathRules.Parent(@"C:\Windows"));
|
||||||
|
Assert.Equal(@"pack.zip\docs", PathRules.RelativeParent(@"pack.zip\docs\a.txt"));
|
||||||
|
Assert.Equal("pack.zip", PathRules.RelativeParent(@"pack.zip\docs"));
|
||||||
|
Assert.Equal("", PathRules.RelativeParent("pack.zip"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -62,6 +65,31 @@ public class PathRulesTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ArchiveFormatsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Detects_common_archive_extensions()
|
||||||
|
{
|
||||||
|
Assert.True(ArchiveFormats.IsArchive("pack.zip"));
|
||||||
|
Assert.True(ArchiveFormats.IsArchive("film.7z"));
|
||||||
|
Assert.True(ArchiveFormats.IsArchive("backup.rar"));
|
||||||
|
Assert.True(ArchiveFormats.IsArchive("src.tar.gz"));
|
||||||
|
Assert.True(ArchiveFormats.IsZipFamily("photos.cbz"));
|
||||||
|
Assert.False(ArchiveFormats.IsArchive("notes.txt"));
|
||||||
|
Assert.False(ArchiveFormats.IsArchive("report.docx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejects_unsafe_archive_entry_paths()
|
||||||
|
{
|
||||||
|
Assert.True(ArchiveFormats.TryNormalizeEntryPath("docs/a.txt", out var ok));
|
||||||
|
Assert.Equal(@"docs\a.txt", ok);
|
||||||
|
Assert.False(ArchiveFormats.TryNormalizeEntryPath(@"..\escape.txt", out _));
|
||||||
|
Assert.False(ArchiveFormats.TryNormalizeEntryPath(@"C:\abs.txt", out _));
|
||||||
|
Assert.False(ArchiveFormats.TryNormalizeEntryPath("/", out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class VolumeIdentityTests
|
public class VolumeIdentityTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -161,6 +161,107 @@ public class ScannerTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Full_scan_indexes_archive_members_without_double_counting_folder_size()
|
||||||
|
{
|
||||||
|
var root = CreateTree();
|
||||||
|
var zipPath = Path.Combine(root, "pack.zip");
|
||||||
|
CreateZip(zipPath, ("docs/inner.txt", new string('x', 40)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var store = await OpenStore();
|
||||||
|
var source = await AddSource(store, root);
|
||||||
|
var indexer = CreateIndexer(store, enabled: true);
|
||||||
|
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance, indexer);
|
||||||
|
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||||
|
|
||||||
|
var inner = await store.Entries.GetByPathAsync(source.Id, @"pack.zip\docs\inner.txt");
|
||||||
|
Assert.NotNull(inner);
|
||||||
|
Assert.Equal(40, inner.SizeBytes);
|
||||||
|
Assert.False(inner.IsDirectory);
|
||||||
|
|
||||||
|
var zip = await store.Entries.GetByPathAsync(source.Id, "pack.zip");
|
||||||
|
Assert.NotNull(zip);
|
||||||
|
Assert.False(zip.IsDirectory);
|
||||||
|
Assert.Equal(new FileInfo(zipPath).Length, zip.SizeBytes);
|
||||||
|
|
||||||
|
var scanRoot = await store.Entries.GetRootAsync(source.Id);
|
||||||
|
Assert.Equal(100 + 50 + zip.SizeBytes, scanRoot!.AggregateSize);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Full_scan_skips_archive_contents_when_setting_is_off()
|
||||||
|
{
|
||||||
|
var root = CreateTree();
|
||||||
|
CreateZip(Path.Combine(root, "pack.zip"), ("inner.txt", "hello"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var store = await OpenStore();
|
||||||
|
var source = await AddSource(store, root);
|
||||||
|
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance);
|
||||||
|
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||||
|
Assert.Null(await store.Entries.GetByPathAsync(source.Id, @"pack.zip\inner.txt"));
|
||||||
|
Assert.NotNull(await store.Entries.GetByPathAsync(source.Id, "pack.zip"));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Folder_reconcile_tombs_archive_members_when_zip_is_removed()
|
||||||
|
{
|
||||||
|
var root = CreateTree();
|
||||||
|
var zipPath = Path.Combine(root, "pack.zip");
|
||||||
|
CreateZip(zipPath, ("inner.txt", "hello"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var store = await OpenStore();
|
||||||
|
var source = await AddSource(store, root);
|
||||||
|
var indexer = CreateIndexer(store, enabled: true);
|
||||||
|
var scanner = new FilesystemScanner(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), NullLogger<FilesystemScanner>.Instance, indexer);
|
||||||
|
await scanner.ScanAsync(source, ScanKind.Full, null, null, CancellationToken.None);
|
||||||
|
File.Delete(zipPath);
|
||||||
|
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance), indexer);
|
||||||
|
await reconciler.ReconcileAsync(source, "", CancellationToken.None);
|
||||||
|
var zip = await store.Entries.GetByPathAsync(source.Id, "pack.zip");
|
||||||
|
var inner = await store.Entries.GetByPathAsync(source.Id, @"pack.zip\inner.txt");
|
||||||
|
Assert.Equal(EntryStatus.Deleted, zip!.Status);
|
||||||
|
Assert.Equal(EntryStatus.Deleted, inner!.Status);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Archive_catalog_lists_zip_entry_sizes_without_extracting()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "ew-scan", Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
var zipPath = Path.Combine(dir, "pack.zip");
|
||||||
|
CreateZip(zipPath, ("docs/inner.txt", new string('z', 25)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var members = new ArchiveCatalog().TryList(zipPath);
|
||||||
|
var inner = Assert.Single(members, m => m.RelativePath.Equals(@"docs\inner.txt", StringComparison.OrdinalIgnoreCase));
|
||||||
|
Assert.False(inner.IsDirectory);
|
||||||
|
Assert.Equal(25, inner.SizeBytes);
|
||||||
|
Assert.DoesNotContain(Directory.GetFiles(dir, "*", SearchOption.AllDirectories), p => p.EndsWith("inner.txt", StringComparison.OrdinalIgnoreCase) && !p.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDelete(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Folder_reconcile_tombs_removed_file()
|
public async Task Folder_reconcile_tombs_removed_file()
|
||||||
{
|
{
|
||||||
@@ -218,4 +319,46 @@ public class ScannerTests
|
|||||||
{
|
{
|
||||||
try { Directory.Delete(root, true); } catch { /* ignore */ }
|
try { Directory.Delete(root, true); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void CreateZip(string zipPath, params (string Name, string Content)[] files)
|
||||||
|
{
|
||||||
|
using var zip = System.IO.Compression.ZipFile.Open(zipPath, System.IO.Compression.ZipArchiveMode.Create);
|
||||||
|
foreach (var (name, content) in files)
|
||||||
|
{
|
||||||
|
var entry = zip.CreateEntry(name);
|
||||||
|
using var stream = entry.Open();
|
||||||
|
using var writer = new StreamWriter(stream);
|
||||||
|
writer.Write(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ArchiveContentsIndexer CreateIndexer(IIndexStore store, bool enabled)
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "ew-scan-prefs", Guid.NewGuid().ToString("N"));
|
||||||
|
var prefs = new UiPreferencesStore(new ScanPrefsEnv(dir));
|
||||||
|
prefs.Save(new UiPreferences("Dark", false, false, enabled));
|
||||||
|
return new ArchiveContentsIndexer(
|
||||||
|
store,
|
||||||
|
new IoEnumerator(),
|
||||||
|
new HydrationGuard(new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance)),
|
||||||
|
new ArchiveCatalog(),
|
||||||
|
prefs,
|
||||||
|
NullLogger<ArchiveContentsIndexer>.Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class ScanPrefsEnv : IAppEnvironment
|
||||||
|
{
|
||||||
|
public ScanPrefsEnv(string dir)
|
||||||
|
{
|
||||||
|
DataDirectory = dir;
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
DatabasePath = Path.Combine(dir, "index.db");
|
||||||
|
LogDirectory = Path.Combine(dir, "logs");
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string DataDirectory { get; }
|
||||||
|
public string DatabasePath { get; }
|
||||||
|
public string LogDirectory { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user