From e2916aef9c9840103e484d4f77a6f12d56326a69 Mon Sep 17 00:00:00 2001 From: netquick Date: Wed, 26 Aug 2026 11:21:20 +0200 Subject: [PATCH] Show folder names immediately and refresh stale index sizes without walking the whole drive. Co-authored-by: Cursor --- docs/ARCHITECTURE.md | 6 +- docs/Documentation.md | 73 ++- src/Explorer.Analysis/DuplicateAndHistory.cs | 35 +- .../Explorer.Analysis.csproj | 1 + src/Explorer.App/App.xaml | 64 +-- src/Explorer.App/Converters.cs | 9 + src/Explorer.App/ExplorerAddressBar.xaml | 79 +++ src/Explorer.App/ExplorerAddressBar.xaml.cs | 178 +++++++ src/Explorer.App/ListMarquee.cs | 462 ++++++++++++++++++ src/Explorer.App/MainWindow.xaml | 188 +++---- src/Explorer.App/MainWindow.xaml.cs | 280 +++++++++-- src/Explorer.App/MaximizedWorkArea.cs | 105 ++++ .../Settings/Pages/AdvancedSettingsPage.xaml | 23 + .../Pages/AdvancedSettingsPage.xaml.cs | 24 + .../Pages/AppearanceSettingsPage.xaml | 19 + .../Pages/AppearanceSettingsPage.xaml.cs | 8 + .../Pages/FileOperationsSettingsPage.xaml | 43 ++ .../Pages/FileOperationsSettingsPage.xaml.cs | 38 ++ .../Settings/Pages/GeneralSettingsPage.xaml | 19 + .../Pages/GeneralSettingsPage.xaml.cs | 8 + .../Settings/Pages/IndexingSettingsPage.xaml | 51 ++ .../Pages/IndexingSettingsPage.xaml.cs | 8 + .../Pages/NavigationSettingsPage.xaml | 47 ++ .../Pages/NavigationSettingsPage.xaml.cs | 8 + src/Explorer.App/Settings/SettingsCatalog.cs | 20 + src/Explorer.App/Settings/SettingsCategory.cs | 17 + .../Settings/SettingsCategoryId.cs | 13 + src/Explorer.App/Settings/SettingsDraft.cs | 101 ++++ .../Settings/SettingsPathBrowse.cs | 25 + src/Explorer.App/Settings/SettingsSession.cs | 6 + src/Explorer.App/Settings/SettingsShell.cs | 27 + src/Explorer.App/Settings/SettingsStyles.xaml | 25 + src/Explorer.App/SettingsWindow.xaml | 150 +++--- src/Explorer.App/SettingsWindow.xaml.cs | 89 +--- src/Explorer.App/VirtualizingWrapPanel.cs | 1 + .../BackgroundMaintenance.cs | 40 ++ .../BackgroundMaintenancePlanner.cs | 44 ++ .../BackgroundWorkPolicy.cs | 97 ++++ src/Explorer.Application/BrowseHydration.cs | 1 + src/Explorer.Application/BrowseService.cs | 205 ++++++-- src/Explorer.Application/FavoriteFolders.cs | 87 ++++ .../FolderDisplayRefresh.cs | 57 +++ src/Explorer.Application/FolderStatusText.cs | 73 +++ .../IKnownUserFolderCatalog.cs | 13 + src/Explorer.Application/MarqueeRange.cs | 80 +++ src/Explorer.Application/SourceManager.cs | 110 +++++ .../TreeRevealSelector.cs | 102 ++++ .../UiPreferencesStore.cs | 54 +- src/Explorer.Application/WorkbenchHost.cs | 2 + .../IBackgroundMaintenance.cs | 34 ++ src/Explorer.Contracts/IWorkbenchHost.cs | 4 + .../Abstractions/IIndexStore.cs | 1 + src/Explorer.Domain/AppConstants.cs | 1 + src/Explorer.Domain/Entities.cs | 9 +- src/Explorer.Domain/LocationRoots.cs | 4 +- src/Explorer.FileOperations/TransferQueue.cs | 12 +- .../ExplorerHostClientServices.cs | 9 + .../Ipc/WorkbenchPipeClient.cs | 49 +- .../WorkbenchHostConnector.cs | 42 +- .../BackgroundMaintenanceCoordinator.cs | 266 ++++++++++ src/Explorer.Hosting/ExplorerHostServices.cs | 11 +- .../Ipc/WorkbenchPipeServer.cs | 48 +- src/Explorer.Indexing/FolderReconciler.cs | 100 +++- src/Explorer.Indexing/IndexingCoordinator.cs | 126 ++++- src/Explorer.Indexing/UsnChangeApplier.cs | 4 +- .../ViewModels/ExplorerPaneViewModel.cs | 294 ++++++++++- .../ViewModels/MainViewModel.cs | 232 ++++++++- .../ViewModels/NavigationTreeViewModel.cs | 162 ++++-- .../AnalysisHistoryHashStores.cs | 8 + src/Explorer.Windows/NativeMethods.cs | 26 + src/Explorer.Windows/WindowsIdleAndPower.cs | 34 ++ .../WindowsKnownUserFolderCatalog.cs | 58 +++ .../Explorer.Analysis.Tests/AnalysisTests.cs | 1 + .../BackgroundMaintenancePlannerTests.cs | 78 +++ .../BackgroundWorkPolicyTests.cs | 87 ++++ .../BrowseHydrationTests.cs | 138 ++++++ .../BrowseServiceTests.cs | 52 +- .../FavoriteFoldersTests.cs | 49 ++ .../FolderDisplayRefreshTests.cs | 68 +++ .../FolderStatusTextTests.cs | 85 ++++ .../MarqueeRangeTests.cs | 29 ++ .../SourceManagerTests.cs | 46 +- .../TreeRevealSelectorTests.cs | 77 +++ .../UiPreferencesStoreTests.cs | 51 +- .../BackgroundMaintenanceCoordinatorTests.cs | 322 ++++++++++++ .../CoreRegistrationTests.cs | 6 +- .../WorkbenchPipeTests.cs | 15 + tests/Explorer.Indexing.Tests/ScannerTests.cs | 64 +++ 88 files changed, 5373 insertions(+), 544 deletions(-) create mode 100644 src/Explorer.App/ExplorerAddressBar.xaml create mode 100644 src/Explorer.App/ExplorerAddressBar.xaml.cs create mode 100644 src/Explorer.App/ListMarquee.cs create mode 100644 src/Explorer.App/MaximizedWorkArea.cs create mode 100644 src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/FileOperationsSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml create mode 100644 src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml.cs create mode 100644 src/Explorer.App/Settings/SettingsCatalog.cs create mode 100644 src/Explorer.App/Settings/SettingsCategory.cs create mode 100644 src/Explorer.App/Settings/SettingsCategoryId.cs create mode 100644 src/Explorer.App/Settings/SettingsDraft.cs create mode 100644 src/Explorer.App/Settings/SettingsPathBrowse.cs create mode 100644 src/Explorer.App/Settings/SettingsSession.cs create mode 100644 src/Explorer.App/Settings/SettingsShell.cs create mode 100644 src/Explorer.App/Settings/SettingsStyles.xaml create mode 100644 src/Explorer.Application/BackgroundMaintenance.cs create mode 100644 src/Explorer.Application/BackgroundMaintenancePlanner.cs create mode 100644 src/Explorer.Application/BackgroundWorkPolicy.cs create mode 100644 src/Explorer.Application/FavoriteFolders.cs create mode 100644 src/Explorer.Application/FolderDisplayRefresh.cs create mode 100644 src/Explorer.Application/FolderStatusText.cs create mode 100644 src/Explorer.Application/IKnownUserFolderCatalog.cs create mode 100644 src/Explorer.Application/MarqueeRange.cs create mode 100644 src/Explorer.Application/TreeRevealSelector.cs create mode 100644 src/Explorer.Contracts/IBackgroundMaintenance.cs create mode 100644 src/Explorer.Hosting/BackgroundMaintenanceCoordinator.cs create mode 100644 src/Explorer.Windows/WindowsIdleAndPower.cs create mode 100644 src/Explorer.Windows/WindowsKnownUserFolderCatalog.cs create mode 100644 tests/Explorer.Application.Tests/BackgroundMaintenancePlannerTests.cs create mode 100644 tests/Explorer.Application.Tests/BackgroundWorkPolicyTests.cs create mode 100644 tests/Explorer.Application.Tests/FavoriteFoldersTests.cs create mode 100644 tests/Explorer.Application.Tests/FolderDisplayRefreshTests.cs create mode 100644 tests/Explorer.Application.Tests/FolderStatusTextTests.cs create mode 100644 tests/Explorer.Application.Tests/MarqueeRangeTests.cs create mode 100644 tests/Explorer.Application.Tests/TreeRevealSelectorTests.cs create mode 100644 tests/Explorer.Hosting.Tests/BackgroundMaintenanceCoordinatorTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6e44bae..17d92c3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/Documentation.md b/docs/Documentation.md index 71fe24f..8f2fa12 100644 --- a/docs/Documentation.md +++ b/docs/Documentation.md @@ -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. --- diff --git a/src/Explorer.Analysis/DuplicateAndHistory.cs b/src/Explorer.Analysis/DuplicateAndHistory.cs index 069df3b..0af3118 100644 --- a/src/Explorer.Analysis/DuplicateAndHistory.cs +++ b/src/Explorer.Analysis/DuplicateAndHistory.cs @@ -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 _logger; - private volatile bool _paused; + private volatile bool _paused = true; + private volatile bool _userRequested; public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger 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 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 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; } } diff --git a/src/Explorer.Analysis/Explorer.Analysis.csproj b/src/Explorer.Analysis/Explorer.Analysis.csproj index 8f78f49..4935e9f 100644 --- a/src/Explorer.Analysis/Explorer.Analysis.csproj +++ b/src/Explorer.Analysis/Explorer.Analysis.csproj @@ -3,6 +3,7 @@ Explorer.Analysis + diff --git a/src/Explorer.App/App.xaml b/src/Explorer.App/App.xaml index 894aa5c..902922b 100644 --- a/src/Explorer.App/App.xaml +++ b/src/Explorer.App/App.xaml @@ -7,9 +7,11 @@ + Segoe MDL2 Assets + @@ -141,67 +143,6 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/src/Explorer.App/ExplorerAddressBar.xaml.cs b/src/Explorer.App/ExplorerAddressBar.xaml.cs new file mode 100644 index 0000000..4cbdbf7 --- /dev/null +++ b/src/Explorer.App/ExplorerAddressBar.xaml.cs @@ -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; + } +} diff --git a/src/Explorer.App/ListMarquee.cs b/src/Explorer.App/ListMarquee.cs new file mode 100644 index 0000000..8f0570a --- /dev/null +++ b/src/Explorer.App/ListMarquee.cs @@ -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().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(_kept); + foreach (var item in HitItems(_list, MarqueeListRect())) + { + next.Add(item); + } + + if (next.Count == _list.SelectedItems.Count && next.SetEquals(_list.SelectedItems.Cast())) + { + 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 HitItems(ListView list, Rect marquee) + { + var wrap = FindWrapPanel(list); + var viewport = ViewportBounds(list); + var sampleIndex = -1; + var sampleBounds = Rect.Empty; + var hits = new HashSet(); + 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 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 kept) + { + list.UnselectAll(); + foreach (var item in kept) + { + list.SelectedItems.Add(item); + } + } + + private static ItemsPresenter? FindItemsPresenter(DependencyObject root) => FindChild(root); + + private static ScrollViewer? FindScrollViewer(DependencyObject root) => FindChild(root); + + private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root) => FindChild(root); + + private static T? FindChild(DependencyObject root) + where T : DependencyObject + { + if (root is T match) + { + return match; + } + + for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++) + { + var found = FindChild(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); + } + } +} diff --git a/src/Explorer.App/MainWindow.xaml b/src/Explorer.App/MainWindow.xaml index 7b6c22d..05a4900 100644 --- a/src/Explorer.App/MainWindow.xaml +++ b/src/Explorer.App/MainWindow.xaml @@ -23,6 +23,81 @@ CornerRadius="0" UseAeroCaptionButtons="False"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -184,12 +260,8 @@ - -