Show folder names immediately and refresh stale index sizes without walking the whole drive.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -839,7 +839,7 @@ Bitte diese Punkte klären. Empfohlene Defaults in Klammern:
|
||||
|
||||
## Hintergrundprozess: Explorer.Host.exe
|
||||
|
||||
Indexing, USN/watchers, transfer queue, hash worker, and history rollup run in **`Explorer.Host.exe`**, not in the WPF window.
|
||||
Indexing, USN/watchers, transfer queue, hash worker, and history rollup run in **`Explorer.Host.exe`**, not in the WPF window. Idle-aware background maintenance is coordinated there as well (`GetLastInputInfo` / power status — no WPF dependency).
|
||||
|
||||
| | Explorer.Host.exe (current) | Windows Service |
|
||||
|---|---|---|
|
||||
@@ -893,7 +893,7 @@ Deviations from the design above, with reasons:
|
||||
3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable.
|
||||
4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain.
|
||||
5. **App data folder** — `%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer.
|
||||
6. **Background work** — indexer, transfer queue, hash worker, history rollup, and watchers run as `IHostedService` instances inside **`Explorer.Host.exe`**. The WPF window is a named-pipe client (`Explorer.Hosting.Client`) with a read-only SQLite store. No Windows Service; optional current-user sign-in (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`).
|
||||
6. **Background work** — indexer, transfer queue, hash worker, and watchers run as `IHostedService` instances inside **`Explorer.Host.exe`**. `BackgroundMaintenanceCoordinator` is the one extra 1s timer: it uses `IUserIdleMonitor` / `BackgroundWorkPolicy` to pause or resume duplicate hashing, enqueue at most one idle local full scan (`IndexWorkOrigin.Idle`, distinct from user/watcher/USN work), and call `HistoryRollupService` (no longer its own hosted loop). Copy/move/delete and explicit scans are never idle work. The WPF window is a named-pipe client (`Explorer.Hosting.Client`) with a read-only SQLite store. No Windows Service; optional current-user sign-in (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`).
|
||||
7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7).
|
||||
8. **UNIQUE identity** — `UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct.
|
||||
9. **INSERT ids** — `Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`.
|
||||
@@ -901,4 +901,6 @@ Deviations from the design above, with reasons:
|
||||
11. **Schema apply** — Microsoft.Data.Sqlite/`Execute` splitting on `;` broke `CREATE TRIGGER` bodies. Schema is applied as an explicit statement array (`SchemaScript.Statements`).
|
||||
12. **Search CurrentFolder** — `SearchRequest.DirectChildrenOnly` restricts to the folder’s children in SQL (not a client-side filter after paging).
|
||||
13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read.
|
||||
14. **Index freshness** — folder sizes in the listing come from `aggregate_size`. Watcher reconcile stays one directory deep. Opening a folder enqueues a 1-level reconcile of that path, then `FolderDisplayRefresh` probes up to 8 largest/visible child dirs (child count). Mismatches get `EnqueueVerify` for that child only and are cancelled when the browse generation changes. Idle maintenance still verifies each local indexed source (deep walk, cap 48). USN directory deletes also tombstone the path prefix.
|
||||
15. **Browse names first** — live folder listing starts without waiting for source lookup, archive index, `MarkReachable`, or child-index overlay. The first name is published immediately (`BrowseHydration.FirstPublish`); folder sizes, cloud state, and Git badges arrive as later `BrowseDelta` updates. Archive paths still resolve through the index before live enumeration.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
| --- | --- |
|
||||
| `%LocalAppData%\ExplorerWorkbench\index.db` | Index, queue, profiles |
|
||||
| `%LocalAppData%\ExplorerWorkbench\logs\` | Rolling logs |
|
||||
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations |
|
||||
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,9 +52,10 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
|
||||
- **Title bar** — Explorer Workbench; minimize / maximize / close.
|
||||
- **Menu** — File (including Stop background host), View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives and Convert), Automation, Development, Recycle Bin.
|
||||
- **Toolbar** — path, navigation, view mode, search, storage, queue summary.
|
||||
- **Tree** — This PC, Network, Cloud (grouping is optional in Settings).
|
||||
- **Folder pane** — details, list, or preview. Split pane is optional.
|
||||
- **Toolbar** — navigation, view mode, search.
|
||||
- **Tree** — Favorites, Home, This PC, Network, Cloud (Network/Cloud grouping and tree synchronization are in Settings → Navigation).
|
||||
- **Folder pane** — breadcrumb bar (click the empty space, or `Ctrl+L` / `Alt+D`, to type a path), then details, list, or preview. Split pane is optional.
|
||||
- **Status bar** — item count and size for the active folder, or for the current selection; Git badge; queue.
|
||||
- **Queue** — compact status; expand for the full File Operations Queue.
|
||||
|
||||
### Tabs and panes
|
||||
@@ -65,6 +66,7 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
| Close tab | File → Close tab, or `Ctrl+W` |
|
||||
| Split pane | File → Split pane |
|
||||
| Details / List / Preview | View menu or toolbar |
|
||||
| Type a path | Click empty space in the tab breadcrumb bar, or `Ctrl+L` / `Alt+D` |
|
||||
| Refresh | View → Refresh, or `F5` |
|
||||
|
||||
Tabs, split panes, and the folder shown in each pane are restored the next time you open Workbench.
|
||||
@@ -73,6 +75,18 @@ Tabs, split panes, and the folder shown in each pane are restored the next time
|
||||
|
||||
## Locations
|
||||
|
||||
### Favorites
|
||||
|
||||
Pinned folders of your choosing, at the top of the tree. Add a folder with **Add to Favorites** on the folder or tree context menu, or drop a folder onto the Favorites root. Remove it with **Remove from Favorites**. Unpinning only removes the pin — it does not delete files, forget a location, or change the index.
|
||||
|
||||
Favorites are stored in `ui-preferences.txt`. A pin that points at a missing folder still appears, marked Offline, so you can unpin it.
|
||||
|
||||
The folder pane always shows the real filesystem path. Breadcrumbs stay the same. The locations tree is separate: by default it keeps the branch you are already in. Opening a folder under This PC, Home, Network, or Cloud does not switch the tree to Favorites just because that folder is also pinned. If you opened the folder from a Favorite, the tree stays under Favorites. **Settings → Navigation → Prefer Favorites when synchronizing the locations tree** selects a matching Favorite pin instead.
|
||||
|
||||
### Home
|
||||
|
||||
Documents, Downloads, Pictures, Videos, and Music from Windows known folders. They have their own root, not under This PC. Folders that do not exist on this PC are omitted.
|
||||
|
||||
### This PC
|
||||
|
||||
Local NTFS volumes and removable disks. Capacity and free space show next to size where Windows reports them.
|
||||
@@ -81,6 +95,8 @@ Local NTFS volumes and removable disks. Capacity and free space show next to siz
|
||||
|
||||
Add a UNC path with **Tools → Locations → Add network…** (`\\server\share`). Mapped Windows drive letters can be imported when Workbench discovers them. Forgetting a Workbench location does **not** disconnect the Windows mapping.
|
||||
|
||||
After a Windows start, shares can show **Offline** until they answer. Opening a folder or a file on the share marks it online in the tree; sleeping NAS boxes may take a second or two.
|
||||
|
||||
### Cloud
|
||||
|
||||
OneDrive, Google Drive, and Nextcloud appear when you add their **mounted Windows folders** (**Tools → Locations → Add OneDrive…** / **Google Drive…** / **Nextcloud…**). Workbench browses those paths with Win32 like any other folder. Plugins only overlay status, pin/dehydrate actions, and quota. Cloud folders stay ordinary locations — they are not a separate “cloud filesystem.”
|
||||
@@ -102,13 +118,13 @@ Open it from **Tools → Recycle Bin**. Workbench talks to the real Windows Recy
|
||||
|
||||
The open folder is always the live filesystem when the location is online. Index data fills in folder sizes, search, and analysis.
|
||||
|
||||
Large folders appear as soon as names are known. Size, date, and type from the directory listing show with the row. Cloud status, indexed folder totals, and Git badges fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one.
|
||||
Large folders appear as soon as names are known — the previous folder stays on screen until the first new name arrives. Size, date, and type from the directory listing show with the row. Cloud status, indexed folder totals, and Git badges fill in shortly after — blank cells mean that extra metadata has not arrived yet, not that the file is empty. Opening another folder cancels leftover work from the previous one. The status bar shows how many items are in the active folder and their known size; with a selection it switches to how many are selected and the size of that selection. Folder sizes appear in the total once the index has them.
|
||||
|
||||
**Preview** shows a wrapping thumbnail grid. Only the tiles on screen (plus a small prefetch) are decoded, on background threads. Other files keep a generic icon until you scroll to them. Online-only cloud files are never opened just to make a thumbnail. Returning to a folder reuses thumbnails that are still in memory.
|
||||
|
||||
Sorting by name or type happens once the listing is in. Sorting by size waits until indexed folder totals are applied, so rows do not jump on every update. Clicking a column sorts immediately with whatever is already known.
|
||||
|
||||
Hidden files follow Settings. Protected system locations (`System Volume Information`, Recovery, pagefile, and similar) are hidden unless you turn them on. Recycle Bin folders stay hidden. Access-denied folders show as access denied — never as 0 bytes.
|
||||
Hidden files follow Settings → Navigation. Protected system locations (`System Volume Information`, Recovery, pagefile, and similar) are hidden unless you turn them on. Recycle Bin folders stay hidden. Access-denied folders show as access denied — never as 0 bytes.
|
||||
|
||||
| Shortcut | Action |
|
||||
| --- | --- |
|
||||
@@ -118,13 +134,17 @@ Hidden files follow Settings. Protected system locations (`System Volume Informa
|
||||
| `F5` | Refresh |
|
||||
| `Enter` | Open |
|
||||
| `F2` | Rename (single item, immediate) |
|
||||
| `Ctrl+A` | Select all |
|
||||
| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | Copy / Cut / Paste |
|
||||
| `Delete` | Recycle (queued) |
|
||||
| `Escape` | Cancel rubber-band selection |
|
||||
| `Ctrl+T` / `Ctrl+W` | New tab / Close tab |
|
||||
|
||||
Drag and drop copies by default. Hold `Shift` to move. Right-drag offers a menu.
|
||||
Drag an empty area of the folder list to rubber-band select, as in File Explorer. The rectangle selects every row it touches in Details and List, and every tile it intersects in Preview. `Ctrl` adds to the current selection; `Escape` restores the previous one. Right-drag selects, then opens the context menu.
|
||||
|
||||
Hidden files follow Settings. Protected system locations (`System Volume Information`, Recovery, pagefile, and similar) are hidden unless you turn them on. Recycle Bin folders stay hidden. Access-denied folders show as access denied — never as 0 bytes.
|
||||
Drag and drop copies by default. Hold `Shift` to move. Right-drag of selected items offers a menu.
|
||||
|
||||
Hidden files follow Settings → Navigation. Protected system locations (`System Volume Information`, Recovery, pagefile, and similar) are hidden unless you turn them on. Recycle Bin folders stay hidden. Access-denied folders show as access denied — never as 0 bytes.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,7 +154,11 @@ Indexing is **user-triggered**, then kept current in the background:
|
||||
|
||||
- Local NTFS: USN journal when Windows allows it; otherwise folder reconcile + watcher
|
||||
- Network: scan + watcher (best effort)
|
||||
- Archives: optional (Settings → include archive contents)
|
||||
- Archives: optional (Settings → Indexing → include archive contents)
|
||||
- Folder sizes in Details come from the index. Opening a folder reconciles **that folder only**, then probes the largest or visible child folders (child count vs index). A mismatch queues a targeted verify of that child — not a walk of C:\. Navigating away cancels further probes. Idle maintenance still verifies whole local drives.
|
||||
- After the PC has been idle (Settings → Indexing), the background host checks local indexed drives for folders whose contents no longer match the index. A full rescan still happens only when a drive is marked out of date or has not been indexed for 7 days.
|
||||
- Before Delete, Workbench checks that the path still exists and refreshes that folder in the index. Leftovers under Program Files can still fail if Windows needs administrator rights — that is separate from the stale size.
|
||||
- Idle maintenance: when the PC has been idle (Settings → Indexing), the background host may hash duplicates, recapture history, and rescan stale **local** indexes. Copy, move, delete, explicit scans, and sync you started are not idle work and keep running. Cloud files are never hydrated. Network, cloud, and removable locations are not scanned just because the PC is idle.
|
||||
|
||||
Default excludes include Windows, recycle bins, `node_modules`, `.git`, and similar. Inaccessible paths are skipped.
|
||||
|
||||
@@ -152,6 +176,8 @@ even when that NAS is currently offline — if the archive was indexed earlier.
|
||||
|
||||
**Tools → Storage → Storage analysis** (or the toolbar). WinDirStat-style trees, biggest folders/files, by type, by source. Figures come from the index, so build the index first.
|
||||
|
||||
**Tools → Storage → Run background maintenance now** runs the same idle-maintenance pipeline immediately (still skipped while a copy/move/delete is running). The status bar may show Idle maintenance, Scanning a location, or Paused because user is active.
|
||||
|
||||
---
|
||||
|
||||
## Duplicates
|
||||
@@ -206,7 +232,7 @@ Metadata placeholders such as `{CreatedDate}` or `{Width}` are not implemented y
|
||||
|
||||
## Archives
|
||||
|
||||
Needs **7-Zip** on the machine (Settings can point at `7z.exe`; otherwise Program Files and PATH). 7-Zip is not bundled.
|
||||
Needs **7-Zip** on the machine (Settings → File Operations can point at `7z.exe`; otherwise Program Files and PATH). 7-Zip is not bundled.
|
||||
|
||||
| Action | Menu |
|
||||
| --- | --- |
|
||||
@@ -221,7 +247,7 @@ Jobs go through the queue. Online-only cloud archives are refused so Workbench w
|
||||
|
||||
## Convert
|
||||
|
||||
Needs **`ffmpeg.exe`** on the machine. FFmpeg is usually a zip, not an installer: unpack a Windows build and either put `ffmpeg.exe` on PATH, under `Program Files\ffmpeg\bin\`, or point Settings at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
|
||||
Needs **`ffmpeg.exe`** on the machine. FFmpeg is usually a zip, not an installer: unpack a Windows build and either put `ffmpeg.exe` on PATH, under `Program Files\ffmpeg\bin\`, or point Settings → File Operations at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
|
||||
|
||||
| Kind | Output |
|
||||
| --- | --- |
|
||||
@@ -300,7 +326,7 @@ Workbench detects repositories and shows a badge (branch, modified, untracked, a
|
||||
|
||||
**Pull (fast-forward)** is `git pull --ff-only --no-rebase`. If that cannot fast-forward, Workbench offers **Pull (merge)** (`git pull --no-rebase`) or a terminal. Fetch and push are the matching `git` commands. There is no stash, branch UI, mergetool, or credential dialog (`GIT_TERMINAL_PROMPT=0`).
|
||||
|
||||
Missing `git.exe` means no badge and no Git actions. Path can be set in Settings. Git is not bundled. Profiles can require a clean working tree.
|
||||
Missing `git.exe` means no badge and no Git actions. Path can be set in Settings → Advanced. Git is not bundled. Profiles can require a clean working tree.
|
||||
|
||||
---
|
||||
|
||||
@@ -318,21 +344,18 @@ Workbench never starts a cloud vendor’s own two-way sync. It never hydrates a
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings** in the menu:
|
||||
**Settings** opens a window with categories on the left. The last category you opened is remembered until you quit Workbench. Empty categories are omitted.
|
||||
|
||||
- Dark / Light theme
|
||||
- Group network under Network; group cloud under Cloud (independent)
|
||||
- Show hidden files
|
||||
- Show protected system locations
|
||||
- Auto-clear queue when done
|
||||
- Include archive contents in the index
|
||||
- Automatically index removable drives when they appear
|
||||
- Start Explorer.Host.exe at Windows sign-in (current-user Startup, no administrator rights)
|
||||
- Path to 7-Zip
|
||||
- Path to git.exe
|
||||
- Path to ffmpeg.exe
|
||||
| Category | Options |
|
||||
| --- | --- |
|
||||
| General | Start Explorer.Host.exe at Windows sign-in (current-user Startup, no administrator rights) |
|
||||
| Appearance | Dark / Light theme |
|
||||
| Navigation | Group network under Network; group cloud under Cloud (independent); prefer Favorites when synchronizing the locations tree (off by default); show hidden files; show protected system locations |
|
||||
| File Operations | Auto-clear queue when done; path to 7-Zip; path to ffmpeg.exe |
|
||||
| Indexing | Include archive contents in the index; automatically index removable drives when they appear; enable idle background maintenance; idle threshold (5 / 10 / 30 minutes); only run expensive maintenance on AC power |
|
||||
| Advanced | Path to git.exe |
|
||||
|
||||
These options change what Workbench shows and indexes. They do not change Windows Explorer settings.
|
||||
These options change what Workbench shows and indexes. They do not change Windows Explorer settings. Layout, favorite pins, organize destinations, and open tabs are stored in `ui-preferences.txt` but are not edited here.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Analysis;
|
||||
|
||||
public sealed class DuplicateHashWorker : BackgroundService
|
||||
public sealed class DuplicateHashWorker : BackgroundService, IIdleHashWork
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly IHydrationGuard _hydration;
|
||||
private readonly ILogger<DuplicateHashWorker> _logger;
|
||||
private volatile bool _paused;
|
||||
private volatile bool _paused = true;
|
||||
private volatile bool _userRequested;
|
||||
|
||||
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
|
||||
{
|
||||
@@ -21,15 +22,20 @@ public sealed class DuplicateHashWorker : BackgroundService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsPaused => _paused && !_userRequested;
|
||||
public void Pause() => _paused = true;
|
||||
public void Resume() => _paused = false;
|
||||
public void BeginUserRequested() => _userRequested = true;
|
||||
|
||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||
=> await _store.Hashes.HasPendingAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
|
||||
foreach (var item in batch)
|
||||
{
|
||||
if (_paused || cancellationToken.IsCancellationRequested)
|
||||
if ((_paused && !_userRequested) || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -92,7 +98,7 @@ public sealed class DuplicateHashWorker : BackgroundService
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
if (_paused)
|
||||
if (_paused && !_userRequested)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -100,6 +106,10 @@ public sealed class DuplicateHashWorker : BackgroundService
|
||||
try
|
||||
{
|
||||
await ProcessPendingAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (_userRequested && !await HasPendingAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
_userRequested = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -123,28 +133,18 @@ public sealed class DuplicateHashWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HistoryRollupService : BackgroundService
|
||||
public sealed class HistoryRollupService : IHistoryMaintenance
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
|
||||
public HistoryRollupService(IIndexStore store) => _store = store;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
|
||||
await CaptureAsync(stoppingToken).ConfigureAwait(false);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
await CaptureAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CaptureAsync(CancellationToken cancellationToken)
|
||||
public async Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if ((DateTime.UtcNow - _last).TotalHours < 20)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -160,5 +160,6 @@ public sealed class HistoryRollupService : BackgroundService
|
||||
var days = AppConstants.DefaultTombstoneRetentionDays;
|
||||
await _store.Entries.DeleteExpiredTombstonesAsync(utc.AddDays(-days), cancellationToken).ConfigureAwait(false);
|
||||
_last = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<RootNamespace>Explorer.Analysis</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Dark.xaml"/>
|
||||
<ResourceDictionary Source="Settings/SettingsStyles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<FontFamily x:Key="Symbol">Segoe MDL2 Assets</FontFamily>
|
||||
<BooleanToVisibilityConverter x:Key="BoolVis"/>
|
||||
<local:InverseBooleanToVisibilityConverter x:Key="InvBoolVis"/>
|
||||
<local:ActiveThicknessConverter x:Key="ActiveThickness"/>
|
||||
<local:FractionWidthConverter x:Key="FractionWidth"/>
|
||||
<local:IndentConverter x:Key="Indent"/>
|
||||
@@ -141,67 +143,6 @@
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
<DataTemplate x:Key="PaneAddressBar">
|
||||
<DockPanel LastChildFill="True" Margin="6,6,6,4">
|
||||
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
|
||||
Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding UpCommand}"
|
||||
IsEnabled="{Binding CanGoUp}"
|
||||
ToolTip="Up one folder"/>
|
||||
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="4,0" MinHeight="28">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
Focusable="False">
|
||||
<ItemsControl ItemsSource="{Binding Breadcrumb}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip="{Binding Path}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock FontFamily="{StaticResource Symbol}" Text="" FontSize="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Label}" Value="This PC">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock Text="›" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
<Style x:Key="PaneChrome" TargetType="Border">
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||
@@ -706,6 +647,7 @@
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||
</Trigger>
|
||||
<DataTrigger Binding="{Binding IsDropTarget}" Value="True">
|
||||
|
||||
@@ -5,6 +5,15 @@ using Explorer.Domain;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
public sealed class InverseBooleanToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> value is true ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public sealed class ActiveThicknessConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
|
||||
79
src/Explorer.App/ExplorerAddressBar.xaml
Normal file
79
src/Explorer.App/ExplorerAddressBar.xaml
Normal file
@@ -0,0 +1,79 @@
|
||||
<UserControl x:Class="Explorer.App.ExplorerAddressBar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Focusable="False">
|
||||
<DockPanel LastChildFill="True" Margin="6,6,6,4">
|
||||
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
|
||||
Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding UpCommand}"
|
||||
IsEnabled="{Binding CanGoUp}"
|
||||
ToolTip="Up one folder"/>
|
||||
<Grid MinHeight="28">
|
||||
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="4,0"
|
||||
Cursor="IBeam"
|
||||
MouseLeftButtonDown="OnBreadcrumbMouseDown"
|
||||
Visibility="{Binding IsEditingPath, Converter={StaticResource InvBoolVis}}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
Focusable="False" HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding Breadcrumb}" HorizontalAlignment="Left">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
Cursor="Hand"
|
||||
ToolTip="{Binding Path}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock FontFamily="{StaticResource Symbol}" Text="" FontSize="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Label}" Value="This PC">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock Text="›" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<ComboBox x:Name="PathCombo"
|
||||
Style="{StaticResource PathComboBox}"
|
||||
VerticalAlignment="Stretch"
|
||||
ItemsSource="{Binding DataContext.PathHistory, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Text="{Binding PathEditText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Visibility="{Binding IsEditingPath, Converter={StaticResource BoolVis}}"
|
||||
PreviewKeyDown="OnPathKeyDown"
|
||||
SelectionChanged="OnPathHistorySelected"
|
||||
LostKeyboardFocus="OnPathLostKeyboardFocus"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
178
src/Explorer.App/ExplorerAddressBar.xaml.cs
Normal file
178
src/Explorer.App/ExplorerAddressBar.xaml.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
public partial class ExplorerAddressBar : UserControl
|
||||
{
|
||||
private bool _suppressLostFocus;
|
||||
|
||||
public ExplorerAddressBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
IsVisibleChanged += (_, _) =>
|
||||
{
|
||||
if (IsVisible && DataContext is ExplorerPaneViewModel { IsEditingPath: true })
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void FocusEditor()
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
PathCombo.ApplyTemplate();
|
||||
if (PathCombo.Template.FindName("PART_EditableTextBox", PathCombo) is TextBox box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
else
|
||||
{
|
||||
PathCombo.Focus();
|
||||
}
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is ExplorerPaneViewModel previous)
|
||||
{
|
||||
previous.PropertyChanged -= OnPanePropertyChanged;
|
||||
}
|
||||
|
||||
if (e.NewValue is ExplorerPaneViewModel pane)
|
||||
{
|
||||
pane.PropertyChanged += OnPanePropertyChanged;
|
||||
if (pane.IsEditingPath)
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ExplorerPaneViewModel.IsEditingPath)
|
||||
&& sender is ExplorerPaneViewModel { IsEditingPath: true })
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBreadcrumbMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pane.BeginEditPath();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPathKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
CommitPath();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
_suppressLostFocus = true;
|
||||
pane.CancelEditPath();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (NavigationTreeViewModel.PathsEqual(path, pane.CurrentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
|
||||
{
|
||||
pane.PathEditText = path;
|
||||
CommitPath();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
if (_suppressLostFocus)
|
||||
{
|
||||
_suppressLostFocus = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PathCombo.IsDropDownOpen || PathCombo.IsKeyboardFocusWithin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is ExplorerPaneViewModel pane)
|
||||
{
|
||||
pane.CancelEditPath();
|
||||
}
|
||||
}, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
private void CommitPath()
|
||||
{
|
||||
if (Window.GetWindow(this)?.DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.GoCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInsideButton(DependencyObject origin)
|
||||
{
|
||||
for (var current = origin; current is not null;)
|
||||
{
|
||||
if (current is Button)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current is Visual
|
||||
? VisualTreeHelper.GetParent(current)
|
||||
: LogicalTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
462
src/Explorer.App/ListMarquee.cs
Normal file
462
src/Explorer.App/ListMarquee.cs
Normal file
@@ -0,0 +1,462 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
internal sealed class ListMarquee
|
||||
{
|
||||
private readonly DispatcherTimer _scroll = new() { Interval = TimeSpan.FromMilliseconds(20) };
|
||||
private ListView? _list;
|
||||
private MarqueeAdorner? _adorner;
|
||||
private Point _startContent;
|
||||
private Point _currentList;
|
||||
private object[] _kept = [];
|
||||
private MouseButton _button;
|
||||
|
||||
public ListMarquee() => _scroll.Tick += (_, _) => AutoScroll();
|
||||
|
||||
public bool IsArmed { get; private set; }
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public void Arm(ListView list, MouseButtonEventArgs e, bool additive)
|
||||
{
|
||||
Cancel();
|
||||
_list = list;
|
||||
_button = e.ChangedButton;
|
||||
_kept = additive ? list.SelectedItems.Cast<object>().ToArray() : [];
|
||||
_startContent = ToContent(list, e.GetPosition(list));
|
||||
_currentList = e.GetPosition(list);
|
||||
IsArmed = true;
|
||||
list.Focus();
|
||||
if (!additive)
|
||||
{
|
||||
list.UnselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void Disarm()
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
IsArmed = false;
|
||||
_list = null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryActivate(Point currentOnList)
|
||||
{
|
||||
if (!IsArmed || IsActive || _list is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.Abs(currentOnList.X - _currentList.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||
&& Math.Abs(currentOnList.Y - _currentList.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IsActive = true;
|
||||
_list.CaptureMouse();
|
||||
_list.Focus();
|
||||
var layer = AdornerLayer.GetAdornerLayer(_list);
|
||||
if (layer is not null)
|
||||
{
|
||||
_adorner = new MarqueeAdorner(_list);
|
||||
layer.Add(_adorner);
|
||||
}
|
||||
|
||||
_scroll.Start();
|
||||
Update(currentOnList);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Update(Point currentOnList)
|
||||
{
|
||||
if (!IsActive || _list is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentList = currentOnList;
|
||||
ApplyHits();
|
||||
UpdateAdorner();
|
||||
}
|
||||
|
||||
public bool TryActivateFromMouse()
|
||||
=> _list is not null && TryActivate(Mouse.GetPosition(_list));
|
||||
|
||||
public void UpdateFromMouse()
|
||||
{
|
||||
if (_list is not null)
|
||||
{
|
||||
Update(Mouse.GetPosition(_list));
|
||||
}
|
||||
}
|
||||
|
||||
public ListView? CompleteForContextMenu()
|
||||
{
|
||||
var list = _list;
|
||||
var openMenu = IsActive && _button == MouseButton.Right;
|
||||
Stop(restore: false);
|
||||
return openMenu ? list : null;
|
||||
}
|
||||
|
||||
public void Cancel() => Stop(restore: IsActive);
|
||||
|
||||
private void Stop(bool restore)
|
||||
{
|
||||
_scroll.Stop();
|
||||
if (_list is not null)
|
||||
{
|
||||
if (_adorner is not null)
|
||||
{
|
||||
AdornerLayer.GetAdornerLayer(_list)?.Remove(_adorner);
|
||||
}
|
||||
|
||||
if (_list.IsMouseCaptured)
|
||||
{
|
||||
_list.ReleaseMouseCapture();
|
||||
}
|
||||
|
||||
if (restore)
|
||||
{
|
||||
Restore(_list, _kept);
|
||||
}
|
||||
}
|
||||
|
||||
_adorner = null;
|
||||
_list = null;
|
||||
_kept = [];
|
||||
IsArmed = false;
|
||||
IsActive = false;
|
||||
}
|
||||
|
||||
private void ApplyHits()
|
||||
{
|
||||
if (_list is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var next = new HashSet<object>(_kept);
|
||||
foreach (var item in HitItems(_list, MarqueeListRect()))
|
||||
{
|
||||
next.Add(item);
|
||||
}
|
||||
|
||||
if (next.Count == _list.SelectedItems.Count && next.SetEquals(_list.SelectedItems.Cast<object>()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_list.UnselectAll();
|
||||
foreach (var item in next)
|
||||
{
|
||||
_list.SelectedItems.Add(item);
|
||||
if (_list.ItemContainerGenerator.ContainerFromItem(item) is ListViewItem row)
|
||||
{
|
||||
row.IsSelected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Rect MarqueeListRect()
|
||||
{
|
||||
var start = FromContent(_list!, _startContent);
|
||||
var rect = new Rect(start, _currentList);
|
||||
if (rect.Width < 1)
|
||||
{
|
||||
rect.Width = 1;
|
||||
}
|
||||
|
||||
if (rect.Height < 1)
|
||||
{
|
||||
rect.Height = 1;
|
||||
}
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> HitItems(ListView list, Rect marquee)
|
||||
{
|
||||
var wrap = FindWrapPanel(list);
|
||||
var viewport = ViewportBounds(list);
|
||||
var sampleIndex = -1;
|
||||
var sampleBounds = Rect.Empty;
|
||||
var hits = new HashSet<int>();
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.ItemContainerGenerator.ContainerFromIndex(i) is not ListViewItem row
|
||||
|| row.ActualHeight < 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var origin = row.TranslatePoint(new Point(0, 0), list);
|
||||
var bounds = wrap is null
|
||||
? new Rect(viewport.X, origin.Y, Math.Max(viewport.Width, 1), row.ActualHeight)
|
||||
: new Rect(origin, new Size(Math.Max(row.ActualWidth, 1), Math.Max(row.ActualHeight, 1)));
|
||||
if (sampleIndex < 0)
|
||||
{
|
||||
sampleIndex = i;
|
||||
sampleBounds = bounds;
|
||||
}
|
||||
|
||||
if (bounds.IntersectsWith(marquee))
|
||||
{
|
||||
hits.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrap is null && sampleIndex >= 0 && sampleBounds.Height > 0)
|
||||
{
|
||||
var first = (int)Math.Floor((marquee.Top - sampleBounds.Y) / sampleBounds.Height) + sampleIndex;
|
||||
var last = (int)Math.Ceiling((marquee.Bottom - sampleBounds.Y) / sampleBounds.Height) - 1 + sampleIndex;
|
||||
first = Math.Clamp(first, 0, list.Items.Count - 1);
|
||||
last = Math.Clamp(last, 0, list.Items.Count - 1);
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
hits.Add(i);
|
||||
}
|
||||
}
|
||||
else if (wrap is not null)
|
||||
{
|
||||
foreach (var index in Hits(list, ToContent(list, marquee.TopLeft), ToContent(list, marquee.BottomRight)))
|
||||
{
|
||||
hits.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var index in hits.OrderBy(i => i))
|
||||
{
|
||||
yield return list.Items[index];
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdorner()
|
||||
{
|
||||
if (_list is null || _adorner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = MarqueeListRect();
|
||||
rect.Intersect(ViewportBounds(_list));
|
||||
_adorner.Bounds = rect;
|
||||
_adorner.InvalidateVisual();
|
||||
}
|
||||
|
||||
private void AutoScroll()
|
||||
{
|
||||
if (_list is null || Mouse.LeftButton != MouseButtonState.Pressed && Mouse.RightButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = Mouse.GetPosition(_list);
|
||||
_currentList = pos;
|
||||
var zone = 32d;
|
||||
var viewer = FindScrollViewer(_list);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var local = _list.TranslatePoint(pos, viewer);
|
||||
if (local.Y < zone)
|
||||
{
|
||||
viewer.LineUp();
|
||||
}
|
||||
else if (local.Y > viewer.ActualHeight - zone)
|
||||
{
|
||||
viewer.LineDown();
|
||||
}
|
||||
}
|
||||
|
||||
ApplyHits();
|
||||
UpdateAdorner();
|
||||
}
|
||||
|
||||
public static bool IsBackground(DependencyObject? source)
|
||||
{
|
||||
while (source is not null)
|
||||
{
|
||||
if (source is ListViewItem or GridViewColumnHeader or ScrollBar or Thumb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (source is ListView)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
source = source is Visual
|
||||
? VisualTreeHelper.GetParent(source)
|
||||
: LogicalTreeHelper.GetParent(source);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> Hits(ListView list, Point startContent, Point currentContent)
|
||||
{
|
||||
var count = list.Items.Count;
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
return MarqueeRange.Wrap(
|
||||
startContent.X, startContent.Y, currentContent.X, currentContent.Y,
|
||||
count, wrap.Columns, wrap.ItemWidth, wrap.ItemHeight);
|
||||
}
|
||||
|
||||
return MarqueeRange.Stack(startContent.Y, currentContent.Y, count, RowHeight(list));
|
||||
}
|
||||
|
||||
private static Point ToContent(ListView list, Point listPoint)
|
||||
{
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
var p = list.TranslatePoint(listPoint, wrap);
|
||||
return new Point(p.X, p.Y + wrap.VerticalOffset);
|
||||
}
|
||||
|
||||
var presenter = (UIElement?)FindItemsPresenter(list) ?? list;
|
||||
var local = list.TranslatePoint(listPoint, presenter);
|
||||
var viewer = FindScrollViewer(list);
|
||||
var height = RowHeight(list);
|
||||
if (viewer is { CanContentScroll: true } && height > 0)
|
||||
{
|
||||
return new Point(local.X, viewer.VerticalOffset * height + local.Y);
|
||||
}
|
||||
|
||||
return new Point(local.X, (viewer?.VerticalOffset ?? 0) + local.Y);
|
||||
}
|
||||
|
||||
private static Point FromContent(ListView list, Point content)
|
||||
{
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
return wrap.TranslatePoint(new Point(content.X, content.Y - wrap.VerticalOffset), list);
|
||||
}
|
||||
|
||||
var presenter = FindItemsPresenter(list);
|
||||
if (presenter is null)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
var viewer = FindScrollViewer(list);
|
||||
var height = RowHeight(list);
|
||||
double y;
|
||||
if (viewer is { CanContentScroll: true } && height > 0)
|
||||
{
|
||||
y = content.Y - viewer.VerticalOffset * height;
|
||||
}
|
||||
else
|
||||
{
|
||||
y = content.Y - (viewer?.VerticalOffset ?? 0);
|
||||
}
|
||||
|
||||
return presenter.TranslatePoint(new Point(content.X, y), list);
|
||||
}
|
||||
|
||||
private static Rect ViewportBounds(ListView list)
|
||||
{
|
||||
var presenter = FindItemsPresenter(list);
|
||||
if (presenter is null)
|
||||
{
|
||||
return new Rect(list.RenderSize);
|
||||
}
|
||||
|
||||
var origin = presenter.TranslatePoint(new Point(0, 0), list);
|
||||
return new Rect(origin, presenter.RenderSize);
|
||||
}
|
||||
|
||||
private static double RowHeight(ListView list)
|
||||
{
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.ItemContainerGenerator.ContainerFromIndex(i) is ListViewItem { ActualHeight: > 1 } row)
|
||||
{
|
||||
return row.ActualHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return 28;
|
||||
}
|
||||
|
||||
private static void Restore(ListView list, IReadOnlyList<object> kept)
|
||||
{
|
||||
list.UnselectAll();
|
||||
foreach (var item in kept)
|
||||
{
|
||||
list.SelectedItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemsPresenter? FindItemsPresenter(DependencyObject root) => FindChild<ItemsPresenter>(root);
|
||||
|
||||
private static ScrollViewer? FindScrollViewer(DependencyObject root) => FindChild<ScrollViewer>(root);
|
||||
|
||||
private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root) => FindChild<VirtualizingWrapPanel>(root);
|
||||
|
||||
private static T? FindChild<T>(DependencyObject root)
|
||||
where T : DependencyObject
|
||||
{
|
||||
if (root is T match)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
|
||||
{
|
||||
var found = FindChild<T>(VisualTreeHelper.GetChild(root, i));
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class MarqueeAdorner : Adorner
|
||||
{
|
||||
public MarqueeAdorner(UIElement adorned) : base(adorned)
|
||||
{
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
|
||||
public Rect Bounds { get; set; }
|
||||
|
||||
protected override void OnRender(DrawingContext drawingContext)
|
||||
{
|
||||
if (Bounds.IsEmpty || Bounds.Width < 1 || Bounds.Height < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var accent = TryAccent();
|
||||
var fill = new SolidColorBrush(Color.FromArgb(0x55, accent.R, accent.G, accent.B));
|
||||
var stroke = new SolidColorBrush(Color.FromArgb(0xE0, accent.R, accent.G, accent.B));
|
||||
fill.Freeze();
|
||||
stroke.Freeze();
|
||||
drawingContext.DrawRectangle(fill, new Pen(stroke, 1), Bounds);
|
||||
}
|
||||
|
||||
private Color TryAccent()
|
||||
{
|
||||
if (AdornedElement is FrameworkElement fe
|
||||
&& fe.TryFindResource("Accent") is SolidColorBrush brush)
|
||||
{
|
||||
return brush.Color;
|
||||
}
|
||||
|
||||
return Color.FromRgb(0x60, 0xCD, 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,81 @@
|
||||
CornerRadius="0"
|
||||
UseAeroCaptionButtons="False"/>
|
||||
</shell:WindowChrome.WindowChrome>
|
||||
<Window.Resources>
|
||||
<ContextMenu x:Key="FolderListContextMenu" x:Shared="false">
|
||||
<MenuItem Header="Open" Click="OnCtxOpen"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
|
||||
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
|
||||
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
|
||||
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
|
||||
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||
<MenuItem Header="Batch rename…" Click="OnBatchRename"
|
||||
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Run profile" Tag="RunProfileMenu"
|
||||
IsEnabled="{Binding ShowRunProfile}">
|
||||
<MenuItem Header="No profiles yet" IsEnabled="False"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
|
||||
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Extract here" Click="OnExtractHere"
|
||||
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Extract to…" Click="OnExtractTo"
|
||||
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
|
||||
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
|
||||
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
|
||||
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
|
||||
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Convert…" Click="OnConvert"
|
||||
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
|
||||
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
|
||||
<MenuItem Header="View changes…" Click="OnGitChanges"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Commit…" Click="OnGitCommit"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Fetch" Click="OnGitFetch"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Push" Click="OnGitPush"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
|
||||
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
|
||||
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to Favorites" Click="OnAddFavorite"
|
||||
Visibility="{Binding ShowAddFavorite, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Remove from Favorites" Click="OnRemoveFavorite"
|
||||
Visibility="{Binding ShowRemoveFavorite, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
|
||||
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
|
||||
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
|
||||
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
|
||||
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
|
||||
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Always keep on this device"
|
||||
Command="{Binding PinCloudCommand}"
|
||||
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Free up space"
|
||||
Command="{Binding FreeUpCloudSpaceCommand}"
|
||||
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
|
||||
</ContextMenu>
|
||||
</Window.Resources>
|
||||
<AdornerDecorator>
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
|
||||
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
|
||||
@@ -61,6 +136,7 @@
|
||||
<MenuItem Header="_Storage">
|
||||
<MenuItem Header="Storage _analysis" Command="{Binding Analysis.OpenCommand}"/>
|
||||
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
|
||||
<MenuItem Header="Run background _maintenance now" Command="{Binding RunMaintenanceNowCommand}"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="_Locations">
|
||||
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
|
||||
@@ -184,12 +260,8 @@
|
||||
</Button.Style>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
|
||||
ItemsSource="{Binding PathHistory}"
|
||||
Text="{Binding PathText, UpdateSourceTrigger=PropertyChanged}"
|
||||
PreviewKeyDown="OnPathKeyDown"
|
||||
SelectionChanged="OnPathHistorySelected"/>
|
||||
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
||||
<TextBox Grid.Column="2" Margin="12,0,0,0"
|
||||
Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
||||
KeyDown="OnSearchKeyDown"/>
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
|
||||
<Button Content="Search" Command="{Binding SearchCommand}"/>
|
||||
@@ -200,12 +272,15 @@
|
||||
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
|
||||
<TextBlock Text="{Binding ActivePane.ListingStatus}" Foreground="{DynamicResource Fg}"
|
||||
VerticalAlignment="Center" Margin="0,0,16,0"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding ActivePane.GitBadge}" VerticalAlignment="Center" FontSize="11"
|
||||
Foreground="{DynamicResource FgMuted}" Margin="0,0,16,0"
|
||||
Visibility="{Binding ActivePane.HasGitBadge, Converter={StaticResource BoolVis}}"/>
|
||||
@@ -363,6 +438,8 @@
|
||||
HorizontalContentAlignment="Stretch">
|
||||
<TreeView.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem x:Name="AddFavoriteMenu" Header="Add to Favorites" Click="OnAddFavoriteFromTree"/>
|
||||
<MenuItem x:Name="RemoveFavoriteMenu" Header="Remove from Favorites" Click="OnRemoveFavoriteFromTree"/>
|
||||
<MenuItem x:Name="RemoveLocationMenu" Header="Remove from Explorer" Click="OnRemoveLocation"/>
|
||||
</ContextMenu>
|
||||
</TreeView.ContextMenu>
|
||||
@@ -405,8 +482,7 @@
|
||||
<Border Grid.Column="0" DataContext="{Binding Left}" Style="{StaticResource PaneChrome}"
|
||||
PreviewMouseDown="OnPaneChromeMouseDown">
|
||||
<DockPanel LastChildFill="True">
|
||||
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
|
||||
Focusable="False"/>
|
||||
<local:ExplorerAddressBar DockPanel.Dock="Top"/>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
|
||||
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
|
||||
<DockPanel>
|
||||
@@ -417,6 +493,9 @@
|
||||
</Border>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -445,78 +524,11 @@
|
||||
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
<ListView.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="Open" Click="OnCtxOpen"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Cut" Command="{Binding CutCommand}"/>
|
||||
<MenuItem Header="Copy" Command="{Binding CopyCommand}"/>
|
||||
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
|
||||
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
|
||||
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||
<MenuItem Header="Batch rename…" Click="OnBatchRename"
|
||||
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Run profile" Tag="RunProfileMenu"
|
||||
IsEnabled="{Binding ShowRunProfile}">
|
||||
<MenuItem Header="No profiles yet" IsEnabled="False"/>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
|
||||
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Extract here" Click="OnExtractHere"
|
||||
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Extract to…" Click="OnExtractTo"
|
||||
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
|
||||
Visibility="{Binding ShowVerifyArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
|
||||
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
|
||||
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
|
||||
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Convert…" Click="OnConvert"
|
||||
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
|
||||
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
|
||||
<MenuItem Header="View changes…" Click="OnGitChanges"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Commit…" Click="OnGitCommit"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Fetch" Click="OnGitFetch"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Push" Click="OnGitPush"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
|
||||
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
|
||||
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
|
||||
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
|
||||
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
|
||||
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
|
||||
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
|
||||
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Always keep on this device"
|
||||
Command="{Binding PinCloudCommand}"
|
||||
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Free up space"
|
||||
Command="{Binding FreeUpCloudSpaceCommand}"
|
||||
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
|
||||
</ContextMenu>
|
||||
</ListView.ContextMenu>
|
||||
</ListView>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -543,6 +555,9 @@
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
ItemTemplate="{StaticResource PreviewTile}"
|
||||
ItemContainerStyle="{StaticResource IconListItem}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
@@ -587,8 +602,7 @@
|
||||
PreviewMouseDown="OnPaneChromeMouseDown"
|
||||
Visibility="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}, Converter={StaticResource BoolVis}}">
|
||||
<DockPanel LastChildFill="True">
|
||||
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
|
||||
Focusable="False"/>
|
||||
<local:ExplorerAddressBar DockPanel.Dock="Top"/>
|
||||
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
|
||||
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
|
||||
<DockPanel>
|
||||
@@ -599,6 +613,9 @@
|
||||
</Border>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -629,6 +646,9 @@
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -655,6 +675,9 @@
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
ItemTemplate="{StaticResource PreviewTile}"
|
||||
ItemContainerStyle="{StaticResource IconListItem}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
@@ -1007,4 +1030,5 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</AdornerDecorator>
|
||||
</Window>
|
||||
|
||||
@@ -21,6 +21,7 @@ public partial class MainWindow : Window
|
||||
private bool _dragPending;
|
||||
private MouseButton _dragButton;
|
||||
private FolderItemViewModel? _dragItem;
|
||||
private readonly ListMarquee _marquee = new();
|
||||
private bool _suppressItemContextMenu;
|
||||
private bool _incomingRightDrag;
|
||||
private bool _sourceRightDrag;
|
||||
@@ -34,6 +35,8 @@ public partial class MainWindow : Window
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
SourceInitialized += (_, _) => MaximizedWorkArea.Hook(this);
|
||||
StateChanged += (_, _) => SyncMaxRestoreButton();
|
||||
DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (_wiredVm is not null)
|
||||
@@ -186,34 +189,6 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
Vm.GoCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (NavigationTreeViewModel.PathsEqual(path, Vm.ActivePane.CurrentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
|
||||
{
|
||||
Vm.PathText = path;
|
||||
Vm.GoCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
@@ -245,16 +220,57 @@ public partial class MainWindow : Window
|
||||
}
|
||||
|
||||
var node = FindTreeNode(e.OriginalSource as DependencyObject);
|
||||
if (node is null || !node.CanRemove)
|
||||
if (node is null)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var canPin = Vm.CanPinFavorite(node);
|
||||
var canUnpin = Vm.CanUnpinFavorite(node.Path);
|
||||
AddFavoriteMenu.Visibility = canPin ? Visibility.Visible : Visibility.Collapsed;
|
||||
RemoveFavoriteMenu.Visibility = canUnpin ? Visibility.Visible : Visibility.Collapsed;
|
||||
RemoveLocationMenu.Visibility = node.CanRemove ? Visibility.Visible : Visibility.Collapsed;
|
||||
if (!canPin && !canUnpin && !node.CanRemove)
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
node.IsSelected = true;
|
||||
AddFavoriteMenu.Tag = node.Path;
|
||||
RemoveFavoriteMenu.Tag = node.Path;
|
||||
RemoveLocationMenu.Tag = node.Path;
|
||||
}
|
||||
|
||||
private async void OnAddFavorite(object sender, RoutedEventArgs e)
|
||||
=> await Vm.AddSelectedFavoritesAsync().ConfigureAwait(true);
|
||||
|
||||
private async void OnRemoveFavorite(object sender, RoutedEventArgs e)
|
||||
=> await Vm.RemoveSelectedFavoritesAsync().ConfigureAwait(true);
|
||||
|
||||
private async void OnAddFavoriteFromTree(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = (sender as FrameworkElement)?.Tag as string ?? AddFavoriteMenu.Tag as string;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Vm.AddFavoritesAsync([path]).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async void OnRemoveFavoriteFromTree(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = (sender as FrameworkElement)?.Tag as string ?? RemoveFavoriteMenu.Tag as string;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Vm.RemoveFavoritesAsync([path]).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async void OnRemoveLocation(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var path = (sender as FrameworkElement)?.Tag as string
|
||||
@@ -560,7 +576,7 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView list)
|
||||
if (sender is not ListView { IsVisible: true } list)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -608,6 +624,18 @@ public partial class MainWindow : Window
|
||||
_dragPending = true;
|
||||
_dragButton = e.ChangedButton;
|
||||
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
|
||||
if (sender is ListView list
|
||||
&& _dragItem is null
|
||||
&& ListMarquee.IsBackground(e.OriginalSource as DependencyObject))
|
||||
{
|
||||
_marquee.Arm(list, e, (Keyboard.Modifiers & ModifierKeys.Control) != 0);
|
||||
e.Handled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_marquee.Disarm();
|
||||
}
|
||||
|
||||
TryScheduleClickRename(sender as ListView, e);
|
||||
}
|
||||
|
||||
@@ -729,6 +757,34 @@ public partial class MainWindow : Window
|
||||
base.OnPreviewMouseMove(e);
|
||||
var held = _dragButton == MouseButton.Left && e.LeftButton == MouseButtonState.Pressed
|
||||
|| _dragButton == MouseButton.Right && e.RightButton == MouseButtonState.Pressed;
|
||||
if (_marquee.IsArmed || _marquee.IsActive)
|
||||
{
|
||||
if (!held)
|
||||
{
|
||||
if (!_marquee.IsActive)
|
||||
{
|
||||
_marquee.Disarm();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (_marquee.IsActive)
|
||||
{
|
||||
_marquee.UpdateFromMouse();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_marquee.TryActivateFromMouse())
|
||||
{
|
||||
_dragPending = false;
|
||||
CancelClickRename();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_dragPending || !held)
|
||||
{
|
||||
return;
|
||||
@@ -784,15 +840,69 @@ public partial class MainWindow : Window
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseLeftButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
if (CompleteMarqueeMouseUp(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnPreviewMouseLeftButtonUp(e);
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseRightButtonUp(MouseButtonEventArgs e)
|
||||
{
|
||||
if (CompleteMarqueeMouseUp(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnPreviewMouseRightButtonUp(e);
|
||||
}
|
||||
|
||||
protected override void OnPreviewMouseUp(MouseButtonEventArgs e)
|
||||
{
|
||||
if (CompleteMarqueeMouseUp(e))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
base.OnPreviewMouseUp(e);
|
||||
if (e.ChangedButton == _dragButton)
|
||||
{
|
||||
_dragPending = false;
|
||||
_marquee.Disarm();
|
||||
}
|
||||
}
|
||||
|
||||
private bool CompleteMarqueeMouseUp(MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.ChangedButton != _dragButton)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_marquee.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_dragPending = false;
|
||||
var list = _marquee.CompleteForContextMenu();
|
||||
e.Handled = true;
|
||||
if (list?.ContextMenu is { } menu)
|
||||
{
|
||||
_suppressItemContextMenu = true;
|
||||
menu.DataContext = DataContext;
|
||||
_ = FillRunProfileMenuAsync(menu);
|
||||
menu.PlacementTarget = list;
|
||||
menu.Placement = PlacementMode.MousePoint;
|
||||
menu.IsOpen = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryGetDropFiles(DragEventArgs e, out string[] files)
|
||||
{
|
||||
if (e.Data.GetDataPresent(DataFormats.FileDrop) && e.Data.GetData(DataFormats.FileDrop) is string[] dropped && dropped.Length > 0)
|
||||
@@ -909,6 +1019,15 @@ public partial class MainWindow : Window
|
||||
|
||||
_incomingRightDrag = (e.KeyStates & DragDropKeyStates.RightMouseButton) != 0;
|
||||
var node = HitTestTreeNode(e);
|
||||
if (IsFavoritesPinTarget(node) && files.Any(Directory.Exists))
|
||||
{
|
||||
SetListDropTarget(null);
|
||||
SetTreeDropTarget(node);
|
||||
e.Effects = DragDropEffects.Link;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var dest = node is null || node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path)
|
||||
? null
|
||||
: node.Path;
|
||||
@@ -936,6 +1055,15 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var node = HitTestTreeNode(e);
|
||||
if (IsFavoritesPinTarget(node))
|
||||
{
|
||||
_incomingRightDrag = false;
|
||||
_sourceRightDrag = false;
|
||||
await Vm.AddFavoritesAsync(files).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var dest = HitTestTreePath(e);
|
||||
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
|
||||
{
|
||||
@@ -965,6 +1093,9 @@ public partial class MainWindow : Window
|
||||
SetTreeDropTarget(null);
|
||||
}
|
||||
|
||||
private static bool IsFavoritesPinTarget(NavNodeViewModel? node)
|
||||
=> node is { IsGroup: true, Path: LocationRoots.Favorites };
|
||||
|
||||
private string? HitTestTreePath(DragEventArgs e)
|
||||
{
|
||||
var node = HitTestTreeNode(e);
|
||||
@@ -1705,6 +1836,43 @@ public partial class MainWindow : Window
|
||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
|
||||
if ((ctrl && e.Key == Key.L) || (alt && (e.Key == Key.D || e.SystemKey == Key.D)))
|
||||
{
|
||||
BeginAddressEdit();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsAddressEditor(e.OriginalSource as DependencyObject))
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
Vm.ActivePane.CancelEditPath();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Enter)
|
||||
{
|
||||
Vm.GoCommand.Execute(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
if (e.Key is not (Key.F1 or Key.F5))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (e.OriginalSource is TextBox && e.Key is not (Key.F1 or Key.F5))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Escape && (_marquee.IsActive || _marquee.IsArmed))
|
||||
{
|
||||
_marquee.Cancel();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && shift && e.Key == Key.N)
|
||||
{
|
||||
Vm.NewFolderCommand.Execute(null);
|
||||
@@ -1741,6 +1909,11 @@ public partial class MainWindow : Window
|
||||
{
|
||||
await Vm.PasteAsync().ConfigureAwait(true);
|
||||
}
|
||||
else if (ctrl && e.Key == Key.A && e.OriginalSource is not TextBox)
|
||||
{
|
||||
FindActiveFileList()?.SelectAll();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == Key.Delete)
|
||||
{
|
||||
await DeleteSelectedAsync().ConfigureAwait(true);
|
||||
@@ -1771,4 +1944,51 @@ public partial class MainWindow : Window
|
||||
await Vm.OpenSelectedAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginAddressEdit()
|
||||
{
|
||||
Vm.ActivePane.BeginEditPath();
|
||||
FindAddressBar(Vm.ActivePane)?.FocusEditor();
|
||||
}
|
||||
|
||||
private static bool IsAddressEditor(DependencyObject? origin)
|
||||
{
|
||||
for (var current = origin; current is not null;)
|
||||
{
|
||||
if (current is ExplorerAddressBar)
|
||||
{
|
||||
return origin is TextBox;
|
||||
}
|
||||
|
||||
current = current is Visual
|
||||
? VisualTreeHelper.GetParent(current)
|
||||
: LogicalTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private ExplorerAddressBar? FindAddressBar(ExplorerPaneViewModel pane)
|
||||
=> FindAddressBar(this, pane);
|
||||
|
||||
private static ExplorerAddressBar? FindAddressBar(DependencyObject root, ExplorerPaneViewModel pane)
|
||||
{
|
||||
var count = VisualTreeHelper.GetChildrenCount(root);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var child = VisualTreeHelper.GetChild(root, i);
|
||||
if (child is ExplorerAddressBar bar && ReferenceEquals(bar.DataContext, pane))
|
||||
{
|
||||
return bar;
|
||||
}
|
||||
|
||||
var nested = FindAddressBar(child, pane);
|
||||
if (nested is not null)
|
||||
{
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
105
src/Explorer.App/MaximizedWorkArea.cs
Normal file
105
src/Explorer.App/MaximizedWorkArea.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
internal static class MaximizedWorkArea
|
||||
{
|
||||
private const int WmGetMinMaxInfo = 0x0024;
|
||||
private const uint MonitorDefaultToNearest = 2;
|
||||
|
||||
public static void Hook(Window window)
|
||||
{
|
||||
if (PresentationSource.FromVisual(window) is not HwndSource source)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
source.AddHook(WndProc);
|
||||
}
|
||||
|
||||
private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg != WmGetMinMaxInfo)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var info = Marshal.PtrToStructure<MinMaxInfo>(lParam);
|
||||
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
var monitorInfo = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
|
||||
if (GetMonitorInfo(monitor, ref monitorInfo))
|
||||
{
|
||||
var work = monitorInfo.Work;
|
||||
var display = monitorInfo.Monitor;
|
||||
info.MaxPosition = new NativePoint(work.Left - display.Left, work.Top - display.Top);
|
||||
info.MaxSize = new NativePoint(work.Right - work.Left, work.Bottom - work.Top);
|
||||
info.MaxTrackSize = info.MaxSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (HwndSource.FromHwnd(hwnd)?.RootVisual is Window window)
|
||||
{
|
||||
var dpi = VisualTreeHelper.GetDpi(window);
|
||||
info.MinTrackSize = new NativePoint(
|
||||
(int)Math.Ceiling(window.MinWidth * dpi.DpiScaleX),
|
||||
(int)Math.Ceiling(window.MinHeight * dpi.DpiScaleY));
|
||||
}
|
||||
|
||||
Marshal.StructureToPtr(info, lParam, fDeleteOld: false);
|
||||
handled = true;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
|
||||
public NativePoint(int x, int y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeRect
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MinMaxInfo
|
||||
{
|
||||
public NativePoint Reserved;
|
||||
public NativePoint MaxSize;
|
||||
public NativePoint MaxPosition;
|
||||
public NativePoint MinTrackSize;
|
||||
public NativePoint MaxTrackSize;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int Size;
|
||||
public NativeRect Monitor;
|
||||
public NativeRect Work;
|
||||
public uint Flags;
|
||||
}
|
||||
}
|
||||
23
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
Normal file
23
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
Normal file
@@ -0,0 +1,23 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.AdvancedSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Advanced" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="Technical tool paths. Leave a path empty to search Program Files and PATH."/>
|
||||
|
||||
<TextBlock Text="Git" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsPathHelp}"
|
||||
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
|
||||
Click="OnBrowseGit" Margin="8,0,0,0"
|
||||
AutomationProperties.Name="Browse for Git"/>
|
||||
<TextBox Text="{Binding GitPath, UpdateSourceTrigger=PropertyChanged}"
|
||||
AutomationProperties.Name="Git path"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
24
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
Normal file
24
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Explorer.App.Settings;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class AdvancedSettingsPage : UserControl
|
||||
{
|
||||
public AdvancedSettingsPage() => InitializeComponent();
|
||||
|
||||
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is SettingsDraft draft
|
||||
&& SettingsPathBrowse.TryPick(
|
||||
Window.GetWindow(this),
|
||||
"Git executable",
|
||||
"Git|git.exe|Executables|*.exe|All files|*.*",
|
||||
draft.GitPath,
|
||||
out var path))
|
||||
{
|
||||
draft.GitPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
Normal file
19
src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
Normal file
@@ -0,0 +1,19 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.AppearanceSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Appearance" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="Visual options for Explorer Workbench."/>
|
||||
|
||||
<TextBlock Text="Theme" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"/>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton Content="Dark" GroupName="SettingsTheme" Margin="0,0,16,0"
|
||||
IsChecked="{Binding IsDarkTheme, Mode=TwoWay}"/>
|
||||
<RadioButton Content="Light" GroupName="SettingsTheme"
|
||||
IsChecked="{Binding IsLightTheme, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class AppearanceSettingsPage : UserControl
|
||||
{
|
||||
public AppearanceSettingsPage() => InitializeComponent();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.FileOperationsSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="File Operations" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="Queue behavior and tools used for copy, move, archive, and convert work."/>
|
||||
|
||||
<TextBlock Text="File operations queue" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Auto clear queue when done"
|
||||
IsChecked="{Binding AutoClearQueueWhenDone}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="Finished copy, move, delete, and rename steps are removed automatically. Failed items stay until you dismiss or retry them. The queue is restored after restart."/>
|
||||
|
||||
<TextBlock Text="7-Zip" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsPathHelp}"
|
||||
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
|
||||
<DockPanel Margin="0,0,0,18">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
|
||||
Click="OnBrowseSevenZip" Margin="8,0,0,0"
|
||||
AutomationProperties.Name="Browse for 7-Zip"/>
|
||||
<TextBox Text="{Binding SevenZipPath, UpdateSourceTrigger=PropertyChanged}"
|
||||
AutomationProperties.Name="7-Zip path"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="FFmpeg" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsPathHelp}"
|
||||
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28"
|
||||
Click="OnBrowseFfmpeg" Margin="8,0,0,0"
|
||||
AutomationProperties.Name="Browse for FFmpeg"/>
|
||||
<TextBox Text="{Binding FfmpegPath, UpdateSourceTrigger=PropertyChanged}"
|
||||
AutomationProperties.Name="FFmpeg path"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Explorer.App.Settings;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class FileOperationsSettingsPage : UserControl
|
||||
{
|
||||
public FileOperationsSettingsPage() => InitializeComponent();
|
||||
|
||||
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is SettingsDraft draft
|
||||
&& SettingsPathBrowse.TryPick(
|
||||
Window.GetWindow(this),
|
||||
"7-Zip executable",
|
||||
"7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
|
||||
draft.SevenZipPath,
|
||||
out var path))
|
||||
{
|
||||
draft.SevenZipPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is SettingsDraft draft
|
||||
&& SettingsPathBrowse.TryPick(
|
||||
Window.GetWindow(this),
|
||||
"FFmpeg executable",
|
||||
"FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
|
||||
draft.FfmpegPath,
|
||||
out var path))
|
||||
{
|
||||
draft.FfmpegPath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
Normal file
19
src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
Normal file
@@ -0,0 +1,19 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.GeneralSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="General" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="Startup and application behavior. These options do not change Windows Explorer settings."/>
|
||||
|
||||
<TextBlock Text="Background host" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Start Explorer.Host.exe at Windows sign-in"
|
||||
IsChecked="{Binding BackgroundHostAtLogon}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
|
||||
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class GeneralSettingsPage : UserControl
|
||||
{
|
||||
public GeneralSettingsPage() => InitializeComponent();
|
||||
}
|
||||
51
src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
Normal file
51
src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
Normal file
@@ -0,0 +1,51 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.IndexingSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Indexing" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="What Workbench records in the local index. Online-only cloud files are never hydrated."/>
|
||||
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Include archive contents in the index"
|
||||
IsChecked="{Binding IndexArchiveContents}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
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."/>
|
||||
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Automatically index removable drives when they appear"
|
||||
IsChecked="{Binding AutoIndexRemovable}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
|
||||
|
||||
<TextBlock Text="Idle maintenance" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="When you are not using the PC, the background host can hash duplicates, refresh stale local indexes, and capture history. Copy, move, and other jobs you started keep running. Cloud files are never hydrated. Network and removable locations are not scanned automatically."/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Enable background maintenance when idle"
|
||||
IsChecked="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="The host watches Windows idle time even if this window is closed. Conservative defaults wait 10 minutes and prefer AC power."/>
|
||||
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
|
||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
<DockPanel LastChildFill="False" Margin="0,0,0,14">
|
||||
<ComboBox Width="80" HorizontalAlignment="Left"
|
||||
ItemsSource="{Binding IdleThresholdChoices}"
|
||||
SelectedItem="{Binding IdleMaintenanceMinutes}"
|
||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"
|
||||
AutomationProperties.Name="Idle threshold in minutes"/>
|
||||
<TextBlock Text="minutes" VerticalAlignment="Center" Margin="10,0,0,0"
|
||||
Foreground="{DynamicResource FgMuted}"
|
||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
</DockPanel>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Only run expensive maintenance on AC power"
|
||||
IsChecked="{Binding IdleMaintenanceAcOnly}"
|
||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
|
||||
Text="Skips hashing and idle rescans on battery so a laptop is not drained in the background."/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class IndexingSettingsPage : UserControl
|
||||
{
|
||||
public IndexingSettingsPage() => InitializeComponent();
|
||||
}
|
||||
47
src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml
Normal file
47
src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml
Normal file
@@ -0,0 +1,47 @@
|
||||
<UserControl x:Class="Explorer.App.Settings.Pages.NavigationSettingsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
DataContext="{Binding Draft, RelativeSource={RelativeSource AncestorType=Window}}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Navigation" Style="{StaticResource SettingsHeading}"
|
||||
AutomationProperties.HeadingLevel="Level1"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="Locations tree, grouping, and what Workbench shows while browsing. These options only change what Explorer Workbench shows and indexes. Windows settings and files are not modified."/>
|
||||
|
||||
<TextBlock Text="Locations tree" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
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 Margin="0,0,0,6"
|
||||
Content="Group network drives under Network"
|
||||
IsChecked="{Binding GroupNetworkPlaces}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="Mapped letters and UNC shares become children of a Network item. This PC then shows only local and removable drives."/>
|
||||
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Group cloud locations under Cloud"
|
||||
IsChecked="{Binding GroupCloudPlaces}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="OneDrive, Google Drive, and Nextcloud become children of a Cloud item. The two options are independent."/>
|
||||
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Prefer Favorites when synchronizing the locations tree"
|
||||
IsChecked="{Binding PreferFavoritesInTree}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="Off (default): keep the current tree context. Opening a folder under This PC, Home, Network, or Cloud does not switch the tree to Favorites just because that folder is also pinned. When you opened the folder from a Favorite, the tree stays under Favorites. On: if the folder is under a pinned Favorite, the tree selects that Favorite. Breadcrumbs and the filesystem path are unchanged."/>
|
||||
|
||||
<TextBlock Text="Filesystem visibility" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Show hidden files"
|
||||
IsChecked="{Binding ShowHiddenFiles}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="Items marked Hidden by Windows. Default matches the current Explorer Workbench listing."/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Show protected system locations"
|
||||
IsChecked="{Binding ShowProtectedSystemLocations}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}" Margin="24,0,0,0"
|
||||
Text="System Volume Information, Recycle Bin, Recovery, and similar locations. When a folder cannot be read, its size is shown as access denied — never as 0 bytes. Administrator rights are detected but never requested automatically."/>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class NavigationSettingsPage : UserControl
|
||||
{
|
||||
public NavigationSettingsPage() => InitializeComponent();
|
||||
}
|
||||
20
src/Explorer.App/Settings/SettingsCatalog.cs
Normal file
20
src/Explorer.App/Settings/SettingsCatalog.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Explorer.App.Settings.Pages;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public static class SettingsCatalog
|
||||
{
|
||||
public static IReadOnlyList<SettingsCategory> Create()
|
||||
{
|
||||
// Storage & Analysis and Network & Cloud stay out of the list until they have settings.
|
||||
return
|
||||
[
|
||||
new(SettingsCategoryId.General, "General", new GeneralSettingsPage()),
|
||||
new(SettingsCategoryId.Appearance, "Appearance", new AppearanceSettingsPage()),
|
||||
new(SettingsCategoryId.Navigation, "Navigation", new NavigationSettingsPage()),
|
||||
new(SettingsCategoryId.FileOperations, "File Operations", new FileOperationsSettingsPage()),
|
||||
new(SettingsCategoryId.Indexing, "Indexing", new IndexingSettingsPage()),
|
||||
new(SettingsCategoryId.Advanced, "Advanced", new AdvancedSettingsPage())
|
||||
];
|
||||
}
|
||||
}
|
||||
17
src/Explorer.App/Settings/SettingsCategory.cs
Normal file
17
src/Explorer.App/Settings/SettingsCategory.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed class SettingsCategory
|
||||
{
|
||||
public SettingsCategory(SettingsCategoryId id, string title, FrameworkElement page)
|
||||
{
|
||||
Id = id;
|
||||
Title = title;
|
||||
Page = page;
|
||||
}
|
||||
|
||||
public SettingsCategoryId Id { get; }
|
||||
public string Title { get; }
|
||||
public FrameworkElement Page { get; }
|
||||
}
|
||||
13
src/Explorer.App/Settings/SettingsCategoryId.cs
Normal file
13
src/Explorer.App/Settings/SettingsCategoryId.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public enum SettingsCategoryId
|
||||
{
|
||||
General,
|
||||
Appearance,
|
||||
Navigation,
|
||||
FileOperations,
|
||||
Indexing,
|
||||
StorageAnalysis,
|
||||
NetworkCloud,
|
||||
Advanced
|
||||
}
|
||||
101
src/Explorer.App/Settings/SettingsDraft.cs
Normal file
101
src/Explorer.App/Settings/SettingsDraft.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed partial class SettingsDraft : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _theme = "Dark";
|
||||
[ObservableProperty] private bool _groupNetworkPlaces;
|
||||
[ObservableProperty] private bool _groupCloudPlaces;
|
||||
[ObservableProperty] private bool _preferFavoritesInTree;
|
||||
[ObservableProperty] private bool _showHiddenFiles = true;
|
||||
[ObservableProperty] private bool _showProtectedSystemLocations;
|
||||
[ObservableProperty] private bool _autoClearQueueWhenDone;
|
||||
[ObservableProperty] private bool _indexArchiveContents;
|
||||
[ObservableProperty] private bool _autoIndexRemovable;
|
||||
[ObservableProperty] private bool _backgroundHostAtLogon;
|
||||
[ObservableProperty] private bool _backgroundMaintenanceWhenIdle = true;
|
||||
[ObservableProperty] private int _idleMaintenanceMinutes = 10;
|
||||
[ObservableProperty] private bool _idleMaintenanceAcOnly = true;
|
||||
[ObservableProperty] private string _sevenZipPath = "";
|
||||
[ObservableProperty] private string _gitPath = "";
|
||||
[ObservableProperty] private string _ffmpegPath = "";
|
||||
|
||||
public IReadOnlyList<int> IdleThresholdChoices { get; } = [5, 10, 30];
|
||||
|
||||
public bool IsDarkTheme
|
||||
{
|
||||
get => Theme != "Light";
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
Theme = "Dark";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLightTheme
|
||||
{
|
||||
get => Theme == "Light";
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
Theme = "Light";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SettingsDraft From(UiPreferences preferences)
|
||||
=> new()
|
||||
{
|
||||
Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme),
|
||||
GroupNetworkPlaces = preferences.GroupNetworkPlaces,
|
||||
GroupCloudPlaces = preferences.GroupCloudPlaces,
|
||||
PreferFavoritesInTree = preferences.PreferFavoritesInTree,
|
||||
ShowHiddenFiles = preferences.ShowHiddenFiles,
|
||||
ShowProtectedSystemLocations = preferences.ShowProtectedSystemLocations,
|
||||
AutoClearQueueWhenDone = preferences.AutoClearQueueWhenDone,
|
||||
IndexArchiveContents = preferences.IndexArchiveContents,
|
||||
AutoIndexRemovable = preferences.AutoIndexRemovable,
|
||||
BackgroundHostAtLogon = preferences.BackgroundHostAtLogon,
|
||||
BackgroundMaintenanceWhenIdle = preferences.BackgroundMaintenanceWhenIdle,
|
||||
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
|
||||
IdleMaintenanceAcOnly = preferences.IdleMaintenanceAcOnly,
|
||||
SevenZipPath = preferences.SevenZipPath ?? "",
|
||||
GitPath = preferences.GitPath ?? "",
|
||||
FfmpegPath = preferences.FfmpegPath ?? ""
|
||||
};
|
||||
|
||||
public UiPreferences ApplyTo(UiPreferences stored)
|
||||
=> stored with
|
||||
{
|
||||
Theme = UiPreferencesStore.NormalizeTheme(Theme),
|
||||
GroupNetworkPlaces = GroupNetworkPlaces,
|
||||
GroupCloudPlaces = GroupCloudPlaces,
|
||||
PreferFavoritesInTree = PreferFavoritesInTree,
|
||||
ShowHiddenFiles = ShowHiddenFiles,
|
||||
ShowProtectedSystemLocations = ShowProtectedSystemLocations,
|
||||
AutoClearQueueWhenDone = AutoClearQueueWhenDone,
|
||||
IndexArchiveContents = IndexArchiveContents,
|
||||
AutoIndexRemovable = AutoIndexRemovable,
|
||||
BackgroundHostAtLogon = BackgroundHostAtLogon,
|
||||
BackgroundMaintenanceWhenIdle = BackgroundMaintenanceWhenIdle,
|
||||
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(IdleMaintenanceMinutes),
|
||||
IdleMaintenanceAcOnly = IdleMaintenanceAcOnly,
|
||||
SevenZipPath = EmptyToNull(SevenZipPath),
|
||||
GitPath = EmptyToNull(GitPath),
|
||||
FfmpegPath = EmptyToNull(FfmpegPath)
|
||||
};
|
||||
|
||||
partial void OnThemeChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDarkTheme));
|
||||
OnPropertyChanged(nameof(IsLightTheme));
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
25
src/Explorer.App/Settings/SettingsPathBrowse.cs
Normal file
25
src/Explorer.App/Settings/SettingsPathBrowse.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
internal static class SettingsPathBrowse
|
||||
{
|
||||
public static bool TryPick(Window? owner, string title, string filter, string? current, out string path)
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
FileName = current ?? ""
|
||||
};
|
||||
if (dlg.ShowDialog(owner) == true)
|
||||
{
|
||||
path = dlg.FileName;
|
||||
return true;
|
||||
}
|
||||
|
||||
path = "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
src/Explorer.App/Settings/SettingsSession.cs
Normal file
6
src/Explorer.App/Settings/SettingsSession.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public static class SettingsSession
|
||||
{
|
||||
public static SettingsCategoryId? LastCategory { get; set; }
|
||||
}
|
||||
27
src/Explorer.App/Settings/SettingsShell.cs
Normal file
27
src/Explorer.App/Settings/SettingsShell.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed partial class SettingsShell : ObservableObject
|
||||
{
|
||||
public SettingsShell(SettingsDraft draft, IReadOnlyList<SettingsCategory> categories)
|
||||
{
|
||||
Draft = draft;
|
||||
Categories = categories;
|
||||
_selectedCategory = Restore(categories);
|
||||
}
|
||||
|
||||
public SettingsDraft Draft { get; }
|
||||
public IReadOnlyList<SettingsCategory> Categories { get; }
|
||||
|
||||
[ObservableProperty] private SettingsCategory _selectedCategory;
|
||||
|
||||
partial void OnSelectedCategoryChanged(SettingsCategory value)
|
||||
=> SettingsSession.LastCategory = value.Id;
|
||||
|
||||
private static SettingsCategory Restore(IReadOnlyList<SettingsCategory> categories)
|
||||
{
|
||||
var last = SettingsSession.LastCategory;
|
||||
return categories.FirstOrDefault(c => c.Id == last) ?? categories[0];
|
||||
}
|
||||
}
|
||||
25
src/Explorer.App/Settings/SettingsStyles.xaml
Normal file
25
src/Explorer.App/Settings/SettingsStyles.xaml
Normal file
@@ -0,0 +1,25 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style x:Key="SettingsHeading" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsIntro" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsHelp" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="24,0,0,14"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsPathHelp" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -3,99 +3,71 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Settings"
|
||||
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||
Height="720" Width="560"
|
||||
MinHeight="560" MinWidth="480"
|
||||
Height="640" Width="820"
|
||||
MinHeight="480" MinWidth="700"
|
||||
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">
|
||||
ResizeMode="CanResize">
|
||||
<DockPanel Margin="16">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,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="Filesystem visibility" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,12"
|
||||
Text="These options only change what Explorer Workbench shows and indexes. Windows settings and files are not modified."/>
|
||||
<CheckBox x:Name="ShowHidden" Margin="0,0,0,6"
|
||||
Content="Show hidden files"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,14" FontSize="12"
|
||||
Text="Items marked Hidden by Windows. Default matches the current Explorer Workbench listing."/>
|
||||
<CheckBox x:Name="ShowProtected" Margin="0,0,0,6"
|
||||
Content="Show protected system locations"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="System Volume Information, Recycle Bin, Recovery, and similar locations. When a folder cannot be read, its size is shown as access denied — never as 0 bytes. Administrator rights are detected but never requested automatically."/>
|
||||
|
||||
<TextBlock Text="File operations queue" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<CheckBox x:Name="AutoClearQueue" Margin="0,0,0,6"
|
||||
Content="Auto clear queue when done"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="Finished copy, move, delete, and rename steps are removed automatically. Failed items stay until you dismiss or retry them. The queue is restored after restart."/>
|
||||
|
||||
<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,18" 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."/>
|
||||
<CheckBox x:Name="AutoIndexRemovable" Margin="0,0,0,6"
|
||||
Content="Automatically index removable drives when they appear"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
|
||||
|
||||
<TextBlock Text="Background host" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
|
||||
Content="Start Explorer.Host.exe at Windows sign-in"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
|
||||
|
||||
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
|
||||
<DockPanel Margin="0,0,0,18">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseSevenZip" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="SevenZipPath"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" MinWidth="160"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="*" MinWidth="360"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Categories" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<ListBox x:Name="CategoryList"
|
||||
ItemsSource="{Binding Categories}"
|
||||
SelectedItem="{Binding SelectedCategory}"
|
||||
DisplayMemberPath="Title"
|
||||
AutomationProperties.Name="Settings categories"
|
||||
KeyboardNavigation.TabIndex="0">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}" CornerRadius="3" Margin="0,1">
|
||||
<ContentPresenter HorizontalAlignment="Stretch" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="GitPath"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="FFmpeg" FontSize="16" FontWeight="SemiBold" Margin="0,16,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseFfmpeg" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="FfmpegPath"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<GridSplitter Grid.Column="1" Width="8" HorizontalAlignment="Stretch"
|
||||
Background="{DynamicResource Stroke}"
|
||||
ResizeBehavior="PreviousAndNext"/>
|
||||
<Border Grid.Column="2" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}"
|
||||
BorderThickness="1" Padding="16">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
KeyboardNavigation.TabIndex="1">
|
||||
<ContentControl Content="{Binding SelectedCategory.Page}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Top"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using Explorer.Application;
|
||||
using Explorer.App.Settings;
|
||||
using Explorer.Hosting;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Explorer.App;
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly SettingsDraft _draft;
|
||||
private readonly string _originalTheme;
|
||||
|
||||
public SettingsWindow(MainViewModel vm)
|
||||
@@ -16,48 +18,25 @@ public partial class SettingsWindow : Window
|
||||
_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;
|
||||
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
|
||||
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
|
||||
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
|
||||
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
|
||||
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
|
||||
SevenZipPath.Text = prefs.SevenZipPath ?? "";
|
||||
GitPath.Text = prefs.GitPath ?? "";
|
||||
FfmpegPath.Text = prefs.FfmpegPath ?? "";
|
||||
_draft = SettingsDraft.From(prefs);
|
||||
_draft.PropertyChanged += OnDraftChanged;
|
||||
DataContext = new SettingsShell(_draft, SettingsCatalog.Create());
|
||||
Closed += (_, _) => _draft.PropertyChanged -= OnDraftChanged;
|
||||
}
|
||||
|
||||
private void OnThemeChanged(object sender, RoutedEventArgs e)
|
||||
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (!IsLoaded)
|
||||
if (e.PropertyName is nameof(SettingsDraft.Theme)
|
||||
or nameof(SettingsDraft.IsDarkTheme)
|
||||
or nameof(SettingsDraft.IsLightTheme))
|
||||
{
|
||||
return;
|
||||
_vm.Theme = _draft.Theme;
|
||||
}
|
||||
|
||||
_vm.Theme = ThemeLight.IsChecked == true ? "Light" : "Dark";
|
||||
}
|
||||
|
||||
private async void OnOk(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var prefs = _vm.CurrentPreferences() with
|
||||
{
|
||||
Theme = ThemeLight.IsChecked == true ? "Light" : "Dark",
|
||||
GroupNetworkPlaces = GroupNetwork.IsChecked == true,
|
||||
GroupCloudPlaces = GroupCloud.IsChecked == true,
|
||||
IndexArchiveContents = IndexArchives.IsChecked == true,
|
||||
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
|
||||
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
|
||||
ShowHiddenFiles = ShowHidden.IsChecked == true,
|
||||
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
|
||||
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
|
||||
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
|
||||
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim(),
|
||||
FfmpegPath = string.IsNullOrWhiteSpace(FfmpegPath.Text) ? null : FfmpegPath.Text.Trim()
|
||||
};
|
||||
var prefs = _draft.ApplyTo(_vm.CurrentPreferences());
|
||||
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
|
||||
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
|
||||
DialogResult = true;
|
||||
@@ -94,48 +73,6 @@ public partial class SettingsWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "7-Zip executable",
|
||||
Filter = "7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = SevenZipPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
SevenZipPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Git executable",
|
||||
Filter = "Git|git.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = GitPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
GitPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "FFmpeg executable",
|
||||
Filter = "FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = FfmpegPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
FfmpegPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_vm.Theme = _originalTheme;
|
||||
|
||||
@@ -42,6 +42,7 @@ public sealed class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
|
||||
|
||||
public int FirstVisibleIndex => _firstVisible;
|
||||
public int LastVisibleIndex => _lastVisible;
|
||||
public int Columns => Math.Max(1, _columns);
|
||||
|
||||
public ScrollViewer? ScrollOwner { get; set; }
|
||||
public bool CanVerticallyScroll { get; set; } = true;
|
||||
|
||||
40
src/Explorer.Application/BackgroundMaintenance.cs
Normal file
40
src/Explorer.Application/BackgroundMaintenance.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public interface IUserIdleMonitor
|
||||
{
|
||||
TimeSpan GetIdleDuration();
|
||||
}
|
||||
|
||||
public interface IPowerSourceMonitor
|
||||
{
|
||||
/// <summary>True when on AC or when power state cannot be determined.</summary>
|
||||
bool IsOnAcPower { get; }
|
||||
}
|
||||
|
||||
public interface IIdleIndexWork
|
||||
{
|
||||
bool IsBusy { get; }
|
||||
bool HasIdleWork { get; }
|
||||
void SetIdleAllowed(bool allowed);
|
||||
void EnqueueIdleFullScan(long sourceId);
|
||||
void EnqueueIdleVerify(long sourceId, string pathRel);
|
||||
}
|
||||
|
||||
public interface IIdleHashWork
|
||||
{
|
||||
bool IsPaused { get; }
|
||||
void Pause();
|
||||
void Resume();
|
||||
void BeginUserRequested();
|
||||
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IHistoryMaintenance
|
||||
{
|
||||
Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IForegroundWorkSignal
|
||||
{
|
||||
bool HasForegroundWork();
|
||||
}
|
||||
44
src/Explorer.Application/BackgroundMaintenancePlanner.cs
Normal file
44
src/Explorer.Application/BackgroundMaintenancePlanner.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class BackgroundMaintenancePlanner
|
||||
{
|
||||
public static Source? NextLocalScan(
|
||||
IEnumerable<Source> sources,
|
||||
DateTimeOffset utc,
|
||||
IReadOnlySet<long>? alreadyQueued = null)
|
||||
{
|
||||
var cutoff = utc - TimeSpan.FromDays(AppConstants.IdleRescanAfterDays);
|
||||
return EligibleLocal(sources, alreadyQueued)
|
||||
.Where(source => source.Status == SourceStatus.Stale
|
||||
|| source.LastIndexedUtc is null
|
||||
|| source.LastIndexedUtc < cutoff)
|
||||
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
|
||||
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static Source? NextLocalVerify(
|
||||
IEnumerable<Source> sources,
|
||||
IReadOnlySet<long>? alreadyQueued = null)
|
||||
=> EligibleLocal(sources, alreadyQueued)
|
||||
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
|
||||
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
|
||||
public static Source? NextHashCollisionSource(
|
||||
IEnumerable<Source> sources,
|
||||
IReadOnlySet<long>? alreadyEnqueued = null)
|
||||
=> EligibleLocal(sources, alreadyEnqueued)
|
||||
.OrderBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static IEnumerable<Source> EligibleLocal(IEnumerable<Source> sources, IReadOnlySet<long>? skip)
|
||||
=> sources.Where(source =>
|
||||
source.Kind == SourceKind.NtfsLocal
|
||||
&& source.IsIndexed
|
||||
&& source.Status is SourceStatus.Online or SourceStatus.Stale
|
||||
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
|
||||
&& (skip is null || !skip.Contains(source.Id)));
|
||||
}
|
||||
97
src/Explorer.Application/BackgroundWorkPolicy.cs
Normal file
97
src/Explorer.Application/BackgroundWorkPolicy.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public enum UserActivityState
|
||||
{
|
||||
Active,
|
||||
Idle
|
||||
}
|
||||
|
||||
public enum MaintenanceSkipReason
|
||||
{
|
||||
None,
|
||||
Disabled,
|
||||
UserActive,
|
||||
BelowIdleThreshold,
|
||||
OnBattery,
|
||||
ForegroundOperations,
|
||||
AlreadyRunning
|
||||
}
|
||||
|
||||
public readonly record struct BackgroundWorkInputs(
|
||||
bool Enabled,
|
||||
TimeSpan IdleThreshold,
|
||||
TimeSpan IdleDuration,
|
||||
bool AcOnly,
|
||||
bool OnAcPower,
|
||||
bool ForegroundOperations,
|
||||
bool RunNowRequested);
|
||||
|
||||
public readonly record struct BackgroundWorkDecision(
|
||||
UserActivityState Activity,
|
||||
bool MaintenanceAllowed,
|
||||
MaintenanceSkipReason SkipReason)
|
||||
{
|
||||
public static BackgroundWorkDecision Active(MaintenanceSkipReason reason)
|
||||
=> new(UserActivityState.Active, false, reason);
|
||||
|
||||
public static BackgroundWorkDecision IdleBlocked(MaintenanceSkipReason reason)
|
||||
=> new(UserActivityState.Idle, false, reason);
|
||||
|
||||
public static BackgroundWorkDecision Allowed(UserActivityState activity)
|
||||
=> new(activity, true, MaintenanceSkipReason.None);
|
||||
}
|
||||
|
||||
public static class BackgroundWorkPolicy
|
||||
{
|
||||
public static BackgroundWorkDecision Evaluate(BackgroundWorkInputs inputs)
|
||||
{
|
||||
var idle = inputs.IdleDuration >= inputs.IdleThreshold && inputs.IdleDuration >= TimeSpan.Zero;
|
||||
var activity = idle ? UserActivityState.Idle : UserActivityState.Active;
|
||||
|
||||
if (inputs.RunNowRequested)
|
||||
{
|
||||
if (inputs.ForegroundOperations)
|
||||
{
|
||||
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.ForegroundOperations);
|
||||
}
|
||||
|
||||
return BackgroundWorkDecision.Allowed(activity);
|
||||
}
|
||||
|
||||
if (!inputs.Enabled)
|
||||
{
|
||||
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.Disabled);
|
||||
}
|
||||
|
||||
if (!idle)
|
||||
{
|
||||
return BackgroundWorkDecision.Active(
|
||||
inputs.IdleDuration <= TimeSpan.Zero
|
||||
? MaintenanceSkipReason.UserActive
|
||||
: MaintenanceSkipReason.BelowIdleThreshold);
|
||||
}
|
||||
|
||||
if (inputs.AcOnly && !inputs.OnAcPower)
|
||||
{
|
||||
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.OnBattery);
|
||||
}
|
||||
|
||||
if (inputs.ForegroundOperations)
|
||||
{
|
||||
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.ForegroundOperations);
|
||||
}
|
||||
|
||||
return BackgroundWorkDecision.Allowed(UserActivityState.Idle);
|
||||
}
|
||||
|
||||
public static string Describe(MaintenanceSkipReason reason) => reason switch
|
||||
{
|
||||
MaintenanceSkipReason.Disabled => "disabled",
|
||||
MaintenanceSkipReason.UserActive => "user is active",
|
||||
MaintenanceSkipReason.BelowIdleThreshold => "idle threshold not reached",
|
||||
MaintenanceSkipReason.OnBattery => "on battery",
|
||||
MaintenanceSkipReason.ForegroundOperations => "foreground file operations",
|
||||
MaintenanceSkipReason.AlreadyRunning => "maintenance already running",
|
||||
_ => "none"
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public static class BrowseHydration
|
||||
public const int ConstrainedWorkers = 2;
|
||||
public const int LocalProviderBatch = 64;
|
||||
public const int ConstrainedProviderBatch = 16;
|
||||
public const int FirstPublish = 1;
|
||||
public const int PublishBatch = 48;
|
||||
public const int NearbyWindow = 32;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class BrowseService
|
||||
private readonly UiPreferencesStore _preferences;
|
||||
private readonly IElevatedScanService? _elevation;
|
||||
private readonly IRecycleBinCatalog? _recycle;
|
||||
private readonly IKnownUserFolderCatalog? _knownFolders;
|
||||
|
||||
public BrowseService(
|
||||
IFileSystemEnumerator enumerator,
|
||||
@@ -26,7 +27,8 @@ public sealed class BrowseService
|
||||
CloudPlaceStore cloudPlaces,
|
||||
UiPreferencesStore preferences,
|
||||
IElevatedScanService? elevation = null,
|
||||
IRecycleBinCatalog? recycle = null)
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IKnownUserFolderCatalog? knownFolders = null)
|
||||
{
|
||||
_enumerator = enumerator;
|
||||
_volumes = volumes;
|
||||
@@ -37,6 +39,7 @@ public sealed class BrowseService
|
||||
_preferences = preferences;
|
||||
_elevation = elevation;
|
||||
_recycle = recycle;
|
||||
_knownFolders = knownFolders;
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
||||
@@ -56,6 +59,41 @@ public sealed class BrowseService
|
||||
return new FolderListing { Path = listing.Path, Items = items };
|
||||
}
|
||||
|
||||
public FolderListing ListHome()
|
||||
{
|
||||
var items = (_knownFolders?.ListExisting() ?? [])
|
||||
.Select(folder => new FileSystemItem
|
||||
{
|
||||
FullPath = folder.Path,
|
||||
Name = folder.Name,
|
||||
DisplayName = folder.Name,
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory
|
||||
})
|
||||
.ToList();
|
||||
return new FolderListing { Path = LocationRoots.Home, Items = items };
|
||||
}
|
||||
|
||||
public FolderListing ListFavorites()
|
||||
{
|
||||
var items = new List<FileSystemItem>();
|
||||
foreach (var path in FavoriteFolders.Normalize(_preferences.Load().FavoriteFolders))
|
||||
{
|
||||
var exists = Directory.Exists(path);
|
||||
var name = FavoriteDisplayName(path);
|
||||
items.Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = name,
|
||||
DisplayName = exists ? name : $"{name} (Offline)",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory
|
||||
});
|
||||
}
|
||||
|
||||
return new FolderListing { Path = LocationRoots.Favorites, Items = items };
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
|
||||
@@ -172,17 +210,6 @@ public sealed class BrowseService
|
||||
BrowseViewport? viewport = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
|
||||
{
|
||||
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveListing is not null)
|
||||
{
|
||||
yield return CompleteDelta(archiveListing);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
|
||||
{
|
||||
yield return CompleteDelta(ListRecycleBin(path));
|
||||
@@ -190,13 +217,35 @@ public sealed class BrowseService
|
||||
}
|
||||
|
||||
var reachable = _volumes.IsPathReachable(path);
|
||||
if (MightBeArchiveListing(path, reachable))
|
||||
{
|
||||
var archiveSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveSource is { IsIndexed: true })
|
||||
{
|
||||
var archiveListing = await TryListArchiveAsync(archiveSource, path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveListing is not null)
|
||||
{
|
||||
yield return CompleteDelta(archiveListing);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reachable)
|
||||
{
|
||||
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
|
||||
yield return CompleteDelta(await ListOfflineAsync(archiveSource, path, cancellationToken).ConfigureAwait(false));
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
else if (!reachable)
|
||||
{
|
||||
var offlineSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
yield return CompleteDelta(await ListOfflineAsync(offlineSource, path, cancellationToken).ConfigureAwait(false));
|
||||
yield break;
|
||||
}
|
||||
|
||||
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
|
||||
var sourceTask = _sources.FindByPathAsync(path, cancellationToken);
|
||||
_ = MarkReachableInBackground(path, cancellationToken);
|
||||
await foreach (var delta in ListLiveProgressiveAsync(path, sourceTask, viewport, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return delta;
|
||||
}
|
||||
@@ -205,6 +254,64 @@ public sealed class BrowseService
|
||||
public bool CanBrowseArchive(string name)
|
||||
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
|
||||
|
||||
private bool MightBeArchiveListing(string path, bool reachable)
|
||||
{
|
||||
if (!_preferences.Load().IndexArchiveContents)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!reachable)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||
return !string.IsNullOrEmpty(name) && ArchiveFormats.IsArchive(name);
|
||||
}
|
||||
|
||||
private async Task MarkReachableInBackground(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sources.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Listing already started from the live filesystem.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, IndexEntry>> LoadIndexAfterSourceAsync(
|
||||
Task<Source?> sourceTask,
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Source? source;
|
||||
try
|
||||
{
|
||||
source = await sourceTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (source is not { IsIndexed: true })
|
||||
{
|
||||
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return await LoadIndexChildrenAsync(source, path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ApplyDelta(
|
||||
List<FileSystemItem> items,
|
||||
Dictionary<string, int> byPath,
|
||||
@@ -293,46 +400,43 @@ public sealed class BrowseService
|
||||
|
||||
private async IAsyncEnumerable<BrowseDelta> ListLiveProgressiveAsync(
|
||||
string path,
|
||||
Source? source,
|
||||
Task<Source?> sourceTask,
|
||||
BrowseViewport? viewport,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var prefs = _preferences.Load();
|
||||
var spaceCache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
||||
var indexTask = source is { IsIndexed: true }
|
||||
? LoadIndexChildrenAsync(source, path, cancellationToken)
|
||||
: Task.FromResult(new Dictionary<string, IndexEntry>(StringComparer.Ordinal));
|
||||
Task<Dictionary<string, IndexEntry>>? indexTask = null;
|
||||
|
||||
Task<Dictionary<string, IndexEntry>> KickIndex()
|
||||
=> indexTask ??= LoadIndexAfterSourceAsync(sourceTask, path, cancellationToken);
|
||||
|
||||
var batch = new List<FileSystemItem>(BrowseHydration.PublishBatch);
|
||||
var all = new List<FileSystemItem>();
|
||||
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, IndexEntry>? indexMap = indexTask.IsCompletedSuccessfully ? indexTask.Result : null;
|
||||
var sink = new FileEnumerationSink();
|
||||
var firstFlush = true;
|
||||
|
||||
await foreach (var raw in StreamEnumerationAsync(path, sink, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (indexMap is null && indexTask.IsCompleted)
|
||||
{
|
||||
indexMap = await indexTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var item = Annotate(raw, sizeFromIndex: false, prefs, probeAccess: false);
|
||||
var item = Annotate(
|
||||
raw.Overlay(hydration: raw.Hydration & ~(ItemHydrationFlags.Index | ItemHydrationFlags.Provider)),
|
||||
sizeFromIndex: false,
|
||||
prefs,
|
||||
probeAccess: false);
|
||||
if (!LocationVisibility.ShouldShow(item.Location, prefs))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (indexMap is not null)
|
||||
{
|
||||
item = OverlayIndex(item, indexMap, prefs);
|
||||
}
|
||||
|
||||
item = AttachVolumeSpace(item, spaceCache);
|
||||
Upsert(all, byPath, item);
|
||||
batch.Add(item);
|
||||
if (batch.Count >= BrowseHydration.PublishBatch)
|
||||
var limit = firstFlush ? BrowseHydration.FirstPublish : BrowseHydration.PublishBatch;
|
||||
if (batch.Count >= limit)
|
||||
{
|
||||
firstFlush = false;
|
||||
yield return new BrowseDelta
|
||||
{
|
||||
Path = path,
|
||||
@@ -340,6 +444,7 @@ public sealed class BrowseService
|
||||
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
||||
};
|
||||
batch.Clear();
|
||||
_ = KickIndex();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,11 +457,7 @@ public sealed class BrowseService
|
||||
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
|
||||
};
|
||||
|
||||
if (indexMap is null)
|
||||
{
|
||||
indexMap = await indexTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var indexMap = await KickIndex().ConfigureAwait(false);
|
||||
var indexUpdates = OverlayPendingIndex(all, byPath, indexMap, prefs);
|
||||
if (indexUpdates.Count > 0)
|
||||
{
|
||||
@@ -368,6 +469,7 @@ public sealed class BrowseService
|
||||
};
|
||||
}
|
||||
|
||||
var source = sourceTask.IsCompletedSuccessfully ? sourceTask.Result : await sourceTask.ConfigureAwait(false);
|
||||
var accessUpdates = await ProbeAccessDeniedAsync(all, byPath, prefs, source?.Kind, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (accessUpdates.Count > 0)
|
||||
@@ -506,6 +608,7 @@ public sealed class BrowseService
|
||||
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
|
||||
fileId: item.FileId ?? entry.FileId,
|
||||
cloud: cloud,
|
||||
indexedChildCount: entry.ChildFileCount + entry.ChildDirCount,
|
||||
hydration: item.Hydration | ItemHydrationFlags.Index);
|
||||
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
|
||||
}
|
||||
@@ -757,6 +860,28 @@ public sealed class BrowseService
|
||||
return item.Overlay(freeSpaceBytes: space.FreeBytes, capacityBytes: space.CapacityBytes);
|
||||
}
|
||||
|
||||
public int CountVisibleChildren(string path)
|
||||
{
|
||||
var live = _enumerator.EnumerateChildrenSafe(path, out var error);
|
||||
if (error is not null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var prefs = _preferences.Load();
|
||||
var count = 0;
|
||||
foreach (var item in live)
|
||||
{
|
||||
var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
|
||||
if (LocationVisibility.ShouldShow(location, prefs) && !location.IsRecycleBin)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private IReadOnlyList<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
|
||||
{
|
||||
var cache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -813,6 +938,12 @@ public sealed class BrowseService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string FavoriteDisplayName(string path)
|
||||
{
|
||||
var name = PathRules.GetFileName(path.TrimEnd('\\'));
|
||||
return string.IsNullOrEmpty(name) ? path : name;
|
||||
}
|
||||
|
||||
private static string FormatBytes(long bytes)
|
||||
{
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
87
src/Explorer.Application/FavoriteFolders.cs
Normal file
87
src/Explorer.Application/FavoriteFolders.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FavoriteFolders
|
||||
{
|
||||
public const int MaxCount = 32;
|
||||
|
||||
public static IReadOnlyList<string> Normalize(IEnumerable<string>? paths)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in paths ?? [])
|
||||
{
|
||||
if (!TryNormalize(raw, out var path) || !seen.Add(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(path);
|
||||
if (result.Count >= MaxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryNormalize(string? raw, out string path)
|
||||
{
|
||||
path = "";
|
||||
if (string.IsNullOrWhiteSpace(raw) || LocationRoots.IsVirtual(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = PathRules.FromExtended(raw).TrimEnd('\\');
|
||||
if (normalized.Length == 2 && normalized[1] == ':')
|
||||
{
|
||||
normalized += "\\";
|
||||
}
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
path = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool Contains(IEnumerable<string>? paths, string? candidate)
|
||||
=> TryNormalize(candidate, out var path)
|
||||
&& Normalize(paths).Any(p => p.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static IReadOnlyList<string> Add(IEnumerable<string>? current, IEnumerable<string> candidates)
|
||||
{
|
||||
var list = Normalize(current).ToList();
|
||||
var seen = new HashSet<string>(list, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!TryNormalize(candidate, out var path) || !seen.Add(path) || list.Count >= MaxCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(path);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> Remove(IEnumerable<string>? current, IEnumerable<string> candidates)
|
||||
{
|
||||
var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (TryNormalize(candidate, out var path))
|
||||
{
|
||||
remove.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return Normalize(current).Where(p => !remove.Contains(p)).ToList();
|
||||
}
|
||||
}
|
||||
57
src/Explorer.Application/FolderDisplayRefresh.cs
Normal file
57
src/Explorer.Application/FolderDisplayRefresh.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FolderDisplayRefresh
|
||||
{
|
||||
public const int MaxProbes = 8;
|
||||
public const long MinProbeBytes = 1024 * 1024;
|
||||
|
||||
public static IReadOnlyList<FileSystemItem> Pick(
|
||||
IEnumerable<FileSystemItem> items,
|
||||
IReadOnlyCollection<string>? visiblePaths = null)
|
||||
{
|
||||
var dirs = items
|
||||
.Where(item => item.IsDirectory
|
||||
&& item.SizeBytes >= MinProbeBytes
|
||||
&& item.Cloud?.MayHydrateOnRead != true)
|
||||
.ToList();
|
||||
if (dirs.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var visible = visiblePaths is { Count: > 0 }
|
||||
? new HashSet<string>(visiblePaths, StringComparer.OrdinalIgnoreCase)
|
||||
: null;
|
||||
IEnumerable<FileSystemItem> ordered = visible is null
|
||||
? dirs.OrderByDescending(item => item.SizeBytes)
|
||||
: dirs.Where(item => visible.Contains(item.FullPath))
|
||||
.OrderByDescending(item => item.SizeBytes)
|
||||
.Concat(dirs.Where(item => !visible.Contains(item.FullPath)).OrderByDescending(item => item.SizeBytes));
|
||||
|
||||
var picked = new List<FileSystemItem>(Math.Min(MaxProbes, dirs.Count));
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in ordered)
|
||||
{
|
||||
if (!seen.Add(item.FullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
picked.Add(item);
|
||||
if (picked.Count >= MaxProbes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return picked;
|
||||
}
|
||||
|
||||
public static bool NeedsVerify(int liveChildCount, int indexedChildCount)
|
||||
=> liveChildCount >= 0 && liveChildCount != indexedChildCount;
|
||||
|
||||
public static bool HasIndexCounts(FileSystemItem item)
|
||||
=> (item.Hydration & ItemHydrationFlags.Index) != 0;
|
||||
}
|
||||
73
src/Explorer.Application/FolderStatusText.cs
Normal file
73
src/Explorer.Application/FolderStatusText.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FolderStatusText
|
||||
{
|
||||
public static string Format(IEnumerable<FileSystemItem> folderItems, IEnumerable<FileSystemItem> selectedItems)
|
||||
{
|
||||
var selected = selectedItems as IReadOnlyCollection<FileSystemItem> ?? selectedItems.ToList();
|
||||
var isSelection = selected.Count > 0;
|
||||
var totals = Measure(isSelection ? selected : folderItems);
|
||||
var noun = totals.Count == 1 ? "item" : "items";
|
||||
var prefix = isSelection
|
||||
? $"{totals.Count:N0} {noun} selected"
|
||||
: $"{totals.Count:N0} {noun}";
|
||||
if (!ShouldShowSize(totals))
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
return $"{prefix} · {FormatSize(totals.KnownBytes)}";
|
||||
}
|
||||
|
||||
public static FolderStatusTotals Measure(IEnumerable<FileSystemItem> items)
|
||||
{
|
||||
var count = 0;
|
||||
var files = 0;
|
||||
var knownBytes = 0L;
|
||||
var hasKnownSize = false;
|
||||
foreach (var item in items)
|
||||
{
|
||||
count++;
|
||||
if (!item.IsDirectory)
|
||||
{
|
||||
files++;
|
||||
}
|
||||
|
||||
if (item.SizeKnowledge == SizeKnowledge.Unknown)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
knownBytes += Math.Max(0, item.SizeBytes);
|
||||
hasKnownSize = true;
|
||||
}
|
||||
|
||||
return new FolderStatusTotals(count, files, knownBytes, hasKnownSize);
|
||||
}
|
||||
|
||||
private static bool ShouldShowSize(FolderStatusTotals totals)
|
||||
=> totals.HasKnownSize && (totals.KnownBytes > 0 || totals.Files > 0);
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes < 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return unit == 0 ? $"{bytes} B" : $"{value:0.##} {units[unit]}";
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct FolderStatusTotals(int Count, int Files, long KnownBytes, bool HasKnownSize);
|
||||
13
src/Explorer.Application/IKnownUserFolderCatalog.cs
Normal file
13
src/Explorer.Application/IKnownUserFolderCatalog.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public sealed record KnownUserFolder(string Name, string Path, string Glyph);
|
||||
|
||||
public interface IKnownUserFolderCatalog
|
||||
{
|
||||
IReadOnlyList<KnownUserFolder> ListExisting();
|
||||
}
|
||||
|
||||
public sealed class KnownUserFolderCatalog : IKnownUserFolderCatalog
|
||||
{
|
||||
public IReadOnlyList<KnownUserFolder> ListExisting() => [];
|
||||
}
|
||||
80
src/Explorer.Application/MarqueeRange.cs
Normal file
80
src/Explorer.Application/MarqueeRange.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class MarqueeRange
|
||||
{
|
||||
public static IReadOnlyList<int> Stack(double top, double bottom, int count, double itemHeight)
|
||||
{
|
||||
if (count <= 0 || itemHeight <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var min = Math.Min(top, bottom);
|
||||
var max = Math.Max(top, bottom);
|
||||
if (max - min < 0.5)
|
||||
{
|
||||
var index = (int)Math.Floor(min / itemHeight);
|
||||
return index >= 0 && index < count ? [index] : [];
|
||||
}
|
||||
|
||||
var first = (int)Math.Floor(min / itemHeight);
|
||||
var last = (int)Math.Ceiling(max / itemHeight) - 1;
|
||||
first = Math.Clamp(first, 0, count - 1);
|
||||
last = Math.Clamp(last, 0, count - 1);
|
||||
if (last < first)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var hits = new int[last - first + 1];
|
||||
for (var i = 0; i < hits.Length; i++)
|
||||
{
|
||||
hits[i] = first + i;
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> Wrap(double left, double top, double right, double bottom, int count, int columns, double itemWidth, double itemHeight)
|
||||
{
|
||||
if (count <= 0 || columns <= 0 || itemWidth <= 0 || itemHeight <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var minX = Math.Min(left, right);
|
||||
var maxX = Math.Max(left, right);
|
||||
var minY = Math.Min(top, bottom);
|
||||
var maxY = Math.Max(top, bottom);
|
||||
if (maxX - minX < 0.5 && maxY - minY < 0.5)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var firstCol = (int)Math.Floor(minX / itemWidth);
|
||||
var lastCol = (int)Math.Ceiling(maxX / itemWidth) - 1;
|
||||
var firstRow = (int)Math.Floor(minY / itemHeight);
|
||||
var lastRow = (int)Math.Ceiling(maxY / itemHeight) - 1;
|
||||
firstCol = Math.Clamp(firstCol, 0, columns - 1);
|
||||
lastCol = Math.Clamp(lastCol, 0, columns - 1);
|
||||
if (lastCol < firstCol || lastRow < firstRow)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var hits = new List<int>();
|
||||
for (var row = firstRow; row <= lastRow; row++)
|
||||
{
|
||||
for (var col = firstCol; col <= lastCol; col++)
|
||||
{
|
||||
var index = row * columns + col;
|
||||
if (index >= 0 && index < count)
|
||||
{
|
||||
hits.Add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ public sealed class SourceManager
|
||||
private long _refreshCacheTimestamp;
|
||||
private static readonly TimeSpan RefreshCacheTtl = TimeSpan.FromSeconds(2);
|
||||
|
||||
public event EventHandler<Source>? PresenceChanged;
|
||||
|
||||
public SourceManager(
|
||||
IIndexStore store,
|
||||
IVolumeService volumes,
|
||||
@@ -176,6 +178,7 @@ public sealed class SourceManager
|
||||
&& (source.Kind.IsNetwork() || PathRules.IsUnc(source.LastRootPath))
|
||||
&& _volumes.IsPathReachable(source.LastRootPath))
|
||||
{
|
||||
await BringOnlineAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -282,6 +285,77 @@ public sealed class SourceManager
|
||||
return source;
|
||||
}
|
||||
|
||||
public async Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_store.CanWrite)
|
||||
{
|
||||
if (_remote is null)
|
||||
{
|
||||
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var current = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (current is { Status: SourceStatus.Online or SourceStatus.Stale or SourceStatus.Scanning })
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var updated = await _remote.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
InvalidateRefreshCache();
|
||||
var source = updated ?? await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is not null)
|
||||
{
|
||||
RaisePresence(source);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
var found = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (found is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await BringOnlineAsync(found, cancellationToken).ConfigureAwait(false);
|
||||
return found;
|
||||
}
|
||||
|
||||
public async Task ConfirmAccessedAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is null || source.Status != SourceStatus.Offline)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_volumes.IsPathReachable(path))
|
||||
{
|
||||
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 4; attempt++)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
if (_volumes.IsPathReachable(path))
|
||||
{
|
||||
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPresentInWindows(Source source)
|
||||
{
|
||||
var online = _volumes.EnumerateOnlineVolumes();
|
||||
@@ -528,6 +602,42 @@ public sealed class SourceManager
|
||||
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> _store.Sources.GetAsync(id, cancellationToken);
|
||||
|
||||
private async Task BringOnlineAsync(Source source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.Status == SourceStatus.Scanning
|
||||
&& await _store.ScanJobs.HasActiveAsync(source.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.Status is SourceStatus.Online or SourceStatus.Stale)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var wasOffline = source.Status == SourceStatus.Offline;
|
||||
source.Status = SourceStatus.Online;
|
||||
source.LastSeenUtc = _clock.UtcNow;
|
||||
source.LastError = null;
|
||||
await TryIndexWrite(
|
||||
() => _store.Sources.UpsertAsync(source, cancellationToken),
|
||||
"mark source online",
|
||||
source.Id).ConfigureAwait(false);
|
||||
if (source.IsIndexed && wasOffline)
|
||||
{
|
||||
await TryIndexWrite(
|
||||
() => _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken),
|
||||
"mark online",
|
||||
source.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
InvalidateRefreshCache();
|
||||
RaisePresence(source);
|
||||
}
|
||||
|
||||
private void RaisePresence(Source source)
|
||||
=> PresenceChanged?.Invoke(this, source);
|
||||
|
||||
private async Task<SourceStatus> ResolveReachableStatusAsync(Source source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.Status == SourceStatus.Scanning
|
||||
|
||||
102
src/Explorer.Application/TreeRevealSelector.cs
Normal file
102
src/Explorer.Application/TreeRevealSelector.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses which locations-tree root should be expanded for a filesystem path.
|
||||
/// Does not change the canonical path shown in the folder pane or breadcrumbs.
|
||||
/// </summary>
|
||||
public readonly record struct TreeRevealCandidate(string Path, bool IsFavorite);
|
||||
|
||||
public static class TreeRevealSelector
|
||||
{
|
||||
public static TreeRevealCandidate? Choose(
|
||||
IReadOnlyList<TreeRevealCandidate> candidates,
|
||||
string targetPath,
|
||||
bool preferFavorites,
|
||||
bool currentlyInFavorites)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetPath) || candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matches = new List<TreeRevealCandidate>();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Covers(candidate.Path, targetPath))
|
||||
{
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<TreeRevealCandidate> pool;
|
||||
if (preferFavorites || currentlyInFavorites)
|
||||
{
|
||||
var favorites = Matches(matches, favorite: true);
|
||||
pool = favorites.Count > 0 ? favorites : Matches(matches, favorite: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var others = Matches(matches, favorite: false);
|
||||
pool = others.Count > 0 ? others : matches;
|
||||
}
|
||||
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TreeRevealCandidate best = pool[0];
|
||||
var bestLength = NormalizedLength(best.Path);
|
||||
for (var i = 1; i < pool.Count; i++)
|
||||
{
|
||||
var length = NormalizedLength(pool[i].Path);
|
||||
if (length > bestLength)
|
||||
{
|
||||
best = pool[i];
|
||||
bestLength = length;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static bool Covers(string rootPath, string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || LocationRoots.IsVirtual(rootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = Normalize(rootPath);
|
||||
var target = Normalize(targetPath);
|
||||
return target.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| target.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<TreeRevealCandidate> Matches(List<TreeRevealCandidate> matches, bool favorite)
|
||||
{
|
||||
var result = new List<TreeRevealCandidate>();
|
||||
foreach (var candidate in matches)
|
||||
{
|
||||
if (candidate.IsFavorite == favorite)
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
=> PathRules.FromExtended(path).TrimEnd('\\');
|
||||
|
||||
private static int NormalizedLength(string path)
|
||||
=> Normalize(path).Length;
|
||||
}
|
||||
@@ -36,8 +36,13 @@ public sealed record UiPreferences(
|
||||
string? OrganizeDevelopment = null,
|
||||
bool AutoIndexRemovable = false,
|
||||
bool BackgroundHostAtLogon = false,
|
||||
IReadOnlyList<string>? FavoriteFolders = null,
|
||||
IReadOnlyList<SessionTabState>? SessionTabs = null,
|
||||
int SessionActiveTab = 0)
|
||||
int SessionActiveTab = 0,
|
||||
bool PreferFavoritesInTree = false,
|
||||
bool BackgroundMaintenanceWhenIdle = true,
|
||||
int IdleMaintenanceMinutes = 10,
|
||||
bool IdleMaintenanceAcOnly = true)
|
||||
{
|
||||
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
||||
}
|
||||
@@ -84,11 +89,16 @@ public sealed class UiPreferencesStore
|
||||
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
|
||||
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
|
||||
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
|
||||
"prefer-favorites-in-tree=" + (preferences.PreferFavoritesInTree ? "true" : "false"),
|
||||
"background-maintenance-when-idle=" + (preferences.BackgroundMaintenanceWhenIdle ? "true" : "false"),
|
||||
"idle-maintenance-minutes=" + NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
|
||||
"idle-maintenance-ac-only=" + (preferences.IdleMaintenanceAcOnly ? "true" : "false"),
|
||||
.. SevenZipLines(preferences),
|
||||
.. GitLines(preferences),
|
||||
.. FfmpegLines(preferences),
|
||||
.. OrganizeLines(preferences),
|
||||
.. LayoutLines(preferences),
|
||||
.. FavoriteLines(preferences),
|
||||
.. SessionLines(preferences)
|
||||
]);
|
||||
}
|
||||
@@ -109,6 +119,10 @@ public sealed class UiPreferencesStore
|
||||
var autoClearQueue = false;
|
||||
var autoIndexRemovable = false;
|
||||
var backgroundHostAtLogon = false;
|
||||
var preferFavoritesInTree = false;
|
||||
var backgroundMaintenanceWhenIdle = true;
|
||||
var idleMaintenanceMinutes = 10;
|
||||
var idleMaintenanceAcOnly = true;
|
||||
string? sevenZipPath = null;
|
||||
string? gitPath = null;
|
||||
string? ffmpegPath = null;
|
||||
@@ -127,6 +141,7 @@ public sealed class UiPreferencesStore
|
||||
double? treeWidth = null;
|
||||
var sessionTabs = new List<SessionTabState>();
|
||||
var sessionActiveTab = 0;
|
||||
var favorites = new List<string>();
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw.Trim();
|
||||
@@ -179,6 +194,23 @@ public sealed class UiPreferencesStore
|
||||
{
|
||||
backgroundHostAtLogon = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("prefer-favorites-in-tree", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
preferFavoritesInTree = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("background-maintenance-when-idle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
backgroundMaintenanceWhenIdle = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("idle-maintenance-minutes", StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var minutes))
|
||||
{
|
||||
idleMaintenanceMinutes = NormalizeIdleMinutes(minutes);
|
||||
}
|
||||
else if (key.Equals("idle-maintenance-ac-only", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
idleMaintenanceAcOnly = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
@@ -243,6 +275,11 @@ public sealed class UiPreferencesStore
|
||||
{
|
||||
treeWidth = ParseDouble(value);
|
||||
}
|
||||
else if (key.Equals("favorite", StringComparison.OrdinalIgnoreCase)
|
||||
&& favorites.Count < FavoriteFolders.MaxCount)
|
||||
{
|
||||
favorites.Add(value);
|
||||
}
|
||||
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
|
||||
&& activeTab >= 0)
|
||||
@@ -266,7 +303,9 @@ public sealed class UiPreferencesStore
|
||||
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
|
||||
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, ffmpegPath,
|
||||
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
|
||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
|
||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
|
||||
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
|
||||
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
||||
@@ -364,6 +403,14 @@ public sealed class UiPreferencesStore
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FavoriteLines(UiPreferences preferences)
|
||||
{
|
||||
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
|
||||
{
|
||||
yield return "favorite=" + path;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SessionLines(UiPreferences preferences)
|
||||
{
|
||||
var tabs = preferences.SessionTabs;
|
||||
@@ -443,6 +490,9 @@ public sealed class UiPreferencesStore
|
||||
public static string NormalizeTheme(string? theme)
|
||||
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
|
||||
|
||||
public static int NormalizeIdleMinutes(int minutes)
|
||||
=> minutes <= 5 ? 5 : minutes >= 30 ? 30 : 10;
|
||||
|
||||
private static string? EmptyToNull(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ public sealed class LocalSourceHost : ISourceHost
|
||||
=> _sources.EnsureForPathAsync(path, cancellationToken);
|
||||
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> _sources.ForgetDisconnectedAsync(path, cancellationToken);
|
||||
public Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> _sources.MarkReachableAsync(path, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class LocalIndexMutations : IIndexMutations
|
||||
|
||||
34
src/Explorer.Contracts/IBackgroundMaintenance.cs
Normal file
34
src/Explorer.Contracts/IBackgroundMaintenance.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Explorer.Contracts;
|
||||
|
||||
public sealed record MaintenanceSnapshot(
|
||||
string Activity,
|
||||
bool Allowed,
|
||||
string Message,
|
||||
string? SkipReason = null)
|
||||
{
|
||||
public static MaintenanceSnapshot Empty { get; } = new("Active", false, "");
|
||||
}
|
||||
|
||||
public interface IBackgroundMaintenance
|
||||
{
|
||||
event EventHandler<MaintenanceSnapshot>? Changed;
|
||||
MaintenanceSnapshot Snapshot { get; }
|
||||
void RunNow();
|
||||
}
|
||||
|
||||
public sealed class NullBackgroundMaintenance : IBackgroundMaintenance
|
||||
{
|
||||
public static NullBackgroundMaintenance Instance { get; } = new();
|
||||
|
||||
public event EventHandler<MaintenanceSnapshot>? Changed
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public MaintenanceSnapshot Snapshot => MaintenanceSnapshot.Empty;
|
||||
|
||||
public void RunNow()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public interface IIndexingHost
|
||||
void EnqueueFullScan(long sourceId);
|
||||
void EnqueueFolderScan(long sourceId, string pathRel);
|
||||
void EnqueueReconcile(long sourceId, string pathRel);
|
||||
/// <summary>Shallow reconcile, then descend into child folders whose index no longer matches disk.</summary>
|
||||
void EnqueueVerify(long sourceId, string pathRel) => EnqueueReconcile(sourceId, pathRel);
|
||||
void Cancel(long sourceId);
|
||||
}
|
||||
|
||||
@@ -63,6 +65,8 @@ public interface ISourceHost
|
||||
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Source?>(null);
|
||||
}
|
||||
|
||||
public interface IIndexMutations
|
||||
|
||||
@@ -159,6 +159,7 @@ public sealed class SourceSnapshot
|
||||
public interface IHashStore
|
||||
{
|
||||
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
||||
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
|
||||
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -19,5 +19,6 @@ public static class AppConstants
|
||||
public const int NetworkScanParallelism = 1;
|
||||
public const int ProgressHzMilliseconds = 100;
|
||||
public const int MaxArchiveEntries = 8000;
|
||||
public const int IdleRescanAfterDays = 7;
|
||||
public static readonly TimeSpan SyncTimestampSkew = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ public sealed class FileSystemItem
|
||||
public long? CapacityBytes { get; init; }
|
||||
public bool AvailableToImport { get; init; }
|
||||
public ItemHydrationFlags Hydration { get; init; } = ItemHydrationFlags.All;
|
||||
public int IndexedChildCount { get; init; }
|
||||
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
||||
|
||||
public FileSystemItem Overlay(
|
||||
@@ -166,7 +167,8 @@ public sealed class FileSystemItem
|
||||
long? freeSpaceBytes = null,
|
||||
long? capacityBytes = null,
|
||||
bool? availableToImport = null,
|
||||
ItemHydrationFlags? hydration = null)
|
||||
ItemHydrationFlags? hydration = null,
|
||||
int? indexedChildCount = null)
|
||||
=> new()
|
||||
{
|
||||
FullPath = FullPath,
|
||||
@@ -186,7 +188,8 @@ public sealed class FileSystemItem
|
||||
FreeSpaceBytes = freeSpaceBytes ?? FreeSpaceBytes,
|
||||
CapacityBytes = capacityBytes ?? CapacityBytes,
|
||||
AvailableToImport = availableToImport ?? AvailableToImport,
|
||||
Hydration = hydration ?? Hydration
|
||||
Hydration = hydration ?? Hydration,
|
||||
IndexedChildCount = indexedChildCount ?? IndexedChildCount
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +211,8 @@ public sealed record ScanProgress
|
||||
public long BytesSeen { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
public ScanJobStatus Status { get; init; }
|
||||
/// <summary>Index overlay should be reapplied; do not show a full-scan footer.</summary>
|
||||
public bool IndexRefresh { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FolderListing
|
||||
|
||||
@@ -3,10 +3,12 @@ namespace Explorer.Domain;
|
||||
public static class LocationRoots
|
||||
{
|
||||
public const string ThisPc = "This PC";
|
||||
public const string Home = "Home";
|
||||
public const string Favorites = "Favorites";
|
||||
public const string Network = "Network";
|
||||
public const string Cloud = "Cloud";
|
||||
public const string RecycleBin = "Recycle Bin";
|
||||
|
||||
public static bool IsVirtual(string? path)
|
||||
=> path is ThisPc or Network or Cloud or RecycleBin;
|
||||
=> path is ThisPc or Home or Favorites or Network or Cloud or RecycleBin;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
@@ -6,7 +7,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.FileOperations;
|
||||
|
||||
public sealed class TransferQueue : BackgroundService, ITransferHost
|
||||
public sealed class TransferQueue : BackgroundService, ITransferHost, IForegroundWorkSignal
|
||||
{
|
||||
private readonly IOperationExecutor _executor;
|
||||
private readonly IIndexStore _store;
|
||||
@@ -41,6 +42,15 @@ public sealed class TransferQueue : BackgroundService, ITransferHost
|
||||
|
||||
public bool IsPaused => _queuePaused;
|
||||
|
||||
public bool HasForegroundWork()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _jobs.Any(j => j.Status is TransferStatus.Queued or TransferStatus.Running
|
||||
or TransferStatus.Cancelling or TransferStatus.Waiting);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<TransferJob> Snapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -22,6 +22,14 @@ public static class ExplorerHostClientServices
|
||||
services.AddSingleton(workbench.Transfers);
|
||||
services.AddSingleton(workbench.Sources);
|
||||
services.AddSingleton(workbench.Mutations);
|
||||
if (workbench is IBackgroundMaintenance maintenance)
|
||||
{
|
||||
services.AddSingleton(maintenance);
|
||||
}
|
||||
else
|
||||
{
|
||||
services.TryAddSingleton<IBackgroundMaintenance>(_ => NullBackgroundMaintenance.Instance);
|
||||
}
|
||||
services.AddSingleton(workbench as ICloudOverlay ?? NullCloudOverlay.Instance);
|
||||
if (workbench is IHostConnection connection)
|
||||
{
|
||||
@@ -52,6 +60,7 @@ public static class ExplorerHostClientServices
|
||||
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
|
||||
services.AddSingleton<IKnownUserFolderCatalog, WindowsKnownUserFolderCatalog>();
|
||||
services.AddSingleton(sp => new SourceManager(
|
||||
sp.GetRequiredService<IIndexStore>(),
|
||||
sp.GetRequiredService<IVolumeService>(),
|
||||
|
||||
@@ -8,7 +8,7 @@ using Explorer.Plugin.Abstractions;
|
||||
|
||||
namespace Explorer.Hosting.Ipc;
|
||||
|
||||
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IAsyncDisposable
|
||||
public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostConnection, IBackgroundMaintenance, IAsyncDisposable
|
||||
{
|
||||
private NamedPipeClientStream _pipe;
|
||||
private StreamWriter _writer;
|
||||
@@ -24,6 +24,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
private readonly MutationProxy _mutations;
|
||||
private bool _disposed;
|
||||
private bool _suppressRestart;
|
||||
private MaintenanceSnapshot _maintenance = MaintenanceSnapshot.Empty;
|
||||
|
||||
private WorkbenchPipeClient(NamedPipeClientStream pipe, WorkbenchIpcOptions options)
|
||||
{
|
||||
@@ -44,6 +45,11 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
public IIndexMutations Mutations => _mutations;
|
||||
public bool IsConnected => !_disposed && _pipe.IsConnected;
|
||||
public event EventHandler<string>? StatusChanged;
|
||||
public event EventHandler<MaintenanceSnapshot>? Changed;
|
||||
|
||||
public MaintenanceSnapshot Snapshot => _maintenance;
|
||||
|
||||
public void RunNow() => Call("Maintenance.RunNow");
|
||||
|
||||
public static async Task<WorkbenchPipeClient> ConnectAsync(
|
||||
WorkbenchIpcOptions options,
|
||||
@@ -207,8 +213,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
internal IpcEnvelope Call(string op, long? n = null, string? s = null)
|
||||
=> CallAsync(op, CancellationToken.None, n, s).GetAwaiter().GetResult();
|
||||
internal IpcEnvelope Call(string op, long? n = null, string? s = null, bool? flag = null)
|
||||
=> CallAsync(op, CancellationToken.None, n, s, flag: flag).GetAwaiter().GetResult();
|
||||
|
||||
internal async Task<IpcEnvelope> CallAsync(
|
||||
string op,
|
||||
@@ -218,16 +224,20 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
string? dest = null,
|
||||
string[]? paths = null,
|
||||
bool? flag = null,
|
||||
string? payload = null)
|
||||
string? payload = null,
|
||||
TimeSpan? callTimeout = null)
|
||||
{
|
||||
var timeout = callTimeout ?? TimeSpan.FromSeconds(15);
|
||||
try
|
||||
{
|
||||
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
|
||||
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload, timeout)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (!_disposed && !_suppressRestart && ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
await RecycleAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload).ConfigureAwait(false);
|
||||
return await SendOnceAsync(op, cancellationToken, n, s, dest, paths, flag, payload, timeout)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +249,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
string? dest,
|
||||
string[]? paths,
|
||||
bool? flag,
|
||||
string? payload)
|
||||
string? payload,
|
||||
TimeSpan callTimeout)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var tcs = new TaskCompletionSource<IpcEnvelope>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -272,7 +283,7 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
_send.Release();
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||
using var timeout = new CancellationTokenSource(callTimeout);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token, _cts.Token);
|
||||
using var cancelReg = linked.Token.Register(() => tcs.TrySetCanceled(linked.Token));
|
||||
try
|
||||
@@ -285,6 +296,10 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
|
||||
return reply;
|
||||
}
|
||||
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"Host call '{op}' timed out after {callTimeout.TotalSeconds:0}s.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
@@ -339,6 +354,17 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
internal void RaiseChanged() => _transfers.RaiseChanged();
|
||||
internal void RaiseFinished(TransferJob job) => _transfers.RaiseFinished(job);
|
||||
|
||||
private void RaiseMaintenance(IpcEnvelope envelope)
|
||||
{
|
||||
var snapshot = ReadPayload<MaintenanceSnapshot>(envelope.Payload)
|
||||
?? new MaintenanceSnapshot(
|
||||
envelope.Flag == true ? "Idle" : "Active",
|
||||
envelope.Flag == true,
|
||||
envelope.S ?? "");
|
||||
_maintenance = snapshot;
|
||||
Changed?.Invoke(this, snapshot);
|
||||
}
|
||||
|
||||
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -378,6 +404,9 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
case "Indexing.Progress" when envelope.Progress is not null:
|
||||
RaiseProgress(envelope.Progress);
|
||||
break;
|
||||
case "Maintenance.Status":
|
||||
RaiseMaintenance(envelope);
|
||||
break;
|
||||
case "Transfers.Changed":
|
||||
RaiseChanged();
|
||||
break;
|
||||
@@ -447,6 +476,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
=> _client.Call("Indexing.EnqueueFolderScan", sourceId, pathRel);
|
||||
public void EnqueueReconcile(long sourceId, string pathRel)
|
||||
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel);
|
||||
public void EnqueueVerify(long sourceId, string pathRel)
|
||||
=> _client.Call("Indexing.EnqueueReconcile", sourceId, pathRel, flag: true);
|
||||
public void Cancel(long sourceId) => _client.Call("Indexing.Cancel", sourceId);
|
||||
public void Raise(ScanProgress progress) => ProgressChanged?.Invoke(this, progress);
|
||||
}
|
||||
@@ -506,6 +537,8 @@ public sealed class WorkbenchPipeClient : IWorkbenchHost, ICloudOverlay, IHostCo
|
||||
=> CallSource("Sources.EnsureForPath", path, cancellationToken);
|
||||
public async Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> (await _client.CallAsync("Sources.Forget", cancellationToken, s: path).ConfigureAwait(false)).Flag == true;
|
||||
public Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> CallSource("Sources.MarkReachable", path, cancellationToken);
|
||||
private async Task<Source?> CallSource(string op, string path, CancellationToken cancellationToken)
|
||||
=> (await _client.CallAsync(op, cancellationToken, s: path).ConfigureAwait(false)).Source;
|
||||
}
|
||||
|
||||
@@ -13,23 +13,43 @@ public static class WorkbenchHostConnector
|
||||
ILogger? logger = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
var options = new WorkbenchIpcOptions();
|
||||
logger?.LogInformation("Connecting to named pipe {Pipe}", options.PipeName);
|
||||
if (await EnsureHostAsync(timeout, logger, cancellationToken).ConfigureAwait(false))
|
||||
if (!await EnsureHostAsync(timeout, logger, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WorkbenchPipeClient? client = null;
|
||||
try
|
||||
{
|
||||
return await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(2), cancellationToken)
|
||||
client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(2), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var readyTimeout = timeout - Stopwatch.GetElapsedTime(started);
|
||||
if (readyTimeout < TimeSpan.FromSeconds(1))
|
||||
{
|
||||
readyTimeout = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
|
||||
logger?.LogInformation(
|
||||
"Waiting until the background host has finished starting ({Timeout}s)",
|
||||
Math.Ceiling(readyTimeout.TotalSeconds));
|
||||
await client.CallAsync("Host.Ready", cancellationToken, callTimeout: readyTimeout)
|
||||
.ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger?.LogWarning(ex, "Could not connect to Explorer.Host.exe");
|
||||
}
|
||||
if (client is not null)
|
||||
{
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> EnsureHostAsync(
|
||||
TimeSpan timeout,
|
||||
|
||||
266
src/Explorer.Hosting/BackgroundMaintenanceCoordinator.cs
Normal file
266
src/Explorer.Hosting/BackgroundMaintenanceCoordinator.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Hosting;
|
||||
|
||||
public sealed class BackgroundMaintenanceCoordinator : BackgroundService, IBackgroundMaintenance
|
||||
{
|
||||
private readonly IUserIdleMonitor _idle;
|
||||
private readonly IPowerSourceMonitor _power;
|
||||
private readonly UiPreferencesStore _preferences;
|
||||
private readonly IForegroundWorkSignal _foreground;
|
||||
private readonly IIdleIndexWork _indexing;
|
||||
private readonly IIdleHashWork _hash;
|
||||
private readonly IHistoryMaintenance _history;
|
||||
private readonly IIndexStore _store;
|
||||
private readonly IVolumeService _volumes;
|
||||
private readonly ILogger<BackgroundMaintenanceCoordinator> _logger;
|
||||
private readonly HashSet<long> _collisionEnqueued = [];
|
||||
private readonly HashSet<long> _idleScanQueued = [];
|
||||
private readonly HashSet<long> _idleVerified = [];
|
||||
private readonly object _gate = new();
|
||||
|
||||
private volatile bool _runNow;
|
||||
private UserActivityState _lastActivity = UserActivityState.Active;
|
||||
private bool _lastAllowed;
|
||||
private MaintenanceSkipReason _lastSkip = MaintenanceSkipReason.UserActive;
|
||||
private bool _hadMaintenance;
|
||||
private bool _loggedIdleComplete;
|
||||
private MaintenanceSnapshot _snapshot = MaintenanceSnapshot.Empty;
|
||||
private string? _workMessage;
|
||||
|
||||
public BackgroundMaintenanceCoordinator(
|
||||
IUserIdleMonitor idle,
|
||||
IPowerSourceMonitor power,
|
||||
UiPreferencesStore preferences,
|
||||
IForegroundWorkSignal foreground,
|
||||
IIdleIndexWork indexing,
|
||||
IIdleHashWork hash,
|
||||
IHistoryMaintenance history,
|
||||
IIndexStore store,
|
||||
IVolumeService volumes,
|
||||
ILogger<BackgroundMaintenanceCoordinator> logger)
|
||||
{
|
||||
_idle = idle;
|
||||
_power = power;
|
||||
_preferences = preferences;
|
||||
_foreground = foreground;
|
||||
_indexing = indexing;
|
||||
_hash = hash;
|
||||
_history = history;
|
||||
_store = store;
|
||||
_volumes = volumes;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public event EventHandler<MaintenanceSnapshot>? Changed;
|
||||
|
||||
public MaintenanceSnapshot Snapshot
|
||||
{
|
||||
get { lock (_gate) { return _snapshot; } }
|
||||
}
|
||||
|
||||
public void RunNow() => _runNow = true;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_hash.Pause();
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await TickAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Background maintenance tick failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var prefs = _preferences.Load();
|
||||
var idleFor = _idle.GetIdleDuration();
|
||||
var hashPending = await _hash.HasPendingAsync(cancellationToken).ConfigureAwait(false);
|
||||
var inputs = new BackgroundWorkInputs(
|
||||
prefs.BackgroundMaintenanceWhenIdle,
|
||||
TimeSpan.FromMinutes(UiPreferencesStore.NormalizeIdleMinutes(prefs.IdleMaintenanceMinutes)),
|
||||
idleFor,
|
||||
prefs.IdleMaintenanceAcOnly,
|
||||
_power.IsOnAcPower,
|
||||
_foreground.HasForegroundWork(),
|
||||
_runNow);
|
||||
var decision = BackgroundWorkPolicy.Evaluate(inputs);
|
||||
_indexing.SetIdleAllowed(decision.MaintenanceAllowed);
|
||||
|
||||
if (decision.Activity != _lastActivity)
|
||||
{
|
||||
_logger.LogDebug("{From} -> {To}", _lastActivity, decision.Activity);
|
||||
_lastActivity = decision.Activity;
|
||||
}
|
||||
|
||||
if (!decision.MaintenanceAllowed)
|
||||
{
|
||||
_idleScanQueued.Clear();
|
||||
_idleVerified.Clear();
|
||||
if (!_hash.IsPaused)
|
||||
{
|
||||
_hash.Pause();
|
||||
}
|
||||
|
||||
if (_lastAllowed)
|
||||
{
|
||||
_logger.LogDebug("maintenance paused ({Reason})", BackgroundWorkPolicy.Describe(decision.SkipReason));
|
||||
}
|
||||
else if (decision.SkipReason != _lastSkip)
|
||||
{
|
||||
_logger.LogDebug("maintenance skipped ({Reason})", BackgroundWorkPolicy.Describe(decision.SkipReason));
|
||||
}
|
||||
|
||||
Publish(decision, PauseMessage(decision));
|
||||
_lastAllowed = false;
|
||||
_lastSkip = decision.SkipReason;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_lastAllowed)
|
||||
{
|
||||
_logger.LogDebug(_hadMaintenance ? "maintenance resumed" : "maintenance started");
|
||||
_loggedIdleComplete = false;
|
||||
}
|
||||
|
||||
_lastAllowed = true;
|
||||
_lastSkip = MaintenanceSkipReason.None;
|
||||
_hadMaintenance = true;
|
||||
|
||||
if (_indexing.IsBusy || _indexing.HasIdleWork)
|
||||
{
|
||||
if (!_hash.IsPaused)
|
||||
{
|
||||
_hash.Pause();
|
||||
}
|
||||
|
||||
_workMessage = _indexing.HasIdleWork
|
||||
? (_workMessage ?? "Scanning")
|
||||
: "Idle maintenance";
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
var verify = BackgroundMaintenancePlanner.NextLocalVerify(sources, _idleVerified);
|
||||
if (verify is not null && verify.LastRootPath is not null && _volumes.IsPathReachable(verify.LastRootPath))
|
||||
{
|
||||
if (!_hash.IsPaused)
|
||||
{
|
||||
_hash.Pause();
|
||||
}
|
||||
|
||||
_idleVerified.Add(verify.Id);
|
||||
_indexing.EnqueueIdleVerify(verify.Id, "");
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance · checking " + verify.DisplayName;
|
||||
_logger.LogDebug("maintenance started verify {Source}", verify.DisplayName);
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
var scan = BackgroundMaintenancePlanner.NextLocalScan(sources, DateTimeOffset.UtcNow, _idleScanQueued);
|
||||
if (scan is not null && scan.LastRootPath is not null && _volumes.IsPathReachable(scan.LastRootPath))
|
||||
{
|
||||
if (!_hash.IsPaused)
|
||||
{
|
||||
_hash.Pause();
|
||||
}
|
||||
|
||||
_idleScanQueued.Add(scan.Id);
|
||||
_indexing.EnqueueIdleFullScan(scan.Id);
|
||||
_runNow = false;
|
||||
_workMessage = "Scanning " + scan.DisplayName;
|
||||
_logger.LogDebug("maintenance started scan {Source}", scan.DisplayName);
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
_hash.Resume();
|
||||
if (hashPending)
|
||||
{
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance";
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _history.TryCaptureAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance";
|
||||
_logger.LogDebug("maintenance completed (history)");
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
var hashSource = BackgroundMaintenancePlanner.NextHashCollisionSource(sources, _collisionEnqueued);
|
||||
if (hashSource is not null)
|
||||
{
|
||||
_collisionEnqueued.Add(hashSource.Id);
|
||||
await _store.Hashes.EnqueueSizeCollisionsAsync(hashSource.Id, cancellationToken).ConfigureAwait(false);
|
||||
_workMessage = "Idle maintenance";
|
||||
Publish(decision, _workMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
_runNow = false;
|
||||
_workMessage = "Idle maintenance";
|
||||
if (!_loggedIdleComplete)
|
||||
{
|
||||
_logger.LogDebug("maintenance completed");
|
||||
_loggedIdleComplete = true;
|
||||
}
|
||||
Publish(decision, _workMessage);
|
||||
}
|
||||
|
||||
private string PauseMessage(BackgroundWorkDecision decision)
|
||||
{
|
||||
if (decision.SkipReason == MaintenanceSkipReason.ForegroundOperations)
|
||||
{
|
||||
return _hadMaintenance ? "Paused because a file operation is running" : "";
|
||||
}
|
||||
|
||||
if (decision.Activity == UserActivityState.Active && _hadMaintenance)
|
||||
{
|
||||
return "Paused because user is active";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private void Publish(BackgroundWorkDecision decision, string message)
|
||||
{
|
||||
var snapshot = new MaintenanceSnapshot(
|
||||
decision.Activity.ToString(),
|
||||
decision.MaintenanceAllowed,
|
||||
message,
|
||||
decision.SkipReason == MaintenanceSkipReason.None ? null : BackgroundWorkPolicy.Describe(decision.SkipReason));
|
||||
lock (_gate)
|
||||
{
|
||||
if (_snapshot == snapshot)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_snapshot = snapshot;
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, snapshot);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,9 @@ public static class ExplorerHostServices
|
||||
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
|
||||
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
|
||||
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
|
||||
services.AddSingleton<IKnownUserFolderCatalog, WindowsKnownUserFolderCatalog>();
|
||||
services.AddSingleton<IUserIdleMonitor, WindowsUserIdleMonitor>();
|
||||
services.AddSingleton<IPowerSourceMonitor, WindowsPowerSourceMonitor>();
|
||||
services.AddSingleton(sp => new SourceManager(
|
||||
sp.GetRequiredService<IIndexStore>(),
|
||||
sp.GetRequiredService<IVolumeService>(),
|
||||
@@ -85,6 +88,7 @@ public static class ExplorerHostServices
|
||||
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
|
||||
services.AddSingleton<TransferQueue>();
|
||||
services.AddSingleton<ITransferHost>(sp => sp.GetRequiredService<TransferQueue>());
|
||||
services.AddSingleton<IForegroundWorkSignal>(sp => sp.GetRequiredService<TransferQueue>());
|
||||
services.AddSingleton<IIndexingHost>(sp => sp.GetRequiredService<IndexingCoordinator>());
|
||||
services.AddSingleton<IIndexMutations>(sp => new LocalIndexMutations(sp.GetRequiredService<IIndexStore>()));
|
||||
services.AddSingleton<IWorkbenchHost>(sp => new WorkbenchHost(
|
||||
@@ -94,10 +98,15 @@ public static class ExplorerHostServices
|
||||
sp.GetRequiredService<IIndexMutations>()));
|
||||
services.AddSingleton<DuplicateHashWorker>();
|
||||
services.AddSingleton<HistoryRollupService>();
|
||||
services.AddSingleton<IIdleIndexWork>(sp => sp.GetRequiredService<IndexingCoordinator>());
|
||||
services.AddSingleton<IIdleHashWork>(sp => sp.GetRequiredService<DuplicateHashWorker>());
|
||||
services.AddSingleton<IHistoryMaintenance>(sp => sp.GetRequiredService<HistoryRollupService>());
|
||||
services.AddSingleton<BackgroundMaintenanceCoordinator>();
|
||||
services.AddSingleton<IBackgroundMaintenance>(sp => sp.GetRequiredService<BackgroundMaintenanceCoordinator>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<BackgroundMaintenanceCoordinator>());
|
||||
services.AddHostedService<WatcherHostedService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -200,7 +200,17 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
void OnFinished(object? sender, TransferJob job)
|
||||
=> _ = WriteAsync(writer, new IpcEnvelope { Evt = "Transfers.JobFinished", Job = job }, stoppingToken);
|
||||
|
||||
void OnMaintenance(object? sender, MaintenanceSnapshot snapshot)
|
||||
=> _ = WriteAsync(writer, new IpcEnvelope
|
||||
{
|
||||
Evt = "Maintenance.Status",
|
||||
S = snapshot.Message,
|
||||
Flag = snapshot.Allowed,
|
||||
Payload = JsonSerializer.Serialize(snapshot, WorkbenchIpc.Json)
|
||||
}, stoppingToken);
|
||||
|
||||
var hooked = false;
|
||||
IBackgroundMaintenance? hookedMaintenance = null;
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
@@ -235,6 +245,12 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
Workbench.Indexing.ProgressChanged += OnProgress;
|
||||
Workbench.Transfers.Changed += OnChanged;
|
||||
Workbench.Transfers.JobFinished += OnFinished;
|
||||
if (_services.GetService<IBackgroundMaintenance>() is { } maintenance)
|
||||
{
|
||||
maintenance.Changed += OnMaintenance;
|
||||
hookedMaintenance = maintenance;
|
||||
}
|
||||
|
||||
hooked = true;
|
||||
}
|
||||
|
||||
@@ -251,6 +267,11 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
Workbench.Transfers.Changed -= OnChanged;
|
||||
Workbench.Transfers.JobFinished -= OnFinished;
|
||||
}
|
||||
|
||||
if (hookedMaintenance is not null)
|
||||
{
|
||||
hookedMaintenance.Changed -= OnMaintenance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +304,19 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
{
|
||||
case "Ping":
|
||||
return reply;
|
||||
case "Host.Ready":
|
||||
return reply;
|
||||
case "Maintenance.RunNow":
|
||||
_services.GetService<IBackgroundMaintenance>()?.RunNow();
|
||||
return reply;
|
||||
case "Maintenance.Snapshot":
|
||||
{
|
||||
var snap = _services.GetService<IBackgroundMaintenance>()?.Snapshot ?? MaintenanceSnapshot.Empty;
|
||||
reply.S = snap.Message;
|
||||
reply.Flag = snap.Allowed;
|
||||
reply.Payload = JsonSerializer.Serialize(snap, WorkbenchIpc.Json);
|
||||
return reply;
|
||||
}
|
||||
case "Host.Shutdown":
|
||||
await RequestShutdownAsync().ConfigureAwait(false);
|
||||
return reply;
|
||||
@@ -293,7 +327,15 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
Workbench.Indexing.EnqueueFolderScan(request.N ?? 0, request.S ?? "");
|
||||
return reply;
|
||||
case "Indexing.EnqueueReconcile":
|
||||
if (request.Flag == true)
|
||||
{
|
||||
Workbench.Indexing.EnqueueVerify(request.N ?? 0, request.S ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
Workbench.Indexing.EnqueueReconcile(request.N ?? 0, request.S ?? "");
|
||||
}
|
||||
|
||||
return reply;
|
||||
case "Indexing.Cancel":
|
||||
Workbench.Indexing.Cancel(request.N ?? 0);
|
||||
@@ -381,6 +423,9 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
case "Sources.Forget":
|
||||
reply.Flag = await Workbench.Sources.ForgetAsync(request.S ?? "").ConfigureAwait(false);
|
||||
return reply;
|
||||
case "Sources.MarkReachable":
|
||||
reply.Source = await Workbench.Sources.MarkReachableAsync(request.S ?? "").ConfigureAwait(false);
|
||||
return reply;
|
||||
case "Mutations.UpsertSyncProfile":
|
||||
reply.N = await Workbench.Mutations.UpsertSyncProfileAsync(Read<SyncProfile>(request.Payload)).ConfigureAwait(false);
|
||||
return reply;
|
||||
@@ -401,6 +446,7 @@ public sealed class WorkbenchPipeServer : BackgroundService
|
||||
return reply;
|
||||
case "Mutations.EnqueueHashCollisions":
|
||||
await Workbench.Mutations.EnqueueHashCollisionsAsync(request.N is 0 or null ? null : request.N).ConfigureAwait(false);
|
||||
_services.GetService<IIdleHashWork>()?.BeginUserRequested();
|
||||
return reply;
|
||||
case "Mutations.UpsertRelation":
|
||||
await Workbench.Mutations.UpsertRelationAsync(Read<FileRelation>(request.Payload)).ConfigureAwait(false);
|
||||
|
||||
@@ -26,18 +26,33 @@ public sealed class FolderReconciler
|
||||
_preferences = preferences;
|
||||
}
|
||||
|
||||
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||
public Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||
=> ReconcileAsync(source, pathRel, cancellationToken, verifyChildren: false);
|
||||
|
||||
public Task<bool> ReconcileAsync(
|
||||
Source source,
|
||||
string pathRel,
|
||||
CancellationToken cancellationToken,
|
||||
bool verifyChildren)
|
||||
=> ReconcileCoreAsync(source, pathRel, verifyChildren, remaining: 48, cancellationToken);
|
||||
|
||||
private async Task<bool> ReconcileCoreAsync(
|
||||
Source source,
|
||||
string pathRel,
|
||||
bool verifyChildren,
|
||||
int remaining,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source.LastRootPath))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var full = PathRules.Combine(source.LastRootPath, pathRel);
|
||||
var parent = await _store.Entries.GetByPathAsync(source.Id, pathRel, cancellationToken).ConfigureAwait(false);
|
||||
if (parent is null)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!parent.IsDirectory)
|
||||
@@ -48,20 +63,20 @@ public sealed class FolderReconciler
|
||||
{
|
||||
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 false;
|
||||
}
|
||||
|
||||
return;
|
||||
await _store.Entries.TombstoneByPathPrefixAsync(source.Id, parent.PathRel, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Directory.Exists(full) || LocationClassifier.IsRecycleBinName(parent.Name))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
|
||||
@@ -73,6 +88,7 @@ public sealed class FolderReconciler
|
||||
var archivesToExpand = new List<IndexEntry>();
|
||||
var archivesToTomb = new List<string>();
|
||||
var prefs = _preferences?.Load() ?? UiPreferences.Default;
|
||||
var changed = false;
|
||||
|
||||
await _store.RunWriteAsync(async s =>
|
||||
{
|
||||
@@ -118,11 +134,13 @@ public sealed class FolderReconciler
|
||||
var delta = item.SizeBytes - oldSize;
|
||||
if (existing is null)
|
||||
{
|
||||
changed = true;
|
||||
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, item.SizeBytes, 1, 0, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (delta != 0)
|
||||
{
|
||||
changed = true;
|
||||
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
@@ -134,6 +152,7 @@ public sealed class FolderReconciler
|
||||
}
|
||||
else if (existing is null)
|
||||
{
|
||||
changed = true;
|
||||
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
@@ -143,8 +162,13 @@ public sealed class FolderReconciler
|
||||
{
|
||||
if (!liveNames.Contains(old.NameNorm))
|
||||
{
|
||||
changed = true;
|
||||
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
|
||||
if (!old.IsDirectory && ArchiveFormats.IsArchive(old.Name))
|
||||
if (old.IsDirectory)
|
||||
{
|
||||
archivesToTomb.Add(old.PathRel);
|
||||
}
|
||||
else if (ArchiveFormats.IsArchive(old.Name))
|
||||
{
|
||||
archivesToTomb.Add(old.PathRel);
|
||||
}
|
||||
@@ -166,5 +190,59 @@ public sealed class FolderReconciler
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verifyChildren || remaining <= 0)
|
||||
{
|
||||
return changed;
|
||||
}
|
||||
|
||||
changed |= await WalkSizedChildrenAsync(source, indexed, liveNames, remaining, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return changed;
|
||||
}
|
||||
|
||||
private async Task<bool> WalkSizedChildrenAsync(
|
||||
Source source,
|
||||
IReadOnlyList<IndexEntry> indexed,
|
||||
HashSet<string> liveNames,
|
||||
int remaining,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var changed = false;
|
||||
foreach (var child in indexed
|
||||
.Where(e => e.IsDirectory
|
||||
&& liveNames.Contains(e.NameNorm)
|
||||
&& (e.AggregateSize > 0 || e.ChildFileCount > 0 || e.ChildDirCount > 0))
|
||||
.OrderByDescending(e => e.AggregateSize))
|
||||
{
|
||||
if (remaining <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
remaining--;
|
||||
var full = PathRules.Combine(source.LastRootPath!, child.PathRel);
|
||||
var liveKids = _enumerator.EnumerateChildrenSafe(full, out var error);
|
||||
if (error is not null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var indexedKids = await _store.Entries.GetChildrenAsync(source.Id, child.Id, EntryStatus.Present, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var childLiveNames = new HashSet<string>(liveKids.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
|
||||
var matches = liveKids.Count == indexedKids.Count && indexedKids.All(e => childLiveNames.Contains(e.NameNorm));
|
||||
if (!matches)
|
||||
{
|
||||
changed |= await ReconcileCoreAsync(source, child.PathRel, verifyChildren: true, remaining, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
changed |= await WalkSizedChildrenAsync(source, indexedKids, childLiveNames, remaining, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
@@ -7,7 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Explorer.Indexing;
|
||||
|
||||
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
public sealed class IndexingCoordinator : BackgroundService, IIndexingHost, IIdleIndexWork
|
||||
{
|
||||
private readonly IIndexStore _store;
|
||||
private readonly FilesystemScanner _scanner;
|
||||
@@ -17,8 +18,10 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
private readonly IVolumeService _volumes;
|
||||
private readonly ILogger<IndexingCoordinator> _logger;
|
||||
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
|
||||
private readonly Dictionary<long, CancellationTokenSource> _running = new();
|
||||
private readonly Dictionary<long, RunningWork> _running = new();
|
||||
private readonly object _gate = new();
|
||||
private volatile bool _idleAllowed;
|
||||
private int _idleQueued;
|
||||
|
||||
public event EventHandler<ScanProgress>? ProgressChanged;
|
||||
|
||||
@@ -40,25 +43,89 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _running.Values.Any(w => w.Origin != IndexWorkOrigin.Periodic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasIdleWork
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _idleQueued > 0 || _running.Values.Any(w => w.Origin == IndexWorkOrigin.Idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetIdleAllowed(bool allowed)
|
||||
{
|
||||
_idleAllowed = allowed;
|
||||
if (allowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var work in _running.Values.Where(w => w.Origin == IndexWorkOrigin.Idle))
|
||||
{
|
||||
work.Cts.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void EnqueueFullScan(long sourceId)
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null));
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null, IndexWorkOrigin.User));
|
||||
|
||||
public void EnqueueIdleFullScan(long sourceId)
|
||||
=> EnqueueIdle(new IndexWork(WorkKind.Full, sourceId, null, IndexWorkOrigin.Idle));
|
||||
|
||||
public void EnqueueIdleVerify(long sourceId, string pathRel)
|
||||
=> EnqueueIdle(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.Idle, VerifyChildren: true));
|
||||
|
||||
private void EnqueueIdle(IndexWork work)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_idleQueued++;
|
||||
}
|
||||
|
||||
if (!_work.Writer.TryWrite(work))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_idleQueued = Math.Max(0, _idleQueued - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void EnqueueFolderScan(long sourceId, string pathRel)
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel));
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel, IndexWorkOrigin.User));
|
||||
|
||||
public void EnqueueReconcile(long sourceId, string pathRel)
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel));
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.Watcher, VerifyChildren: false));
|
||||
|
||||
public void EnqueueVerify(long sourceId, string pathRel)
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel, IndexWorkOrigin.User, VerifyChildren: true));
|
||||
|
||||
public void EnqueueUsn(long sourceId)
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null));
|
||||
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null, IndexWorkOrigin.Periodic));
|
||||
|
||||
public void Cancel(long sourceId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_running.TryGetValue(sourceId, out var cts))
|
||||
if (_running.TryGetValue(sourceId, out var work))
|
||||
{
|
||||
cts.Cancel();
|
||||
work.Cts.Cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +154,19 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
|
||||
private async Task RunAsync(IndexWork item, CancellationToken stoppingToken)
|
||||
{
|
||||
if (item.Origin == IndexWorkOrigin.Idle)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_idleQueued = Math.Max(0, _idleQueued - 1);
|
||||
}
|
||||
|
||||
if (!_idleAllowed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var source = await _store.Sources.GetAsync(item.SourceId, stoppingToken).ConfigureAwait(false);
|
||||
if (source is null)
|
||||
{
|
||||
@@ -96,7 +176,7 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
lock (_gate)
|
||||
{
|
||||
_running[item.SourceId] = linked;
|
||||
_running[item.SourceId] = new RunningWork(linked, item.Origin);
|
||||
}
|
||||
|
||||
try
|
||||
@@ -116,7 +196,22 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
case WorkKind.Reconcile:
|
||||
if (item.PathRel is not null)
|
||||
{
|
||||
await _reconciler.ReconcileAsync(source, item.PathRel, linked.Token).ConfigureAwait(false);
|
||||
var changed = await _reconciler
|
||||
.ReconcileAsync(source, item.PathRel, linked.Token, item.VerifyChildren)
|
||||
.ConfigureAwait(false);
|
||||
if (changed)
|
||||
{
|
||||
var full = source.LastRootPath is null
|
||||
? item.PathRel
|
||||
: PathRules.Combine(source.LastRootPath, item.PathRel);
|
||||
ProgressChanged?.Invoke(this, new ScanProgress
|
||||
{
|
||||
SourceId = source.Id,
|
||||
CurrentPath = full,
|
||||
Status = ScanJobStatus.Done,
|
||||
IndexRefresh = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -152,5 +247,14 @@ public sealed class IndexingCoordinator : BackgroundService, IIndexingHost
|
||||
|
||||
private enum WorkKind { Full, Folder, Reconcile, Usn }
|
||||
|
||||
private readonly record struct IndexWork(WorkKind Kind, long SourceId, string? PathRel);
|
||||
private enum IndexWorkOrigin { User, Watcher, Periodic, Idle }
|
||||
|
||||
private readonly record struct IndexWork(
|
||||
WorkKind Kind,
|
||||
long SourceId,
|
||||
string? PathRel,
|
||||
IndexWorkOrigin Origin,
|
||||
bool VerifyChildren = false);
|
||||
|
||||
private sealed record RunningWork(CancellationTokenSource Cts, IndexWorkOrigin Origin);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ public sealed class UsnChangeApplier
|
||||
if (existing is not null)
|
||||
{
|
||||
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
|
||||
if (existing.IsDirectory || ArchiveFormats.IsArchive(existing.Name))
|
||||
{
|
||||
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -140,7 +140,7 @@ public sealed class UsnChangeApplier
|
||||
if (existing is not null)
|
||||
{
|
||||
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsDirectory && ArchiveFormats.IsArchive(existing.Name))
|
||||
if (existing.IsDirectory || ArchiveFormats.IsArchive(existing.Name))
|
||||
{
|
||||
await store.Entries.TombstoneByPathPrefixAsync(source.Id, existing.PathRel, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -24,6 +24,10 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
private bool _didAutoSort;
|
||||
private int _browseGeneration;
|
||||
private Dictionary<string, FolderItemViewModel>? _rows;
|
||||
private bool _skipReconcileOnComplete;
|
||||
private readonly HashSet<string> _probedFolders = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _probeBusy;
|
||||
private bool _probeAgain;
|
||||
|
||||
[ObservableProperty] private string _currentPath = "This PC";
|
||||
[ObservableProperty] private bool _isOffline;
|
||||
@@ -38,6 +42,9 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string _gitBadge = "";
|
||||
[ObservableProperty] private bool _hasGitRepo;
|
||||
[ObservableProperty] private string _listingStatus = "0 items";
|
||||
[ObservableProperty] private bool _isEditingPath;
|
||||
[ObservableProperty] private string _pathEditText = "This PC";
|
||||
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
|
||||
|
||||
public ExplorerPaneViewModel(
|
||||
@@ -56,6 +63,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
_thumbnails = thumbnails;
|
||||
Items = new RangeObservableCollection<FolderItemViewModel>();
|
||||
SelectedItems = [];
|
||||
Items.CollectionChanged += (_, _) => RefreshListingStatus();
|
||||
SelectedItems.CollectionChanged += (_, _) => RefreshListingStatus();
|
||||
}
|
||||
|
||||
partial void OnGitBadgeChanged(string value) => OnPropertyChanged(nameof(HasGitBadge));
|
||||
@@ -79,13 +88,18 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
_userChoseSort = false;
|
||||
_awaitingSizeSort = false;
|
||||
_didAutoSort = false;
|
||||
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
|
||||
_probedFolders.Clear();
|
||||
_probeAgain = false;
|
||||
_viewport = new BrowseViewport();
|
||||
IsBusy = true;
|
||||
StatusMessage = null;
|
||||
GitBadge = "";
|
||||
HasGitRepo = false;
|
||||
try
|
||||
{
|
||||
CurrentPath = path;
|
||||
PathEditText = path;
|
||||
IsEditingPath = false;
|
||||
if (addHistory)
|
||||
{
|
||||
_history.Navigate(path);
|
||||
@@ -98,19 +112,16 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
|
||||
if (LocationRoots.IsVirtual(path))
|
||||
{
|
||||
GitBadge = "";
|
||||
HasGitRepo = false;
|
||||
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Items.Clear();
|
||||
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
|
||||
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
|
||||
IndexBannerText = ShowIndexBanner
|
||||
? "Build an index for this location to enable instant search and folder sizes."
|
||||
: null;
|
||||
var sizeFromIndex = CurrentSource is { IsIndexed: true };
|
||||
SelectedItems.Clear();
|
||||
CurrentSource = null;
|
||||
ShowIndexBanner = false;
|
||||
IndexBannerText = null;
|
||||
var sourceTask = _sources.FindByPathAsync(path, ct);
|
||||
var replaceListing = true;
|
||||
var published = false;
|
||||
|
||||
await foreach (var delta in _browse.ListProgressiveAsync(path, _viewport, ct).ConfigureAwait(true))
|
||||
@@ -120,6 +131,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentSource is null && sourceTask.IsCompleted)
|
||||
{
|
||||
ApplyCurrentSource(sourceTask);
|
||||
}
|
||||
|
||||
var sizeFromIndex = CurrentSource is { IsIndexed: true };
|
||||
IsOffline = delta.IsOffline;
|
||||
if (delta.Error is not null)
|
||||
{
|
||||
@@ -131,24 +148,56 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
var rows = delta.Added
|
||||
.Select(item => new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory))
|
||||
.ToList();
|
||||
if (replaceListing)
|
||||
{
|
||||
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_rows[row.FullPath] = row;
|
||||
}
|
||||
|
||||
Items.ReplaceAll(rows);
|
||||
replaceListing = false;
|
||||
}
|
||||
else if (_rows is not null)
|
||||
{
|
||||
foreach (var row in rows)
|
||||
{
|
||||
_rows[row.FullPath] = row;
|
||||
}
|
||||
|
||||
Items.AddRange(rows);
|
||||
}
|
||||
|
||||
published = true;
|
||||
IsBusy = false;
|
||||
RefreshListingStatus();
|
||||
}
|
||||
else if (delta.EnumerationComplete && replaceListing)
|
||||
{
|
||||
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
|
||||
Items.Clear();
|
||||
replaceListing = false;
|
||||
published = true;
|
||||
IsBusy = false;
|
||||
RefreshListingStatus();
|
||||
}
|
||||
|
||||
if (delta.Updated.Count > 0)
|
||||
{
|
||||
ApplyUpdates(delta.Updated, sizeFromIndex);
|
||||
RefreshListingStatus();
|
||||
}
|
||||
|
||||
if (delta.EnumerationComplete)
|
||||
{
|
||||
IsBusy = false;
|
||||
if (!sourceTask.IsCompleted)
|
||||
{
|
||||
await sourceTask.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
ApplyCurrentSource(sourceTask);
|
||||
if (CurrentSource is { Status: SourceStatus.Stale })
|
||||
{
|
||||
StatusMessage = string.IsNullOrEmpty(StatusMessage)
|
||||
@@ -160,11 +209,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
_ = ApplyGitAsync(path, ct);
|
||||
KickThumbnails();
|
||||
|
||||
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
|
||||
if (!_skipReconcileOnComplete
|
||||
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
|
||||
&& Directory.Exists(path))
|
||||
{
|
||||
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
|
||||
_indexing.EnqueueReconcile(src.Id, rel);
|
||||
KickFolderProbes(src, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +223,12 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
|| delta.HydrationComplete)
|
||||
{
|
||||
TryAutoSort(sizeMetadataReady: true);
|
||||
if (!_skipReconcileOnComplete
|
||||
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } indexed
|
||||
&& Directory.Exists(path))
|
||||
{
|
||||
KickFolderProbes(indexed, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,11 +255,8 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
public void NotifyViewport(IReadOnlyList<string> visiblePaths)
|
||||
{
|
||||
_viewport?.SetVisible(visiblePaths);
|
||||
if (_thumbnails is null || ViewMode != FolderViewMode.Preview || _rows is null)
|
||||
if (_thumbnails is not null && ViewMode == FolderViewMode.Preview && _rows is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
|
||||
foreach (var path in visiblePaths)
|
||||
{
|
||||
@@ -215,8 +269,21 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
_thumbnails.OnViewportChanged(this, visible);
|
||||
}
|
||||
|
||||
if (!_skipReconcileOnComplete
|
||||
&& CurrentSource is { IsIndexed: true, Status: SourceStatus.Online }
|
||||
&& _loadCts is { IsCancellationRequested: false } cts)
|
||||
{
|
||||
KickFolderProbes(CurrentSource, cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<FolderItemViewModel> SnapshotItems() => Items.ToList();
|
||||
|
||||
private void RefreshListingStatus()
|
||||
=> ListingStatus = FolderStatusText.Format(
|
||||
Items.Select(item => item.Item),
|
||||
SelectedItems.Select(item => item.Item));
|
||||
|
||||
private void KickThumbnails()
|
||||
{
|
||||
if (ViewMode != FolderViewMode.Preview || _thumbnails is null || Items.Count == 0)
|
||||
@@ -227,6 +294,64 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
_thumbnails.OnViewportChanged(this, Items.Take(40).ToList());
|
||||
}
|
||||
|
||||
private void KickFolderProbes(Source source, CancellationToken cancellationToken)
|
||||
=> _ = ProbeVisibleFoldersAsync(source, cancellationToken);
|
||||
|
||||
private async Task ProbeVisibleFoldersAsync(Source source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.LastRootPath is null || _skipReconcileOnComplete)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_probeBusy)
|
||||
{
|
||||
_probeAgain = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_probeBusy = true;
|
||||
try
|
||||
{
|
||||
do
|
||||
{
|
||||
_probeAgain = false;
|
||||
await Task.Yield();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var visible = _viewport?.Snapshot();
|
||||
var candidates = FolderDisplayRefresh.Pick(Items.Select(row => row.Item), visible);
|
||||
foreach (var item in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!FolderDisplayRefresh.HasIndexCounts(item) || !_probedFolders.Add(item.FullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var live = await Task.Run(() => _browse.CountVisibleChildren(item.FullPath), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!FolderDisplayRefresh.NeedsVerify(live, item.IndexedChildCount))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
|
||||
_indexing.EnqueueVerify(source.Id, rel);
|
||||
}
|
||||
}
|
||||
while (_probeAgain);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// navigated away
|
||||
}
|
||||
finally
|
||||
{
|
||||
_probeBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnViewModeChanged(FolderViewMode value)
|
||||
=> _thumbnails?.OnPreviewEnabledChanged(this);
|
||||
|
||||
@@ -275,11 +400,14 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
ShowIndexBanner = false;
|
||||
CurrentSource = null;
|
||||
Items.Clear();
|
||||
SelectedItems.Clear();
|
||||
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
|
||||
var listing = path switch
|
||||
{
|
||||
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
|
||||
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
|
||||
LocationRoots.Home => _browse.ListHome(),
|
||||
LocationRoots.Favorites => _browse.ListFavorites(),
|
||||
LocationRoots.RecycleBin => _browse.ListRecycleBin(),
|
||||
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
|
||||
};
|
||||
@@ -292,6 +420,21 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
|
||||
Items.AddRange(rows);
|
||||
ApplyCurrentSort();
|
||||
RefreshListingStatus();
|
||||
}
|
||||
|
||||
private void ApplyCurrentSource(Task<Source?> sourceTask)
|
||||
{
|
||||
if (!sourceTask.IsCompletedSuccessfully)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentSource = sourceTask.Result;
|
||||
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
|
||||
IndexBannerText = ShowIndexBanner
|
||||
? "Build an index for this location to enable instant search and folder sizes."
|
||||
: null;
|
||||
}
|
||||
|
||||
public Task RefreshGitAsync()
|
||||
@@ -376,6 +519,18 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
public Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
|
||||
=> segment is null ? Task.CompletedTask : NavigateAsync(segment.Path);
|
||||
|
||||
public void BeginEditPath()
|
||||
{
|
||||
PathEditText = CurrentPath;
|
||||
IsEditingPath = true;
|
||||
}
|
||||
|
||||
public void CancelEditPath()
|
||||
{
|
||||
IsEditingPath = false;
|
||||
PathEditText = CurrentPath;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public Task UpAsync()
|
||||
{
|
||||
@@ -398,6 +553,19 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
|
||||
|
||||
public async Task RefreshIndexOverlayAsync()
|
||||
{
|
||||
_skipReconcileOnComplete = true;
|
||||
try
|
||||
{
|
||||
await NavigateAsync(CurrentPath, addHistory: false).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_skipReconcileOnComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OpenItemAsync(FolderItemViewModel item)
|
||||
{
|
||||
if (item.Item.AvailableToImport)
|
||||
@@ -415,6 +583,19 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
}
|
||||
|
||||
_ops.Open([item.FullPath]);
|
||||
_ = ConfirmShareAccessAsync(item.FullPath);
|
||||
}
|
||||
|
||||
private async Task ConfirmShareAccessAsync(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sources.ConfirmAccessedAsync(path).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// share wake-up is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
public void BuildIndex()
|
||||
@@ -475,11 +656,11 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
return ordered.Select(item => byPath[item.FullPath]);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
||||
private IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
||||
{
|
||||
if (path == LocationRoots.ThisPc)
|
||||
if (path is LocationRoots.ThisPc or LocationRoots.Home or LocationRoots.Favorites)
|
||||
{
|
||||
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
|
||||
return [new BreadcrumbSegment(path, path, IsLast: true)];
|
||||
}
|
||||
|
||||
if (path is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin)
|
||||
@@ -491,6 +672,13 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
];
|
||||
}
|
||||
|
||||
var rooted = TryPinnedBreadcrumb(LocationRoots.Home, _browse.ListHome().Items, path)
|
||||
?? TryPinnedBreadcrumb(LocationRoots.Favorites, _browse.ListFavorites().Items, path);
|
||||
if (rooted is not null)
|
||||
{
|
||||
return rooted;
|
||||
}
|
||||
|
||||
var parts = new List<BreadcrumbSegment> { new(LocationRoots.ThisPc, LocationRoots.ThisPc) };
|
||||
var p = PathRules.FromExtended(path);
|
||||
if (PathRules.IsUnc(p))
|
||||
@@ -530,6 +718,58 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<BreadcrumbSegment>? TryPinnedBreadcrumb(
|
||||
string rootLabel,
|
||||
IEnumerable<FileSystemItem> folders,
|
||||
string path)
|
||||
{
|
||||
var current = PathRules.FromExtended(path).TrimEnd('\\');
|
||||
FileSystemItem? best = null;
|
||||
foreach (var folder in folders)
|
||||
{
|
||||
var root = PathRules.FromExtended(folder.FullPath).TrimEnd('\\');
|
||||
if (root.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
&& !current.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (best is null || root.Length > PathRules.FromExtended(best.FullPath).TrimEnd('\\').Length)
|
||||
{
|
||||
best = folder;
|
||||
}
|
||||
}
|
||||
|
||||
if (best is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = new List<BreadcrumbSegment>
|
||||
{
|
||||
new(rootLabel, rootLabel),
|
||||
new(string.IsNullOrEmpty(best.Name) ? best.FullPath : best.Name, best.FullPath)
|
||||
};
|
||||
var prefix = PathRules.FromExtended(best.FullPath).TrimEnd('\\');
|
||||
if (current.Length > prefix.Length)
|
||||
{
|
||||
var acc = prefix;
|
||||
foreach (var piece in current[(prefix.Length + 1)..].Split('\\', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
acc = PathRules.Combine(acc, piece);
|
||||
parts.Add(new BreadcrumbSegment(piece, acc));
|
||||
}
|
||||
}
|
||||
|
||||
parts[^1] = parts[^1] with { IsLast = true };
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BreadcrumbSegment(string Label, string Path, bool IsLast = false);
|
||||
|
||||
@@ -36,6 +36,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
private readonly IMediaConversionProvider _conversion;
|
||||
private readonly IThumbnailService? _thumbnails;
|
||||
private readonly IHostConnection? _host;
|
||||
private readonly IBackgroundMaintenance? _maintenance;
|
||||
private bool _hostStopped;
|
||||
private List<string> _clipboard = [];
|
||||
private bool _clipboardIsCut;
|
||||
@@ -62,6 +63,8 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _showOpenTerminal;
|
||||
[ObservableProperty] private bool _showOpenInCursor;
|
||||
[ObservableProperty] private bool _showGitActions;
|
||||
[ObservableProperty] private bool _showAddFavorite;
|
||||
[ObservableProperty] private bool _showRemoveFavorite;
|
||||
|
||||
private readonly IOsClipboard Clipboard;
|
||||
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||
@@ -92,8 +95,10 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
ConversionPlanner conversionPlanner,
|
||||
IFileSystemEnumerator enumerator,
|
||||
IMediaConversionProvider conversion,
|
||||
IKnownUserFolderCatalog? knownFolders = null,
|
||||
IThumbnailService? thumbnails = null,
|
||||
IHostConnection? hostConnection = null)
|
||||
IHostConnection? hostConnection = null,
|
||||
IBackgroundMaintenance? maintenance = null)
|
||||
{
|
||||
_browse = browse;
|
||||
_ops = ops;
|
||||
@@ -117,6 +122,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
_conversion = conversion;
|
||||
_thumbnails = thumbnails;
|
||||
_host = hostConnection;
|
||||
_maintenance = maintenance;
|
||||
if (_host is not null)
|
||||
{
|
||||
_host.StatusChanged += (_, status) =>
|
||||
@@ -132,14 +138,47 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (_maintenance is not null)
|
||||
{
|
||||
_maintenance.Changed += (_, snapshot) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshot.Message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
void Apply() => Footer = snapshot.Message;
|
||||
if (_ui is null)
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ui.Post(_ => Apply(), null);
|
||||
}
|
||||
};
|
||||
}
|
||||
var prefs = preferences.Load();
|
||||
Theme = prefs.Theme;
|
||||
PathHistory = [];
|
||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
|
||||
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences, knownFolders);
|
||||
Search = new SearchViewModel(search, sources, volumes);
|
||||
Analysis = new AnalysisViewModel(analysis);
|
||||
Duplicates = new DuplicateViewModel(mutations, sources, analysis);
|
||||
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
|
||||
_sources.PresenceChanged += (_, source) =>
|
||||
{
|
||||
void Apply() => _ = Tree.ApplySourceStateAsync(source.Id);
|
||||
if (_ui is { } ctx)
|
||||
{
|
||||
ctx.Post(_ => Apply(), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Apply();
|
||||
}
|
||||
};
|
||||
Transfers = new TransferQueueViewModel(workbench.Transfers, preferences);
|
||||
Tabs = [];
|
||||
Clipboard = clipboard;
|
||||
@@ -157,6 +196,21 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
};
|
||||
_indexing.ProgressChanged += (_, p) =>
|
||||
{
|
||||
if (p.IndexRefresh)
|
||||
{
|
||||
void Refresh() => _ = RefreshIndexOverlayAsync();
|
||||
if (_ui is { } refreshCtx)
|
||||
{
|
||||
refreshCtx.Post(_ => Refresh(), null);
|
||||
}
|
||||
else
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var text = p.Status == ScanJobStatus.Done
|
||||
? $"Indexed {p.FilesSeen:N0} files"
|
||||
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
|
||||
@@ -331,7 +385,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
public async Task GoAsync()
|
||||
{
|
||||
var path = PathText.Trim();
|
||||
var path = ActivePane.IsEditingPath ? ActivePane.PathEditText.Trim() : PathText.Trim();
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return;
|
||||
@@ -426,18 +480,47 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
public Task DeleteAsync() => DeleteSelectedAsync(permanent: false);
|
||||
|
||||
public Task DeleteSelectedAsync(bool permanent)
|
||||
public async Task DeleteSelectedAsync(bool permanent)
|
||||
{
|
||||
var paths = SelectedPaths()
|
||||
.Where(p => !LocationRoots.IsVirtual(p) && !PathRules.IsDriveRoot(p))
|
||||
.ToList();
|
||||
if (paths.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = new List<string>();
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var onDisk = Directory.Exists(path) || File.Exists(path);
|
||||
if (onDisk)
|
||||
{
|
||||
existing.Add(path);
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
EnqueueVerify(path);
|
||||
}
|
||||
|
||||
var parent = PathRules.Parent(path);
|
||||
if (!string.IsNullOrWhiteSpace(parent) && !LocationRoots.IsVirtual(parent))
|
||||
{
|
||||
EnqueueVerify(parent);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.Count == 0)
|
||||
{
|
||||
Footer = "Those items are no longer on disk.";
|
||||
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||
await Tree.RefreshAfterChangesAsync(paths.SelectMany(AffectedDirectories)).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Footer = permanent ? "Deleting permanently…" : "Moving to Recycle Bin…";
|
||||
return _ops.DeleteAsync(paths, permanent);
|
||||
await _ops.DeleteAsync(existing, permanent).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -749,9 +832,102 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
ShowOpenTerminal = target is not null;
|
||||
ShowOpenInCursor = target is not null;
|
||||
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
|
||||
var favoritePaths = FavoriteCandidates();
|
||||
var pinned = _preferences.Load().FavoriteFolders;
|
||||
ShowAddFavorite = favoritePaths.Any(p => !FavoriteFolders.Contains(pinned, p));
|
||||
ShowRemoveFavorite = FavoriteRemovalTargets().Count > 0;
|
||||
_ = RefreshForgetActionAsync();
|
||||
}
|
||||
|
||||
public async Task AddFavoritesAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var dirs = paths
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p) && Directory.Exists(p))
|
||||
.ToList();
|
||||
if (dirs.Count == 0)
|
||||
{
|
||||
Footer = "Select a folder to add to Favorites.";
|
||||
return;
|
||||
}
|
||||
|
||||
var stored = _preferences.Load();
|
||||
var next = FavoriteFolders.Add(stored.FavoriteFolders, dirs);
|
||||
if (next.Count == FavoriteFolders.Normalize(stored.FavoriteFolders).Count)
|
||||
{
|
||||
Footer = dirs.Count == 1 ? "Already in Favorites." : "Those folders are already in Favorites.";
|
||||
return;
|
||||
}
|
||||
|
||||
_preferences.Save(stored with { FavoriteFolders = next });
|
||||
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||
RefreshCloudActions();
|
||||
Footer = dirs.Count == 1 ? "Added to Favorites." : $"Added {dirs.Count} folders to Favorites.";
|
||||
}
|
||||
|
||||
public async Task RemoveFavoritesAsync(IEnumerable<string> paths)
|
||||
{
|
||||
var stored = _preferences.Load();
|
||||
var next = FavoriteFolders.Remove(stored.FavoriteFolders, paths);
|
||||
if (next.Count == FavoriteFolders.Normalize(stored.FavoriteFolders).Count)
|
||||
{
|
||||
Footer = "That folder is not in Favorites.";
|
||||
return;
|
||||
}
|
||||
|
||||
_preferences.Save(stored with { FavoriteFolders = next });
|
||||
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||
RefreshCloudActions();
|
||||
Footer = "Removed from Favorites.";
|
||||
}
|
||||
|
||||
public Task AddSelectedFavoritesAsync()
|
||||
=> AddFavoritesAsync(FavoriteCandidates());
|
||||
|
||||
public Task RemoveSelectedFavoritesAsync()
|
||||
=> RemoveFavoritesAsync(FavoriteRemovalTargets());
|
||||
|
||||
public bool CanPinFavorite(NavNodeViewModel node)
|
||||
=> !node.IsGroup
|
||||
&& !node.IsPlaceholder
|
||||
&& !node.AvailableToImport
|
||||
&& !LocationRoots.IsVirtual(node.Path)
|
||||
&& Directory.Exists(node.Path)
|
||||
&& !FavoriteFolders.Contains(_preferences.Load().FavoriteFolders, node.Path);
|
||||
|
||||
public bool CanUnpinFavorite(string? path)
|
||||
=> FavoriteFolders.Contains(_preferences.Load().FavoriteFolders, path);
|
||||
|
||||
private IReadOnlyList<string> FavoriteCandidates()
|
||||
{
|
||||
var selected = ActivePane.SelectedItems.Where(i => i.IsDirectory && IsRealFileSystemItem(i))
|
||||
.Select(i => i.FullPath)
|
||||
.ToList();
|
||||
if (selected.Count > 0)
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
|
||||
var current = ActivePane.CurrentPath;
|
||||
if (string.IsNullOrWhiteSpace(current) || LocationRoots.IsVirtual(current) || !Directory.Exists(current))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [current];
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> FavoriteRemovalTargets()
|
||||
{
|
||||
var pinned = _preferences.Load().FavoriteFolders;
|
||||
var selected = ActivePane.SelectedItems.Select(i => i.FullPath).Where(p => FavoriteFolders.Contains(pinned, p)).ToList();
|
||||
if (selected.Count > 0)
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
|
||||
return FavoriteFolders.Contains(pinned, ActivePane.CurrentPath) ? [ActivePane.CurrentPath] : [];
|
||||
}
|
||||
|
||||
public async Task RefreshForgetActionAsync()
|
||||
{
|
||||
ShowForgetSource = ActivePane.CurrentPath is LocationRoots.ThisPc or LocationRoots.Network
|
||||
@@ -1009,6 +1185,19 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
Analysis.Close();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RunMaintenanceNow()
|
||||
{
|
||||
if (_maintenance is null)
|
||||
{
|
||||
Footer = "Background host is not connected.";
|
||||
return;
|
||||
}
|
||||
|
||||
_maintenance.RunNow();
|
||||
Footer = "Background maintenance requested.";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RescanStorage()
|
||||
{
|
||||
@@ -1298,7 +1487,7 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
EnqueueReconcile(dir);
|
||||
EnqueueVerify(dir);
|
||||
}
|
||||
|
||||
if (job.Status == TransferStatus.Failed)
|
||||
@@ -1326,6 +1515,16 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
await Tree.RefreshAfterChangesAsync(dirs).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task RefreshIndexOverlayAsync()
|
||||
{
|
||||
await ActivePane.RefreshIndexOverlayAsync().ConfigureAwait(true);
|
||||
if (ActiveTab.IsSplit)
|
||||
{
|
||||
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
||||
await other.RefreshIndexOverlayAsync().ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshFolderViewsAsync(string directory)
|
||||
{
|
||||
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||
@@ -1355,12 +1554,16 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
private void EnqueueReconcile(string path)
|
||||
private void EnqueueReconcile(string path) => EnqueueIndexUpdate(path, verify: false);
|
||||
|
||||
private void EnqueueVerify(string path) => EnqueueIndexUpdate(path, verify: true);
|
||||
|
||||
private void EnqueueIndexUpdate(string path, bool verify)
|
||||
{
|
||||
_ = ReconcileAsync(path);
|
||||
_ = ReconcileAsync(path, verify);
|
||||
}
|
||||
|
||||
private async Task ReconcileAsync(string path)
|
||||
private async Task ReconcileAsync(string path, bool verify = true)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1371,8 +1574,15 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
|
||||
var rel = PathRules.MakeRelative(source.LastRootPath, path);
|
||||
if (verify)
|
||||
{
|
||||
_indexing.EnqueueVerify(source.Id, rel);
|
||||
}
|
||||
else
|
||||
{
|
||||
_indexing.EnqueueReconcile(source.Id, rel);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// local errors stay out of the UI
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
|
||||
public string Glyph { get; init; } = "\uE8B7";
|
||||
public bool IsPlaceholder { get; init; }
|
||||
public bool IsGroup { get; init; }
|
||||
public bool IsFavorite { get; init; }
|
||||
public bool AvailableToImport { get; init; }
|
||||
public long? SourceId { get; init; }
|
||||
public bool CanRemove { get; init; }
|
||||
@@ -33,19 +34,22 @@ public sealed class NavigationTreeViewModel
|
||||
private readonly ICloudOverlay _providers;
|
||||
private readonly CloudPlaceStore _cloudPlaces;
|
||||
private readonly UiPreferencesStore _preferences;
|
||||
private readonly IKnownUserFolderCatalog? _knownFolders;
|
||||
|
||||
public NavigationTreeViewModel(
|
||||
SourceManager sources,
|
||||
BrowseService browse,
|
||||
ICloudOverlay providers,
|
||||
CloudPlaceStore cloudPlaces,
|
||||
UiPreferencesStore preferences)
|
||||
UiPreferencesStore preferences,
|
||||
IKnownUserFolderCatalog? knownFolders = null)
|
||||
{
|
||||
_sources = sources;
|
||||
_browse = browse;
|
||||
_providers = providers;
|
||||
_cloudPlaces = cloudPlaces;
|
||||
_preferences = preferences;
|
||||
_knownFolders = knownFolders;
|
||||
Roots = [];
|
||||
}
|
||||
|
||||
@@ -60,6 +64,8 @@ public sealed class NavigationTreeViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
Roots.Add(CreateGroup(LocationRoots.Favorites, "\uE735"));
|
||||
Roots.Add(CreateGroup(LocationRoots.Home, "\uE80F"));
|
||||
Roots.Add(new NavNodeViewModel
|
||||
{
|
||||
Label = LocationRoots.ThisPc,
|
||||
@@ -75,14 +81,20 @@ public sealed class NavigationTreeViewModel
|
||||
{
|
||||
var expanded = new List<string>();
|
||||
CollectExpanded(Roots, expanded);
|
||||
var selected = FindSelected(Roots)?.Path;
|
||||
var restore = revealPath ?? selected;
|
||||
var selected = FindSelected(Roots);
|
||||
var restore = revealPath ?? selected?.Path;
|
||||
var previousFavorites = Roots.FirstOrDefault(r => r.Path == LocationRoots.Favorites);
|
||||
var wasInFavorites = selected is not null
|
||||
&& previousFavorites is not null
|
||||
&& ContainsNode(previousFavorites, selected);
|
||||
|
||||
IsRevealing = true;
|
||||
try
|
||||
{
|
||||
Roots.Clear();
|
||||
var prefs = _preferences.Load();
|
||||
Roots.Add(CreateFavoritesRoot(prefs));
|
||||
Roots.Add(CreateHomeRoot());
|
||||
var thisPc = new NavNodeViewModel
|
||||
{
|
||||
Label = LocationRoots.ThisPc,
|
||||
@@ -198,7 +210,7 @@ public sealed class NavigationTreeViewModel
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(restore))
|
||||
{
|
||||
await RevealPathAsync(restore).ConfigureAwait(true);
|
||||
await RevealPathAsync(restore, wasInFavorites).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -302,7 +314,7 @@ public sealed class NavigationTreeViewModel
|
||||
node.ChildrenLoaded = true;
|
||||
}
|
||||
|
||||
public async Task RevealPathAsync(string path)
|
||||
public async Task RevealPathAsync(string path, bool? currentlyInFavorites = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
|
||||
{
|
||||
@@ -324,7 +336,7 @@ public sealed class NavigationTreeViewModel
|
||||
return;
|
||||
}
|
||||
|
||||
var current = FindBestRoot(Roots, path);
|
||||
var current = FindBestRoot(path, currentlyInFavorites);
|
||||
if (current is null)
|
||||
{
|
||||
return;
|
||||
@@ -470,47 +482,139 @@ public sealed class NavigationTreeViewModel
|
||||
_ => "\uE753"
|
||||
};
|
||||
|
||||
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
|
||||
private NavNodeViewModel CreateHomeRoot()
|
||||
{
|
||||
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
|
||||
NavNodeViewModel? best = null;
|
||||
var bestLength = -1;
|
||||
|
||||
void Consider(NavNodeViewModel node)
|
||||
var home = CreateGroup(LocationRoots.Home, "\uE80F");
|
||||
foreach (var folder in _knownFolders?.ListExisting() ?? [])
|
||||
{
|
||||
if (node.IsPlaceholder || node.IsGroup || LocationRoots.IsVirtual(node.Path))
|
||||
var node = new NavNodeViewModel
|
||||
{
|
||||
return;
|
||||
Label = folder.Name,
|
||||
Path = folder.Path,
|
||||
Glyph = folder.Glyph
|
||||
};
|
||||
AddPlaceholder(node);
|
||||
home.Children.Add(node);
|
||||
}
|
||||
|
||||
var root = PathRules.FromExtended(node.Path).TrimEnd('\\');
|
||||
if (normalized.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| normalized.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (root.Length > bestLength)
|
||||
{
|
||||
best = node;
|
||||
bestLength = root.Length;
|
||||
}
|
||||
}
|
||||
return home;
|
||||
}
|
||||
|
||||
private NavNodeViewModel CreateFavoritesRoot(UiPreferences prefs)
|
||||
{
|
||||
var root = CreateGroup(LocationRoots.Favorites, "\uE735");
|
||||
foreach (var path in FavoriteFolders.Normalize(prefs.FavoriteFolders))
|
||||
{
|
||||
var exists = Directory.Exists(path);
|
||||
var node = new NavNodeViewModel
|
||||
{
|
||||
Label = FavoriteLabel(path),
|
||||
Path = path,
|
||||
Glyph = "\uE735",
|
||||
Status = exists ? "" : "Offline",
|
||||
IsOffline = !exists,
|
||||
IsFavorite = true
|
||||
};
|
||||
if (exists)
|
||||
{
|
||||
AddPlaceholder(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
node.ChildrenLoaded = true;
|
||||
}
|
||||
|
||||
root.Children.Add(node);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static NavNodeViewModel CreateGroup(string name, string glyph)
|
||||
=> new()
|
||||
{
|
||||
Label = name,
|
||||
Path = name,
|
||||
Glyph = glyph,
|
||||
IsExpanded = true,
|
||||
ChildrenLoaded = true,
|
||||
IsGroup = true
|
||||
};
|
||||
|
||||
private static string FavoriteLabel(string path)
|
||||
{
|
||||
var name = PathRules.GetFileName(path.TrimEnd('\\'));
|
||||
return string.IsNullOrEmpty(name) ? path : name;
|
||||
}
|
||||
|
||||
private NavNodeViewModel? FindBestRoot(string path, bool? currentlyInFavorites = null)
|
||||
{
|
||||
var nodes = EnumerateRevealRoots(Roots).ToList();
|
||||
var prefs = _preferences.Load();
|
||||
var inFavorites = currentlyInFavorites ?? IsSelectionInFavorites();
|
||||
var candidates = nodes
|
||||
.Select(n => new TreeRevealCandidate(n.Path, n.IsFavorite))
|
||||
.ToList();
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
candidates,
|
||||
path,
|
||||
prefs.PreferFavoritesInTree,
|
||||
inFavorites);
|
||||
if (chosen is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodes.FirstOrDefault(n =>
|
||||
n.IsFavorite == chosen.Value.IsFavorite && PathsEqual(n.Path, chosen.Value.Path));
|
||||
}
|
||||
|
||||
private bool IsSelectionInFavorites()
|
||||
{
|
||||
var selected = FindSelected(Roots);
|
||||
var favorites = Roots.FirstOrDefault(r => r.Path == LocationRoots.Favorites);
|
||||
return selected is not null && favorites is not null && ContainsNode(favorites, selected);
|
||||
}
|
||||
|
||||
private static IEnumerable<NavNodeViewModel> EnumerateRevealRoots(IEnumerable<NavNodeViewModel> roots)
|
||||
{
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (root.IsPlaceholder)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (root.IsGroup || LocationRoots.IsVirtual(root.Path))
|
||||
{
|
||||
foreach (var child in root.Children.Where(c => !c.IsPlaceholder))
|
||||
{
|
||||
Consider(child);
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Consider(root);
|
||||
yield return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
private static bool ContainsNode(NavNodeViewModel root, NavNodeViewModel target)
|
||||
{
|
||||
if (ReferenceEquals(root, target))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var child in root.Children)
|
||||
{
|
||||
if (ContainsNode(child, target))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void AddPlaceholder(NavNodeViewModel node)
|
||||
|
||||
@@ -225,6 +225,14 @@ internal sealed class HashStore : IHashStore
|
||||
return conn.ExecuteAsync(sql, new { sourceId });
|
||||
}, cancellationToken);
|
||||
|
||||
public async Task<bool> HasPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
var count = await conn.ExecuteScalarAsync<long>(
|
||||
"SELECT COUNT(*) FROM hash_queue WHERE state IN ('Pending','PartialDone')").ConfigureAwait(false);
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -198,4 +198,30 @@ internal static partial class NativeMethods
|
||||
var n = Array.IndexOf(buffer, '\0');
|
||||
return n < 0 ? new string(buffer) : new string(buffer, 0, n);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct LastInputInfo
|
||||
{
|
||||
public uint CbSize;
|
||||
public uint DwTime;
|
||||
}
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetLastInputInfo(ref LastInputInfo plii);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct SystemPowerStatus
|
||||
{
|
||||
public byte ACLineStatus;
|
||||
public byte BatteryFlag;
|
||||
public byte BatteryLifePercent;
|
||||
public byte SystemStatusFlag;
|
||||
public int BatteryLifeTime;
|
||||
public int BatteryFullLifeTime;
|
||||
}
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
public static partial bool GetSystemPowerStatus(out SystemPowerStatus lpSystemPowerStatus);
|
||||
}
|
||||
|
||||
34
src/Explorer.Windows/WindowsIdleAndPower.cs
Normal file
34
src/Explorer.Windows/WindowsIdleAndPower.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Windows;
|
||||
|
||||
public sealed class WindowsUserIdleMonitor : IUserIdleMonitor
|
||||
{
|
||||
public TimeSpan GetIdleDuration()
|
||||
{
|
||||
var info = new NativeMethods.LastInputInfo { CbSize = (uint)System.Runtime.InteropServices.Marshal.SizeOf<NativeMethods.LastInputInfo>() };
|
||||
if (!NativeMethods.GetLastInputInfo(ref info))
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var idleMs = unchecked((uint)Environment.TickCount) - info.DwTime;
|
||||
return TimeSpan.FromMilliseconds(idleMs);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WindowsPowerSourceMonitor : IPowerSourceMonitor
|
||||
{
|
||||
public bool IsOnAcPower
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!NativeMethods.GetSystemPowerStatus(out var status))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return status.ACLineStatus != 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/Explorer.Windows/WindowsKnownUserFolderCatalog.cs
Normal file
58
src/Explorer.Windows/WindowsKnownUserFolderCatalog.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Windows;
|
||||
|
||||
public sealed class WindowsKnownUserFolderCatalog : IKnownUserFolderCatalog
|
||||
{
|
||||
private static readonly (Guid Id, string Name, string Glyph)[] Folders =
|
||||
[
|
||||
(new("FDD39AD0-238F-46AF-ADB4-6C85480369C7"), "Documents", "\uE8A5"),
|
||||
(new("374DE290-123F-4565-9164-39C4925E467B"), "Downloads", "\uE896"),
|
||||
(new("33E28130-4E1E-4676-835A-98395C3BC3BB"), "Pictures", "\uEB9F"),
|
||||
(new("18989B1D-99B5-455B-841C-AB7C74E4DDFC"), "Videos", "\uE8B2"),
|
||||
(new("4BD8D571-6D19-48D3-BE97-422220080E43"), "Music", "\uE8D6")
|
||||
];
|
||||
|
||||
public IReadOnlyList<KnownUserFolder> ListExisting()
|
||||
{
|
||||
var result = new List<KnownUserFolder>(Folders.Length);
|
||||
foreach (var (id, name, glyph) in Folders)
|
||||
{
|
||||
var path = TryGetPath(id);
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new KnownUserFolder(name, path, glyph));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string? TryGetPath(Guid folderId)
|
||||
{
|
||||
var hr = SHGetKnownFolderPath(folderId, 0, IntPtr.Zero, out var ptr);
|
||||
if (hr != 0 || ptr == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Marshal.PtrToStringUni(ptr);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(ptr);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll")]
|
||||
private static extern int SHGetKnownFolderPath(
|
||||
[MarshalAs(UnmanagedType.LPStruct)] Guid rfid,
|
||||
uint dwFlags,
|
||||
IntPtr hToken,
|
||||
out IntPtr pszPath);
|
||||
}
|
||||
@@ -128,6 +128,7 @@ public class AnalysisTests
|
||||
store,
|
||||
new HydrationGuard(new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance)),
|
||||
NullLogger<DuplicateHashWorker>.Instance);
|
||||
worker.Resume();
|
||||
await worker.ProcessPendingAsync(CancellationToken.None);
|
||||
|
||||
var skipped = await store.Entries.GetByPathAsync(source.Id, "online-only.bin");
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BackgroundMaintenancePlannerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Skips_network_cloud_and_removable()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("net", SourceKind.Smb, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("cloud", SourceKind.Cloud, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("usb", SourceKind.Removable, SourceStatus.Stale, utc.AddDays(-30)),
|
||||
Source("local", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-30))
|
||||
], utc);
|
||||
Assert.NotNull(chosen);
|
||||
Assert.Equal("local", chosen.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefers_stale_over_old_but_online()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("old", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddDays(-40)),
|
||||
Source("stale", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-2))
|
||||
], utc);
|
||||
Assert.Equal("stale", chosen!.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_online_local_is_not_full_rescanned()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan(
|
||||
[
|
||||
Source("fresh", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddHours(-2))
|
||||
], utc);
|
||||
Assert.Null(chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fresh_online_local_is_verified_when_idle()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalVerify(
|
||||
[
|
||||
Source("fresh", SourceKind.NtfsLocal, SourceStatus.Online, utc.AddHours(-2))
|
||||
]);
|
||||
Assert.NotNull(chosen);
|
||||
Assert.Equal("fresh", chosen.StableKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Does_not_pick_a_source_already_queued()
|
||||
{
|
||||
var utc = DateTimeOffset.Parse("2026-08-26T00:00:00Z");
|
||||
var source = Source("local", SourceKind.NtfsLocal, SourceStatus.Stale, utc.AddDays(-30));
|
||||
source.Id = 7;
|
||||
var chosen = BackgroundMaintenancePlanner.NextLocalScan([source], utc, alreadyQueued: new HashSet<long> { 7 });
|
||||
Assert.Null(chosen);
|
||||
}
|
||||
|
||||
private static Source Source(string key, SourceKind kind, SourceStatus status, DateTimeOffset indexed)
|
||||
=> new()
|
||||
{
|
||||
StableKey = key,
|
||||
DisplayName = key,
|
||||
Kind = kind,
|
||||
Status = status,
|
||||
LastRootPath = @"D:\",
|
||||
LastIndexedUtc = indexed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class BackgroundWorkPolicyTests
|
||||
{
|
||||
private static BackgroundWorkInputs Idle(TimeSpan? idle = null, bool enabled = true, bool ac = true,
|
||||
bool acOnly = true, bool foreground = false, bool runNow = false)
|
||||
=> new(
|
||||
enabled,
|
||||
TimeSpan.FromMinutes(10),
|
||||
idle ?? TimeSpan.FromMinutes(12),
|
||||
acOnly,
|
||||
ac,
|
||||
foreground,
|
||||
runNow);
|
||||
|
||||
[Fact]
|
||||
public void Below_threshold_stays_active()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.FromMinutes(3)));
|
||||
Assert.Equal(UserActivityState.Active, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.BelowIdleThreshold, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Idle_past_threshold_allows_maintenance()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle());
|
||||
Assert.Equal(UserActivityState.Idle, decision.Activity);
|
||||
Assert.True(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.None, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activity_resumes_and_blocks_maintenance()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero));
|
||||
Assert.Equal(UserActivityState.Active, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.UserActive, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foreground_jobs_block_even_when_idle()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(foreground: true));
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.ForegroundOperations, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_policy_never_allows()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(enabled: false));
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.Disabled, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ac_only_skips_on_battery()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(ac: false, acOnly: true));
|
||||
Assert.Equal(UserActivityState.Idle, decision.Activity);
|
||||
Assert.False(decision.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.OnBattery, decision.SkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ac_only_allows_on_ac()
|
||||
{
|
||||
var decision = BackgroundWorkPolicy.Evaluate(Idle(ac: true, acOnly: true));
|
||||
Assert.True(decision.MaintenanceAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Run_now_bypasses_idle_and_battery_but_not_foreground()
|
||||
{
|
||||
var allowed = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero, ac: false, runNow: true));
|
||||
Assert.True(allowed.MaintenanceAllowed);
|
||||
|
||||
var blocked = BackgroundWorkPolicy.Evaluate(Idle(TimeSpan.Zero, foreground: true, runNow: true));
|
||||
Assert.False(blocked.MaintenanceAllowed);
|
||||
Assert.Equal(MaintenanceSkipReason.ForegroundOperations, blocked.SkipReason);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ public class BrowseHydrationTests
|
||||
Assert.Equal(BrowseHydration.ConstrainedProviderBatch, BrowseHydration.ProviderBatchSize(SourceKind.NtfsLocal, true));
|
||||
Assert.Equal(BrowseHydration.ConstrainedWorkers, BrowseHydration.WorkerCount(SourceKind.Smb, false));
|
||||
Assert.Equal(BrowseHydration.LocalWorkers, BrowseHydration.WorkerCount(SourceKind.NtfsLocal, false));
|
||||
Assert.Equal(1, BrowseHydration.FirstPublish);
|
||||
Assert.True(BrowseHydration.FirstPublish < BrowseHydration.PublishBatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -88,6 +90,102 @@ public class BrowseHydrationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_publishes_first_names_before_enumeration_finishes()
|
||||
{
|
||||
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var (browse, store, _) = await CreateAsync(enumerator: new GatedEnumerator(gate));
|
||||
try
|
||||
{
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
var first = it.MoveNextAsync().AsTask();
|
||||
var winner = await Task.WhenAny(first, Task.Delay(TimeSpan.FromSeconds(2))).ConfigureAwait(true);
|
||||
Assert.Same(first, winner);
|
||||
Assert.True(await first);
|
||||
Assert.Contains(it.Current.Added, i => i.Name == "first");
|
||||
Assert.False(it.Current.EnumerationComplete);
|
||||
Assert.False(gate.Task.IsCompleted);
|
||||
|
||||
gate.SetResult();
|
||||
var sawSecond = it.Current.Added.Any(i => i.Name == "second");
|
||||
var complete = it.Current.EnumerationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
sawSecond |= it.Current.Added.Any(i => i.Name == "second");
|
||||
complete = it.Current.EnumerationComplete;
|
||||
}
|
||||
|
||||
Assert.True(sawSecond);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_applies_index_sizes_after_names()
|
||||
{
|
||||
var (browse, store, _) = await CreateAsync();
|
||||
try
|
||||
{
|
||||
var source = (await store.Sources.GetAllAsync()).Single();
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow;
|
||||
await store.Sources.UpsertAsync(source);
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "C:",
|
||||
NameNorm = "c:",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "Movies",
|
||||
NameNorm = "movies",
|
||||
IsDirectory = true,
|
||||
PathRel = "Movies",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow,
|
||||
AggregateSize = 200
|
||||
});
|
||||
|
||||
await using var it = browse.ListProgressiveAsync(@"C:\").GetAsyncEnumerator();
|
||||
Assert.True(await it.MoveNextAsync());
|
||||
Assert.NotEmpty(it.Current.Added);
|
||||
Assert.All(it.Current.Added, item => Assert.Equal(0, (int)(item.Hydration & ItemHydrationFlags.Index)));
|
||||
var movies = it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
||||
if (movies is not null)
|
||||
{
|
||||
Assert.Equal(0, movies.SizeBytes);
|
||||
}
|
||||
|
||||
long? size = movies?.SizeBytes;
|
||||
var complete = it.Current.HydrationComplete;
|
||||
while (!complete && await it.MoveNextAsync())
|
||||
{
|
||||
movies ??= it.Current.Added.FirstOrDefault(i => i.Name == "Movies");
|
||||
var updated = it.Current.Updated.FirstOrDefault(i => i.Name == "Movies");
|
||||
if (updated is not null)
|
||||
{
|
||||
size = updated.SizeBytes;
|
||||
}
|
||||
|
||||
complete = it.Current.HydrationComplete;
|
||||
}
|
||||
|
||||
Assert.Equal(200, size);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Progressive_listing_cancels_without_applying_stale_provider_results()
|
||||
{
|
||||
@@ -235,6 +333,46 @@ public class BrowseHydrationTests
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GatedEnumerator(TaskCompletionSource gate) : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
=> EnumerateChildrenSafe(directoryPath, out _);
|
||||
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> throw new InvalidOperationException("GetItem must not run during folder listing.");
|
||||
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return
|
||||
[
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "first"), Name = "first", IsDirectory = true },
|
||||
new FileSystemItem { FullPath = Path.Combine(directoryPath, "second"), Name = "second", IsDirectory = true }
|
||||
];
|
||||
}
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildrenStreaming(
|
||||
string directoryPath,
|
||||
FileEnumerationSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
yield return new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "first"),
|
||||
Name = "first",
|
||||
IsDirectory = true
|
||||
};
|
||||
gate.Task.GetAwaiter().GetResult();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return new FileSystemItem
|
||||
{
|
||||
FullPath = Path.Combine(directoryPath, "second"),
|
||||
Name = "second",
|
||||
IsDirectory = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class GuardedEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public int GetItemCalls { get; private set; }
|
||||
|
||||
@@ -37,6 +37,47 @@ public class BrowseServiceTests
|
||||
Assert.Contains(listing.Items, i => i.FullPath == LocationRoots.RecycleBin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Home_lists_existing_known_folders()
|
||||
{
|
||||
var (browse, store) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
knownFolders: new StubKnownFolders(
|
||||
[
|
||||
new KnownUserFolder("Documents", @"C:\Users\Test\Documents", "d"),
|
||||
new KnownUserFolder("Downloads", @"C:\Users\Test\Downloads", "l")
|
||||
]));
|
||||
var listing = browse.ListHome();
|
||||
Assert.Equal(LocationRoots.Home, listing.Path);
|
||||
Assert.Equal(2, listing.Items.Count);
|
||||
Assert.Equal("Documents", listing.Items[0].DisplayName);
|
||||
Assert.Equal(@"C:\Users\Test\Documents", listing.Items[0].FullPath);
|
||||
Assert.True(listing.Items[0].IsDirectory);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Favorites_lists_pinned_folders_including_offline()
|
||||
{
|
||||
var missing = Path.Combine(Path.GetTempPath(), "ew-fav-missing", Guid.NewGuid().ToString("N"));
|
||||
var (browse, store) = await CreateAsync(
|
||||
@"C:\",
|
||||
"C:",
|
||||
configurePrefs: prefs => prefs.Save(UiPreferences.Default with
|
||||
{
|
||||
FavoriteFolders = [@"D:\Photos", missing]
|
||||
}));
|
||||
var listing = browse.ListFavorites();
|
||||
Assert.Equal(LocationRoots.Favorites, listing.Path);
|
||||
Assert.Equal(2, listing.Items.Count);
|
||||
Assert.Equal(@"D:\Photos", listing.Items[0].FullPath);
|
||||
Assert.Equal("Photos", listing.Items[0].Name);
|
||||
Assert.Equal(missing, listing.Items[1].FullPath);
|
||||
Assert.Contains("(Offline)", listing.Items[1].DisplayName);
|
||||
await store.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task This_pc_lists_untracked_network_drives_as_importable()
|
||||
{
|
||||
@@ -211,7 +252,8 @@ public class BrowseServiceTests
|
||||
string display,
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IFileSystemEnumerator? enumerator = null,
|
||||
Action<UiPreferencesStore>? configurePrefs = null)
|
||||
Action<UiPreferencesStore>? configurePrefs = null,
|
||||
IKnownUserFolderCatalog? knownFolders = null)
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-browse", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
@@ -244,11 +286,17 @@ public class BrowseServiceTests
|
||||
new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance),
|
||||
new CloudPlaceStore(env),
|
||||
prefs,
|
||||
recycle: recycle);
|
||||
recycle: recycle,
|
||||
knownFolders: knownFolders);
|
||||
return (browse, store);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class StubKnownFolders(IReadOnlyList<KnownUserFolder> folders) : IKnownUserFolderCatalog
|
||||
{
|
||||
public IReadOnlyList<KnownUserFolder> ListExisting() => folders;
|
||||
}
|
||||
|
||||
file sealed class BrowseEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath)
|
||||
|
||||
49
tests/Explorer.Application.Tests/FavoriteFoldersTests.cs
Normal file
49
tests/Explorer.Application.Tests/FavoriteFoldersTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FavoriteFoldersTests
|
||||
{
|
||||
[Fact]
|
||||
public void Normalize_dedupes_and_skips_virtual_roots()
|
||||
{
|
||||
var normalized = FavoriteFolders.Normalize(
|
||||
[
|
||||
@"D:\Photos\",
|
||||
@"d:\photos",
|
||||
LocationRoots.ThisPc,
|
||||
LocationRoots.Favorites,
|
||||
@"C:\Users\Dominique\Documents"
|
||||
]);
|
||||
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], normalized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contains_is_case_insensitive()
|
||||
{
|
||||
var pinned = FavoriteFolders.Normalize([@"D:\Photos"]);
|
||||
Assert.True(FavoriteFolders.Contains(pinned, @"d:\photos\"));
|
||||
Assert.False(FavoriteFolders.Contains(pinned, @"D:\Other"));
|
||||
Assert.False(FavoriteFolders.Contains(pinned, LocationRoots.Home));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_appends_new_folders_up_to_max()
|
||||
{
|
||||
var current = Enumerable.Range(0, FavoriteFolders.MaxCount - 1).Select(i => $@"D:\F{i}");
|
||||
var next = FavoriteFolders.Add(current, [@"D:\New", @"D:\F0", @"E:\TooMany"]);
|
||||
Assert.Equal(FavoriteFolders.MaxCount, next.Count);
|
||||
Assert.Equal(@"D:\New", next[^1]);
|
||||
Assert.DoesNotContain(@"E:\TooMany", next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remove_unpins_without_touching_other_entries()
|
||||
{
|
||||
var next = FavoriteFolders.Remove(
|
||||
[@"D:\Photos", @"D:\Music"],
|
||||
[@"d:\photos", LocationRoots.ThisPc]);
|
||||
Assert.Equal([@"D:\Music"], next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderDisplayRefreshTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pick_prefers_visible_then_largest()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
Dir(@"C:\a", "a", 50_000_000),
|
||||
Dir(@"C:\b", "b", 90_000_000),
|
||||
Dir(@"C:\c", "c", 5_000),
|
||||
Dir(@"C:\d", "d", 20_000_000)
|
||||
};
|
||||
var picked = FolderDisplayRefresh.Pick(items, [@"C:\d", @"C:\a"]);
|
||||
Assert.Equal(3, picked.Count);
|
||||
Assert.Equal(@"C:\a", picked[0].FullPath);
|
||||
Assert.Equal(@"C:\d", picked[1].FullPath);
|
||||
Assert.Equal(@"C:\b", picked[2].FullPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pick_caps_and_skips_tiny_or_cloud()
|
||||
{
|
||||
var items = Enumerable.Range(0, 12)
|
||||
.Select(i => Dir($@"C:\f{i}", $"f{i}", (13 - i) * 2_000_000L))
|
||||
.Append(Dir(@"C:\tiny", "tiny", 100))
|
||||
.Append(Dir(@"C:\cloud", "cloud", 80_000_000, hydrate: true))
|
||||
.ToList();
|
||||
var picked = FolderDisplayRefresh.Pick(items);
|
||||
Assert.Equal(FolderDisplayRefresh.MaxProbes, picked.Count);
|
||||
Assert.Equal(@"C:\f0", picked[0].FullPath);
|
||||
Assert.DoesNotContain(picked, item => item.Name is "tiny" or "cloud");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeedsVerify_compares_child_counts()
|
||||
{
|
||||
Assert.True(FolderDisplayRefresh.NeedsVerify(10, 2000));
|
||||
Assert.False(FolderDisplayRefresh.NeedsVerify(10, 10));
|
||||
Assert.False(FolderDisplayRefresh.NeedsVerify(-1, 2000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasIndexCounts_requires_index_hydration()
|
||||
{
|
||||
var live = Dir(@"C:\a", "a", 50_000_000, hydration: ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata);
|
||||
Assert.False(FolderDisplayRefresh.HasIndexCounts(live));
|
||||
Assert.True(FolderDisplayRefresh.HasIndexCounts(live.Overlay(hydration: ItemHydrationFlags.Index)));
|
||||
}
|
||||
|
||||
private static FileSystemItem Dir(string path, string name, long size, bool hydrate = false, ItemHydrationFlags? hydration = null)
|
||||
=> new()
|
||||
{
|
||||
FullPath = path,
|
||||
Name = name,
|
||||
IsDirectory = true,
|
||||
SizeBytes = size,
|
||||
IndexedChildCount = 4,
|
||||
Hydration = hydration ?? ItemHydrationFlags.All,
|
||||
Cloud = hydrate
|
||||
? new CloudPresence("x", CloudAvailability.OnlineOnly, size, null, true)
|
||||
: null
|
||||
};
|
||||
}
|
||||
85
tests/Explorer.Application.Tests/FolderStatusTextTests.cs
Normal file
85
tests/Explorer.Application.Tests/FolderStatusTextTests.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FolderStatusTextTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_folder_is_zero_items()
|
||||
{
|
||||
Assert.Equal("0 items", FolderStatusText.Format([], []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Folder_totals_use_known_sizes()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
File("a.txt", 100),
|
||||
File("b.txt", 924),
|
||||
Folder("pics", size: 0, SizeKnowledge.Unknown)
|
||||
};
|
||||
|
||||
Assert.Equal("3 items · 1 KB", FolderStatusText.Format(items, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Selection_replaces_folder_totals()
|
||||
{
|
||||
var items = new[] { File("a.txt", 100), File("b.txt", 200), File("c.txt", 300) };
|
||||
|
||||
Assert.Equal("1 item selected · 200 B", FolderStatusText.Format(items, [items[1]]));
|
||||
Assert.Equal("2 items selected · 300 B", FolderStatusText.Format(items, [items[0], items[1]]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_folder_sizes_are_omitted()
|
||||
{
|
||||
var folders = new[]
|
||||
{
|
||||
Folder("a", 0, SizeKnowledge.Unknown),
|
||||
Folder("b", 0, SizeKnowledge.Unknown)
|
||||
};
|
||||
|
||||
Assert.Equal("2 items", FolderStatusText.Format(folders, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Indexed_folder_sizes_are_included()
|
||||
{
|
||||
var items = new[]
|
||||
{
|
||||
Folder("docs", 2_048, SizeKnowledge.Calculated),
|
||||
File("readme.txt", 1_024)
|
||||
};
|
||||
|
||||
Assert.Equal("2 items · 3 KB", FolderStatusText.Format(items, []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_byte_file_selection_still_shows_size()
|
||||
{
|
||||
var file = File("empty.dat", 0);
|
||||
Assert.Equal("1 item selected · 0 B", FolderStatusText.Format([file], [file]));
|
||||
}
|
||||
|
||||
private static FileSystemItem File(string name, long size)
|
||||
=> new()
|
||||
{
|
||||
FullPath = @"C:\" + name,
|
||||
Name = name,
|
||||
SizeBytes = size,
|
||||
SizeKnowledge = SizeKnowledge.Calculated
|
||||
};
|
||||
|
||||
private static FileSystemItem Folder(string name, long size, SizeKnowledge knowledge)
|
||||
=> new()
|
||||
{
|
||||
FullPath = @"C:\" + name,
|
||||
Name = name,
|
||||
IsDirectory = true,
|
||||
SizeBytes = size,
|
||||
SizeKnowledge = knowledge
|
||||
};
|
||||
}
|
||||
29
tests/Explorer.Application.Tests/MarqueeRangeTests.cs
Normal file
29
tests/Explorer.Application.Tests/MarqueeRangeTests.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class MarqueeRangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stack_selects_rows_the_band_touches()
|
||||
{
|
||||
Assert.Equal([0, 1, 2], MarqueeRange.Stack(10, 70, 10, 24));
|
||||
Assert.Equal([1], MarqueeRange.Stack(24, 30, 10, 24));
|
||||
Assert.Equal([0], MarqueeRange.Stack(10, 10.2, 10, 24));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrap_selects_tiles_the_band_covers()
|
||||
{
|
||||
// 3 columns, 40x40 cells. Band over the first two tiles of row 0 and the first of row 1.
|
||||
var hits = MarqueeRange.Wrap(5, 5, 55, 45, count: 8, columns: 3, itemWidth: 40, itemHeight: 40);
|
||||
Assert.Equal([0, 1, 3, 4], hits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrap_ignores_cells_past_the_item_count()
|
||||
{
|
||||
var hits = MarqueeRange.Wrap(0, 0, 120, 40, count: 2, columns: 3, itemWidth: 40, itemHeight: 40);
|
||||
Assert.Equal([0, 1], hits);
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,43 @@ public class SourceManagerTests
|
||||
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
|
||||
Assert.Equal(afterInit + 1, volumes.EnumerateCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reachable_unc_share_not_listed_as_a_drive_becomes_online()
|
||||
{
|
||||
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();
|
||||
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 mgr.AddUncAsync(@"\\nas\media");
|
||||
Assert.Equal(SourceStatus.Offline, source.Status);
|
||||
|
||||
volumes.ReachablePaths.Add(@"\\nas\media");
|
||||
await mgr.RefreshOnlineStateAsync(forceRefresh: true);
|
||||
var again = Assert.Single(await store.Sources.GetAllAsync());
|
||||
Assert.Equal(SourceStatus.Online, again.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mark_reachable_flips_offline_unc_immediately()
|
||||
{
|
||||
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();
|
||||
var env = new FakeEnv(Path.GetDirectoryName(db)!);
|
||||
var mgr = new SourceManager(store, volumes, env, new SystemClock(), NullLogger<SourceManager>.Instance);
|
||||
await mgr.InitializeAsync();
|
||||
await mgr.AddUncAsync(@"\\nas\media");
|
||||
|
||||
var updated = await mgr.MarkReachableAsync(@"\\nas\media\clip.mkv");
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(SourceStatus.Online, updated.Status);
|
||||
Assert.Equal(SourceStatus.Online, (await store.Sources.GetAllAsync()).Single().Status);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeVolumes : IVolumeService
|
||||
@@ -310,12 +347,19 @@ file sealed class FakeVolumes : IVolumeService
|
||||
v.RootPath.TrimEnd('\\').Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| v.RootPath.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
public bool IsPathReachable(string path) => Online.Any(v => path.StartsWith(v.RootPath.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
public bool IsPathReachable(string path)
|
||||
=> Matches(Online.Select(v => v.RootPath), path) || Matches(ReachablePaths, path);
|
||||
|
||||
public List<string> ReachablePaths { get; } = [];
|
||||
|
||||
public VolumeSpace GetSpace(string path)
|
||||
{
|
||||
var fp = Probe(path);
|
||||
return new VolumeSpace(fp?.CapacityBytes, fp?.FreeBytes);
|
||||
}
|
||||
|
||||
private static bool Matches(IEnumerable<string> roots, string path)
|
||||
=> roots.Any(root => path.StartsWith(root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
file sealed class FakeEnv : IAppEnvironment
|
||||
|
||||
77
tests/Explorer.Application.Tests/TreeRevealSelectorTests.cs
Normal file
77
tests/Explorer.Application.Tests/TreeRevealSelectorTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class TreeRevealSelectorTests
|
||||
{
|
||||
private static readonly TreeRevealCandidate Drive = new(@"D:\", false);
|
||||
private static readonly TreeRevealCandidate Photos = new(@"D:\Photos", true);
|
||||
private static readonly TreeRevealCandidate HomeDocs = new(@"C:\Users\Dominique\Documents", false);
|
||||
private static readonly TreeRevealCandidate FavDocs = new(@"C:\Users\Dominique\Documents", true);
|
||||
|
||||
[Fact]
|
||||
public void This_pc_context_does_not_jump_to_a_matching_favorite()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(Drive, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Favorite_context_stays_in_the_favorite()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: true);
|
||||
Assert.Equal(Photos, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaving_a_favorite_falls_back_to_the_drive()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Other",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: true);
|
||||
Assert.Equal(Drive, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefer_favorites_selects_the_pin_from_this_pc()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[Photos, Drive],
|
||||
@"D:\Photos\Vacation",
|
||||
preferFavorites: true,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(Photos, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Home_beats_the_drive_when_favorites_are_not_preferred()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[FavDocs, HomeDocs, new(@"C:\", false)],
|
||||
@"C:\Users\Dominique\Documents\Work",
|
||||
preferFavorites: false,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(HomeDocs, chosen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefer_favorites_selects_the_documents_pin_over_home()
|
||||
{
|
||||
var chosen = TreeRevealSelector.Choose(
|
||||
[FavDocs, HomeDocs, new(@"C:\", false)],
|
||||
@"C:\Users\Dominique\Documents\Work",
|
||||
preferFavorites: true,
|
||||
currentlyInFavorites: false);
|
||||
Assert.Equal(FavDocs, chosen);
|
||||
}
|
||||
}
|
||||
@@ -57,10 +57,12 @@ public class UiPreferencesStoreTests
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"auto-index-removable=true",
|
||||
"background-host-at-logon=true"
|
||||
"background-host-at-logon=true",
|
||||
"prefer-favorites-in-tree=true"
|
||||
]);
|
||||
Assert.True(prefs.AutoIndexRemovable);
|
||||
Assert.True(prefs.BackgroundHostAtLogon);
|
||||
Assert.True(prefs.PreferFavoritesInTree);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -76,10 +78,32 @@ public class UiPreferencesStoreTests
|
||||
Assert.False(prefs.AutoClearQueueWhenDone);
|
||||
Assert.False(prefs.AutoIndexRemovable);
|
||||
Assert.False(prefs.BackgroundHostAtLogon);
|
||||
Assert.False(prefs.PreferFavoritesInTree);
|
||||
Assert.True(prefs.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(10, prefs.IdleMaintenanceMinutes);
|
||||
Assert.True(prefs.IdleMaintenanceAcOnly);
|
||||
Assert.Null(prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
Assert.Null(prefs.FfmpegPath);
|
||||
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
||||
Assert.True(prefs.FavoriteFolders is null || prefs.FavoriteFolders.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_idle_maintenance_keys()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"background-maintenance-when-idle=false",
|
||||
"idle-maintenance-minutes=30",
|
||||
"idle-maintenance-ac-only=false"
|
||||
]);
|
||||
Assert.False(prefs.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(30, prefs.IdleMaintenanceMinutes);
|
||||
Assert.False(prefs.IdleMaintenanceAcOnly);
|
||||
Assert.Equal(5, UiPreferencesStore.NormalizeIdleMinutes(4));
|
||||
Assert.Equal(10, UiPreferencesStore.NormalizeIdleMinutes(10));
|
||||
Assert.Equal(30, UiPreferencesStore.NormalizeIdleMinutes(90));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -127,6 +151,22 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(prefs.SessionTabs[1].ActiveIsRight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_favorite_folders_and_skips_virtual_roots()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"favorite=D:\\Photos",
|
||||
"favorite=D:\\Photos",
|
||||
"favorite=This PC",
|
||||
"favorite=C:\\Users\\Dominique\\Documents"
|
||||
]);
|
||||
Assert.NotNull(prefs.FavoriteFolders);
|
||||
Assert.Equal(2, prefs.FavoriteFolders.Count);
|
||||
Assert.Equal(@"D:\Photos", prefs.FavoriteFolders[0]);
|
||||
Assert.Equal(@"C:\Users\Dominique\Documents", prefs.FavoriteFolders[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
|
||||
{
|
||||
@@ -153,11 +193,13 @@ public class UiPreferencesStoreTests
|
||||
WindowWidth: 1100,
|
||||
WindowHeight: 720,
|
||||
TreeWidth: 300,
|
||||
FavoriteFolders: [@"D:\Photos", @"C:\Users\Dominique\Documents"],
|
||||
SessionTabs:
|
||||
[
|
||||
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
|
||||
],
|
||||
SessionActiveTab: 0));
|
||||
SessionActiveTab: 0,
|
||||
PreferFavoritesInTree: true));
|
||||
var loaded = store.Load();
|
||||
Assert.Equal("Light", loaded.Theme);
|
||||
Assert.True(loaded.GroupNetworkPlaces);
|
||||
@@ -168,9 +210,14 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(loaded.AutoClearQueueWhenDone);
|
||||
Assert.False(loaded.AutoIndexRemovable);
|
||||
Assert.False(loaded.BackgroundHostAtLogon);
|
||||
Assert.True(loaded.PreferFavoritesInTree);
|
||||
Assert.True(loaded.BackgroundMaintenanceWhenIdle);
|
||||
Assert.Equal(10, loaded.IdleMaintenanceMinutes);
|
||||
Assert.True(loaded.IdleMaintenanceAcOnly);
|
||||
Assert.Equal(1100, loaded.WindowWidth);
|
||||
Assert.Equal(720, loaded.WindowHeight);
|
||||
Assert.Equal(300, loaded.TreeWidth);
|
||||
Assert.Equal([@"D:\Photos", @"C:\Users\Dominique\Documents"], loaded.FavoriteFolders);
|
||||
Assert.NotNull(loaded.SessionTabs);
|
||||
var tab = Assert.Single(loaded.SessionTabs);
|
||||
Assert.Equal(@"C:\Temp", tab.LeftPath);
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Hosting;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Hosting.Tests;
|
||||
|
||||
public class BackgroundMaintenanceCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Activity_pauses_hashing_and_idle_indexing()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Below_idle_threshold_does_not_start()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(3), sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_allows_maintenance_and_does_not_enqueue_twice()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.True(indexing.IdleAllowed);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.True(hash.IsPaused);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activity_resume_pauses_after_idle_work_started()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var idle = new FakeIdle(TimeSpan.FromMinutes(20));
|
||||
var coordinator = Create(hash, indexing, idle, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.NotEmpty(indexing.IdleVerifies);
|
||||
|
||||
idle.Duration = TimeSpan.Zero;
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Equal("Paused because user is active", coordinator.Snapshot.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_policy_never_starts()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), enabled: false, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Foreground_jobs_keep_hashing_paused()
|
||||
{
|
||||
var hash = new FakeHash { Paused = false };
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), foreground: true, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.False(indexing.IdleAllowed);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Battery_skips_when_ac_only()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), ac: false, sources: [StaleLocal()]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
Assert.Empty(indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Run_now_starts_while_user_is_active()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, sources: [StaleLocal()]);
|
||||
coordinator.RunNow();
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(indexing.IdleAllowed);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_verifies_a_fresh_online_source()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var source = StaleLocal();
|
||||
source.Status = SourceStatus.Online;
|
||||
source.LastIndexedUtc = DateTimeOffset.UtcNow.AddHours(-2);
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), sources: [source]);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { 3L }, indexing.IdleVerifies);
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
}
|
||||
|
||||
private static Source StaleLocal()
|
||||
=> new()
|
||||
{
|
||||
Id = 3,
|
||||
StableKey = "d",
|
||||
DisplayName = "D:",
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
Status = SourceStatus.Stale,
|
||||
LastRootPath = @"D:\",
|
||||
LastIndexedUtc = DateTimeOffset.UtcNow.AddDays(-30)
|
||||
};
|
||||
|
||||
private static BackgroundMaintenanceCoordinator Create(
|
||||
FakeHash hash,
|
||||
FakeIndexing indexing,
|
||||
TimeSpan idle,
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
=> Create(hash, indexing, new FakeIdle(idle), enabled, ac, foreground, sources);
|
||||
|
||||
private static BackgroundMaintenanceCoordinator Create(
|
||||
FakeHash hash,
|
||||
FakeIndexing indexing,
|
||||
FakeIdle idle,
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-maint", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var prefs = new UiPreferencesStore(new TempEnv(dir));
|
||||
prefs.Save(UiPreferences.Default with
|
||||
{
|
||||
BackgroundMaintenanceWhenIdle = enabled,
|
||||
IdleMaintenanceMinutes = 10,
|
||||
IdleMaintenanceAcOnly = true
|
||||
});
|
||||
return new BackgroundMaintenanceCoordinator(
|
||||
idle,
|
||||
new FakePower(ac),
|
||||
prefs,
|
||||
new FakeForeground(foreground),
|
||||
indexing,
|
||||
hash,
|
||||
new FakeHistory(),
|
||||
new FakeStore(sources ?? []),
|
||||
new FakeVolumes(),
|
||||
NullLogger<BackgroundMaintenanceCoordinator>.Instance);
|
||||
}
|
||||
|
||||
private sealed class FakeIdle(TimeSpan idle) : IUserIdleMonitor
|
||||
{
|
||||
public TimeSpan Duration { get; set; } = idle;
|
||||
public TimeSpan GetIdleDuration() => Duration;
|
||||
}
|
||||
|
||||
private sealed class FakePower(bool ac) : IPowerSourceMonitor
|
||||
{
|
||||
public bool IsOnAcPower => ac;
|
||||
}
|
||||
|
||||
private sealed class FakeForeground(bool busy) : IForegroundWorkSignal
|
||||
{
|
||||
public bool HasForegroundWork() => busy;
|
||||
}
|
||||
|
||||
private sealed class FakeIndexing : IIdleIndexWork
|
||||
{
|
||||
public bool Busy { get; set; }
|
||||
public bool IdleAllowed { get; private set; }
|
||||
public List<long> IdleScans { get; } = [];
|
||||
public List<long> IdleVerifies { get; } = [];
|
||||
public bool IsBusy => Busy;
|
||||
public bool HasIdleWork => IdleScans.Count > 0 || IdleVerifies.Count > 0;
|
||||
public void SetIdleAllowed(bool allowed) => IdleAllowed = allowed;
|
||||
public void EnqueueIdleFullScan(long sourceId) => IdleScans.Add(sourceId);
|
||||
public void EnqueueIdleVerify(long sourceId, string pathRel) => IdleVerifies.Add(sourceId);
|
||||
}
|
||||
|
||||
private sealed class FakeHash : IIdleHashWork
|
||||
{
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public void BeginUserRequested() { }
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class FakeHistory : IHistoryMaintenance
|
||||
{
|
||||
public Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class FakeVolumes : IVolumeService
|
||||
{
|
||||
public IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes() => [];
|
||||
public VolumeFingerprint? Probe(string path) => null;
|
||||
public VolumeSpace GetSpace(string path) => default;
|
||||
public bool IsPathReachable(string path) => true;
|
||||
}
|
||||
|
||||
private sealed class FakeStore(IReadOnlyList<Source> sources) : IIndexStore
|
||||
{
|
||||
public ISourceStore Sources { get; } = new FakeSources(sources);
|
||||
public IEntryStore Entries => throw new NotSupportedException();
|
||||
public IExcludeStore Excludes => throw new NotSupportedException();
|
||||
public IScanJobStore ScanJobs => throw new NotSupportedException();
|
||||
public ITransferStore Transfers => throw new NotSupportedException();
|
||||
public ISearchStore Search => throw new NotSupportedException();
|
||||
public IAnalysisStore Analysis => throw new NotSupportedException();
|
||||
public IHistoryStore History => throw new NotSupportedException();
|
||||
public IHashStore Hashes { get; } = new FakeHashes();
|
||||
public IFileRelationStore Relations => throw new NotSupportedException();
|
||||
public IRenameBatchStore RenameBatches => throw new NotSupportedException();
|
||||
public ISyncProfileStore SyncProfiles => throw new NotSupportedException();
|
||||
public IOperationProfileStore OperationProfiles => throw new NotSupportedException();
|
||||
public bool CanWrite => true;
|
||||
public Task OpenAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task CloseAsync() => Task.CompletedTask;
|
||||
public Task<string> QuickCheckAsync(CancellationToken cancellationToken = default) => Task.FromResult("ok");
|
||||
public Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default) => work(this);
|
||||
public Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default) => work(this);
|
||||
}
|
||||
|
||||
private sealed class FakeSources(IReadOnlyList<Source> sources) : ISourceStore
|
||||
{
|
||||
public Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(sources);
|
||||
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(sources.FirstOrDefault(s => s.Id == id));
|
||||
public Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Source?>(null);
|
||||
public Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(source.Id);
|
||||
public Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task DeleteAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeHashes : IHashStore
|
||||
{
|
||||
public Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
public Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<HashWorkItem>>([]);
|
||||
public Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
public Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
public Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
public Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(
|
||||
long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IReadOnlyList<DuplicateGroup>>([]);
|
||||
}
|
||||
|
||||
private sealed class TempEnv : IAppEnvironment
|
||||
{
|
||||
public TempEnv(string dir)
|
||||
{
|
||||
DataDirectory = 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; }
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,9 @@ public class CoreRegistrationTests
|
||||
Assert.Null(sp.GetService<IOsClipboard>());
|
||||
Assert.NotNull(sp.GetService<FilesystemScanner>());
|
||||
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
|
||||
Assert.NotNull(sp.GetService<ICloudOverlay>());
|
||||
Assert.NotNull(sp.GetService<IBackgroundMaintenance>());
|
||||
Assert.NotNull(sp.GetService<IUserIdleMonitor>());
|
||||
Assert.NotNull(sp.GetService<IPowerSourceMonitor>());
|
||||
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
|
||||
}
|
||||
finally
|
||||
@@ -76,7 +78,7 @@ public class CoreRegistrationTests
|
||||
Assert.Empty(sp.GetServices<IStorageProvider>());
|
||||
var hosted = sp.GetServices<IHostedService>().ToList();
|
||||
Assert.Contains(hosted, s => s is IndexStoreLifetime);
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService" or "BackgroundMaintenanceCoordinator");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -22,6 +22,9 @@ public class WorkbenchPipeTests
|
||||
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
||||
Assert.True(ping.Ok);
|
||||
|
||||
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
|
||||
Assert.True(ready.Ok);
|
||||
|
||||
var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 });
|
||||
Assert.True(scan.Ok);
|
||||
Assert.Equal(42, indexing.FullScanId);
|
||||
@@ -131,6 +134,18 @@ public class WorkbenchPipeTests
|
||||
Assert.True(ping.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_Ready_needs_a_workbench()
|
||||
{
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var ready = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Ready" });
|
||||
Assert.False(ready.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_roundtrip_over_a_live_named_pipe()
|
||||
{
|
||||
|
||||
@@ -263,6 +263,70 @@ public class ScannerTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_reconcile_updates_child_folder_size_after_uninstall()
|
||||
{
|
||||
var root = CreateTree();
|
||||
var unity = Path.Combine(root, "Unity");
|
||||
var editor = Path.Combine(unity, "Editor");
|
||||
Directory.CreateDirectory(editor);
|
||||
await File.WriteAllTextAsync(Path.Combine(editor, "big.bin"), new string('x', 10_000));
|
||||
await File.WriteAllTextAsync(Path.Combine(unity, "leftover.txt"), "ok");
|
||||
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);
|
||||
var before = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(before!.AggregateSize >= 10_000);
|
||||
|
||||
Directory.Delete(editor, recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None);
|
||||
var shallow = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(shallow!.AggregateSize >= 10_000);
|
||||
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None, verifyChildren: true);
|
||||
var verified = await store.Entries.GetByPathAsync(source.Id, "Unity");
|
||||
Assert.True(verified!.AggregateSize < 1_000);
|
||||
var editorEntry = await store.Entries.GetByPathAsync(source.Id, @"Unity\Editor");
|
||||
Assert.Equal(EntryStatus.Deleted, editorEntry!.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Verify_from_root_finds_nested_emptied_folder()
|
||||
{
|
||||
var root = CreateTree();
|
||||
var programFiles = Path.Combine(root, "Program Files");
|
||||
var unity = Path.Combine(programFiles, "Unity");
|
||||
var editor = Path.Combine(unity, "Editor");
|
||||
Directory.CreateDirectory(editor);
|
||||
await File.WriteAllTextAsync(Path.Combine(editor, "big.bin"), new string('x', 10_000));
|
||||
await File.WriteAllTextAsync(Path.Combine(unity, "leftover.txt"), "ok");
|
||||
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);
|
||||
Directory.Delete(editor, recursive: true);
|
||||
var reconciler = new FolderReconciler(store, new IoEnumerator(), new StorageProviderRegistry([], NullLogger<StorageProviderRegistry>.Instance));
|
||||
await reconciler.ReconcileAsync(source, "", CancellationToken.None, verifyChildren: true);
|
||||
var verified = await store.Entries.GetByPathAsync(source.Id, @"Program Files\Unity");
|
||||
Assert.True(verified!.AggregateSize < 1_000);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_reconcile_tombs_removed_file()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user