Compare commits

9 Commits

Author SHA1 Message Date
222b5d9969 Persist file categories on the index and classify archives and unknown types during idle maintenance.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 11:37:29 +02:00
b33a78dbbe Add host activity and DB browser, and keep dialogs, drag-drop, and idle maintenance responsive.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 02:03:52 +02:00
b72c375e87 Show Git, Cloud, and created date in Details, and hide Free space except on drives.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 22:07:35 +02:00
ff070beba4 Add patterned rename and Move to, and keep settings, selection, and queue speed from resetting.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 18:29:17 +02:00
66ea25993f Add Open in Notepad++ and static Windows shell verbs without embedding IContextMenu on right-click.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 14:24:00 +02:00
e2916aef9c Show folder names immediately and refresh stale index sizes without walking the whole drive.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 11:21:20 +02:00
6fc7506eb7 Add queued FFmpeg conversion and finish splitting the window from the host.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 01:49:50 +02:00
48d03f794f Cut over the window to Explorer.Host.exe so only the host writes the index.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 17:39:13 +02:00
7c23bc2474 Extract a per-user background host so indexing can later run outside the window.
Keep the GUI as the index writer for now; mutex, named pipe, and opt-in logon autostart prepare Explorer.Host.exe without two SQLite writers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 17:10:49 +02:00
242 changed files with 20316 additions and 1001 deletions

View File

@@ -294,18 +294,19 @@ Goal: Replace FileRenamer workflows.
- [x] Counter padding
- [x] Case conversion
- [x] Extension handling
- [ ] Metadata placeholders
- [x] Metadata placeholders
Potential placeholders:
- `{CreatedDate}`
- `{TakenDate}`
- `{ModifiedDate}`
- `{Counter}`
- `{Width}`
- `{Height}`
- `{Artist}`
- `{Title}`
- `{Project}`
- `{Project}` (Git repo folder, else parent)
- `{Extension}`
## Workflow
@@ -318,6 +319,25 @@ Potential placeholders:
- [x] Add Rename operations to File Operations Queue
- [x] Store `OldPath -> NewPath`
- [x] Support Undo for completed rename batches
- [x] Tags from filename
- [x] Filename from tags
- [x] Tag editor (Artist, Title, Album, Track, Year, Genre)
- [x] Queue tag writes
- [x] Saved name patterns
- [x] EXIF Date Taken on photos
- [x] `{Project}` from Git repo / parent folder
---
# Move to
Patterned move with per-file destination folders (Plex-style movie folders, dated backups, and similar).
- [x] Destination pattern popup
- [x] `%filename%` `%filename_noext%` `%ext%` `%year%` `%month%` `%parent%` `%source_drive%`
- [x] Create missing destination folders
- [x] History / saved patterns
- [x] Preview and queue through File Operations Queue
---
@@ -437,14 +457,14 @@ Goal: Understand what files and folders represent rather than relying only on ex
## Detection signals
- [x] File extension
- [ ] MIME/content signature
- [x] MIME/content signature (light idle magic-byte; never hydrates)
- [x] Folder structure
- [x] Git metadata
- [ ] Media metadata
- [x] Media metadata
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
- [x] File age (old installers flagged in preview)
- [ ] File relationships
- [ ] Index metadata
- [x] Index metadata
---
@@ -543,16 +563,16 @@ Potential provider:
Possible operations:
- [ ] Video conversion
- [x] Video conversion
- [ ] Audio conversion
- [ ] Codec conversion
- [ ] Resolution conversion
- [ ] Extract audio
- [x] Extract audio
- [ ] Generate thumbnails
## Images
- [ ] HEIC -> JPEG
- [x] HEIC -> JPEG
- [ ] PNG -> JPEG
- [ ] Resize
- [ ] Rotate
@@ -566,6 +586,8 @@ Conversions should support:
and be usable inside File Operation Profiles.
V1: Convert… dialog and an operation-profile Convert toggle. Jobs run on the host through the File Operations Queue. FFmpeg is discovered, not bundled.
---
# Git Integration
@@ -693,9 +715,9 @@ Discovery: Settings path, then Program Files, then PATH. Missing 7-Zip fails the
## FFmpeg
Potential:
`FfmpegConversionExecutor` (`IMediaConversionProvider`)
`MediaConversionProvider`
Discovery: Settings path, then Program Files, then PATH. Missing FFmpeg fails the queued job with an install hint. FFmpeg is not bundled. V1: H.264 MP4, extract AAC/M4A, HEIC→JPEG. Preview first. Online-only cloud files are not hydrated.
## Git

View File

@@ -6,6 +6,9 @@
<Project Path="src/Explorer.Contracts/Explorer.Contracts.csproj" />
<Project Path="src/Explorer.Domain/Explorer.Domain.csproj" />
<Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" />
<Project Path="src/Explorer.Host/Explorer.Host.csproj" />
<Project Path="src/Explorer.Hosting.Client/Explorer.Hosting.Client.csproj" />
<Project Path="src/Explorer.Hosting/Explorer.Hosting.csproj" />
<Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" />
<Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" />
<Project Path="src/Explorer.Plugin.GoogleDrive/Explorer.Plugin.GoogleDrive.csproj" />
@@ -21,6 +24,7 @@
<Project Path="tests/Explorer.Application.Tests/Explorer.Application.Tests.csproj" />
<Project Path="tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj" />
<Project Path="tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj" />
<Project Path="tests/Explorer.Hosting.Tests/Explorer.Hosting.Tests.csproj" />
<Project Path="tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj" />
<Project Path="tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj" />
<Project Path="tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj" />

View File

@@ -837,17 +837,24 @@ Bitte diese Punkte klären. Empfohlene Defaults in Klammern:
---
## Hintergrunddienst: A vs. B
## Hintergrundprozess: Explorer.Host.exe
| | A In-Process (V1) | B Windows Service (später) |
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 |
|---|---|---|
| Komplexität | niedrig | Session 0, ACL, IPC, Updates |
| Index wenn UI zu | stoppt | läuft weiter |
| USN-Rechte | oft unzureichend | SYSTEM kann Journal lesen |
| Crash-Isolation | UI-Crash stoppt Index | getrennt |
| Empfohlen | **V1 = A** | **V2 = B**, wenn Identity+Schema stabil sind |
| Complexity | per-user process, named pipe | Session 0, ACL, service updates |
| Index when the window is closed | continues | continues |
| Rights | same user as the window | typically SYSTEM |
| Crash isolation | window crash does not stop the index | isolated |
| Autostart | optional HKCU Run (current user, no elevation) | service start |
| Used | **yes** | **not used** |
V1 so schneiden, dass der Indexer ein `IHostedService` ist. Im Dienst-Host später derselbe Service, UI spricht über Named Pipe / gRPC (`Explorer.Contracts`). Nicht in V1 bauen, Interfaces nicht an WPF kleben.
The window (`Explorer.App.exe`) opens the SQLite index **read-only** (WAL). Only the host opens it for write. `Explorer.Contracts` (`IWorkbenchHost`, `ICloudOverlay`, `IHostConnection`) is the IPC surface over a current-user named pipe. Plugin implementations load in the host; the window talks overlay through the pipe.
If the host is not running, the window starts `Explorer.Host.exe` beside itself. Closing the window does not stop the host. Quit it from the host tray (**Quit background host**) or **File → Stop background host…**. Settings can add the host to the current users Windows sign-in programs (`HKCU\...\Run`) so it starts at logon without administrator rights.
The original V1 sketch was in-process `IHostedService` inside the UI. That path is gone. A Windows Service is still out of scope.
---
@@ -886,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** — in-process `IHostedService` instances (indexer, transfer queue, hash worker, history rollup, watchers). No Windows Service in this 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()`.
@@ -894,4 +901,7 @@ 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 folders 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.
16. **Shell context verbs** — the compact item menu only reads static registry verbs (no `IContextMenu` on right-click; that AVd via `IShellFolder.GetUIObjectOf` / `MENUITEMINFO` string marshaling). **Open in Notepad++** is a first-class Workbench command (same pattern as **Open in Cursor**), not a shell-extension row. `IContextMenu` / `TrackPopupMenu` is not used on the compact menu.

View File

@@ -8,7 +8,7 @@ Mental model:
Windows remains the source of truth. Removing a location from Workbench removes Workbenchs index data for it. It does **not** disconnect a network drive, unlink OneDrive, or change Explorer settings.
Version documented here: **0.1** (schema 8). This file is the user guide. Edit it in any text editor; Explorer Workbench reloads it when you open **Help → Documentation**.
Version documented here: **0.1** (schema 9). This file is the user guide. Edit it in any text editor; Explorer Workbench reloads it when you open **Help → Documentation**.
---
@@ -18,43 +18,44 @@ Workbench **does**:
- Browse live folders (local, removable, network, cloud mounts)
- Index locations you choose, then search and analyze them
- Queue copy, move, recycle, rename, archive, sync, and organize work
- Queue copy, move, recycle, rename, archive, convert, sync, and organize work
- Overlay Git and cloud status without becoming a Git client or a sync engine
Workbench **does not**:
- Two-way sync
- Convert media (no FFmpeg in this build)
- Hydrate online-only cloud files just to look at them
- Change Windows drive mappings or cloud client folders
- Replace Git (no stash, branch UI, mergetool, or credential dialog)
Specialized tools still do specialized jobs. 7-Zip compresses. Git reports status, shows diffs, commits selected files, resolves conflicts, and runs fetch, pull, and push. Workbench orchestrates.
Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a few media kinds. Git reports status, shows diffs, commits selected files, resolves conflicts, and runs fetch, pull, and push. Workbench orchestrates.
---
## First launch
The window opens immediately. Locations fill in a moment later — Workbench does not wait for slow network shares or a second instance locking the index.
1. The window starts `Explorer.Host.exe` if it is not already running, then connects over a named pipe. Locations fill in a moment later — Workbench does not wait for slow network shares.
2. Browsing works with an empty index.
3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control.
4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable.
5. Closing the window leaves the host running (indexing and the queue). A tray icon **Explorer Workbench host** can open the window again or **Quit background host**. **File → Stop background host…** does the same from the window.
| Path | Contents |
| --- | --- |
| `%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, saved name patterns, Move to history |
---
## Window layout
- **Title bar** — Explorer Workbench; minimize / maximize / close.
- **Menu** — File, View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives), 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.
- **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** — 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,12 +66,27 @@ The window opens immediately. Locations fill in a moment later — Workbench doe
| 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.
---
## 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.
@@ -79,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.”
@@ -100,13 +118,15 @@ 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. **Details** also has Date created, Git, and Cloud. Git is blank for clean tracked files; otherwise Modified, Staged, Untracked, Unmerged, or a nested-repo badge. Cloud is blank unless the overlay has a state (Online-only, Local, Pinned, Syncing, Error). Free space is shown only on This PC (and other listings that actually have volume free space). Cloud status, indexed folder totals, and Git 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.
Right-click a file or folder to get Workbench commands plus extra Windows items that fit in the compact menu (PDF24, 7-Zip, and similar). **Open in Notepad++** and **Open in Cursor** are Workbench entries next to **Open terminal here**. Open, Cut, Copy, Delete, and Rename stay Workbenchs own entries so they are not listed twice. Listing those items does not download online-only cloud files; running one of them is an explicit open and may hydrate.
**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 |
| --- | --- |
@@ -116,13 +136,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.
---
@@ -132,7 +156,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.
@@ -150,11 +178,19 @@ 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.
---
## Development tools
**Tools → Development → Host activity…** opens a live monitor of the background host: maintenance, indexing jobs, hashing, transfers, and a rolling activity log. It refreshes about every 1.5 seconds while open and also reacts to push events; closing it stops the polling.
**Tools → Development → Database…** opens a general SQLite viewer. It starts on the Workbench index (`%LocalAppData%\ExplorerWorkbench\index.db`) in read-only mode while the host holds the write lock. Use **Open file…** for any other `.db`, and choose write mode when the file is not locked. You can browse tables, run SQL, and — when writable — edit cells, insert rows, and delete rows.
## Duplicates
**Tools → Storage → Duplicates**. Groups are hashed in the background (size → partial hash → full hash only when needed). Workbench distinguishes:
**Tools → Storage → Duplicates**. Groups come from the **index** (hashed in the background: size → partial hash → full hash only when needed), not from a live walk of the disk. The list fills from the index first (largest groups at the top); missing copies are dropped afterwards without blocking the window. A finished full scan of the drive also marks missing trees deleted; cancelling a scan does not. Unmarked groups have no class label. After you mark a group, Workbench shows:
| Class | Meaning |
| --- | --- |
@@ -172,7 +208,7 @@ Intentional and sync copies are hidden by default. Hardlinks are not wasted spac
Almost every change goes through the queue instead of happening silently.
Supported operations today: copy, move, recycle, permanent delete, rename, empty Recycle Bin, extract, compress, add to archive, verify archive.
Supported operations today: copy, move, recycle, permanent delete, rename, empty Recycle Bin, extract, compress, add to archive, verify archive, convert.
The queue:
@@ -196,15 +232,40 @@ Auto-clear when done is a setting. Failed items stay until you dismiss or retry
Workflow: configure → preview → validate → queue.
Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
Rules: search/replace, regex, prefix, suffix, counter (with padding), case, extension. An optional **name pattern** can replace the current name using placeholders: `{Artist}`, `{Title}`, `{Album}`, `{Track}`, `{Year}`, `{Genre}`, `{CreatedDate}`, `{TakenDate}`, `{ModifiedDate}`, `{Width}`, `{Height}`, `{Name}`, `{Extension}`, `{Parent}`, `{Project}`, `{Counter}`. Dates accept a format (`{TakenDate:yyyyMMdd}`). `{CreatedDate}` uses EXIF Date Taken when that was already read; `{TakenDate}` is the token that reads photo metadata. `{Project}` is the Git repository folder, or the parent folder if the file is not in a repo. **Save pattern** stores a custom pattern in `ui-preferences.txt` (built-in patterns stay in the list: `{Artist} - {Title}`, `{Track:00} - {Title}`, `{TakenDate}_{Name}`, `{CreatedDate}_{Name}`, `{Project}_{Name}`). Collisions and illegal Windows names are caught before enqueue. Completed batches can be undone (**Tools → File Operations → Undo last rename batch**).
Metadata placeholders such as `{CreatedDate}` or `{Width}` are not implemented yet. `{Counter}` and `{Extension}` work.
**Tags…** (select files first) is the Tag&Rename-style editor. **Filename → tags** fills Artist/Title/… from the current names using the same pattern. **Tags → filename** builds new names from tags. **Queue tags** writes ID3 on audio and EXIF (title, comment, creator, date taken) on photos through the File Operations Queue; **Queue rename** queues the new names. Online-only cloud files are skipped so they are not downloaded.
---
## Move to
**Tools → File Operations → Move to…** or **Move to…** on the item context menu (select items first).
Not a plain “move into this folder”. The destination is a **pattern**. Placeholders expand per file, missing folders are created, then the move is queued.
| Token | Meaning |
| --- | --- |
| `%filename%` | Name including extension |
| `%filename_noext%` | Name without extension |
| `%ext%` | Extension without the dot |
| `%year%` / `%month%` | Last-write time (`2024` / `08`) |
| `%parent%` | Parent folder name |
| `%source_drive%` | Drive (`D:`) or UNC share (`\\10.0.0.31\media`) |
`{filename_noext}` and the other `{…}` forms work the same.
Plex-style movies: `\\10.0.0.31\media\movies\%filename_noext%` creates `movies\Inception\` and places `Inception.mkv` inside it. If the last segment is `%filename%` or `%ext%`, the pattern is the full destination file path.
Type the path in the text box (or **Browse…**), then click a token to insert it at the caret. **Recent** lists saved patterns; the `\\host\share\…` row is only an example and is rejected if you queue it. **Queue** and **Save** store real patterns in `ui-preferences.txt` (`move-to=`).
If a UNC share or mapped drive is disconnected, Workbench tries to reconnect it (same credentials, no extra prompt). Jobs that still cannot reach the destination wait in the queue; **Retry** re-probes and reconnects. Online-only cloud files are skipped.
---
## 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 |
| --- | --- |
@@ -217,6 +278,20 @@ 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 → File Operations at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
| Kind | Output |
| --- | --- |
| Video to H.264 MP4 | `.mp4` next to the source, or in a folder you pick |
| Extract audio | AAC in `.m4a` |
| HEIC to JPEG | `.jpg` (depends on the FFmpeg build having a HEIC decoder) |
**Tools → File Operations → Convert…** or **Convert…** on the item context menu. Select files or a folder, pick a kind and destination, preview names, then Queue. Each file is one queue job on the background host. Online-only cloud files are skipped. Existing names get a unique suffix so nothing is overwritten. Source last-write time is copied onto the output when that is possible.
---
## Folder sync
**Tools → Automation → Folder sync…****one-way** only.
@@ -236,18 +311,19 @@ Files copied by sync are tagged as synchronized duplicates.
**Tools → Automation → Operation profiles…** — named recipes that **plan** work, then enqueue it. Steps never touch the filesystem themselves.
Toggles (not a free-form graph): Copy, Rename, Compress, require a clean Git working tree. Excludes are globs, one per line.
Toggles (not a free-form graph): Copy, Rename, Compress, Convert, require a clean Git working tree. Excludes are globs, one per line.
Built-in recipes (seeded when the list is empty):
1. **Archive folder** — require clean Git, compress 7z, exclude `.git` / `bin` / `obj` / `.vs`
2. **Copy to destination** — copy; AutoRun when the destination volume connects
3. **Convert videos to MP4** — FFmpeg H.264 MP4 into the destination folder
Run from the window, from **Run profile** on the context menu (always previews first), or by dropping files onto a profile. AutoRun is Copy-only (no rename, no compress) and fires on unreachable → reachable, not on a timer while already online.
Run from the window, from **Run profile** on the context menu (always previews first), or by dropping files onto a profile. AutoRun is Copy-only (no rename, no compress, no convert) and fires on unreachable → reachable, not on a timer while already online.
Dirty or missing Git, missing 7-Zip, or an unreachable destination stops the plan. Nothing is queued.
Dirty or missing Git, missing 7-Zip, missing FFmpeg, or an unreachable destination stops the plan. Nothing is queued.
Not in this build: SHA-256, recycle source after success, FFmpeg, scheduled or folder-watcher triggers.
Not in this build: SHA-256, recycle source after success, GPU tuner, trim editor, scheduled or folder-watcher triggers.
---
@@ -267,13 +343,28 @@ Classification **suggests** moves. Nothing moves until you Preview and Queue.
Build output names (`node_modules`, `bin`, `obj`, `.vs`, and similar) are never moved. Online-only cloud items are skipped. Old installers (older than one year) still propose a move and show a warning. Destinations may sit *inside* the source folder (Downloads → Downloads\Software). Destinations are remembered in preferences.
Never auto-reorganizes. No MIME/content sniffing (that would hydrate cloud files).
Organize uses the **indexed category** when one exists (including ZIP contents that are mostly photos, and **Classify as…** overrides). Extension heuristics fill in the rest.
Never auto-reorganizes. A light magic-byte peek for unknown extensions runs only during idle maintenance on local files — never on online-only cloud items.
---
## Classification
The index stores a category on each entry (Photos, Video, Documents, Archive, and so on) plus a short reason.
- Details shows a **Category** column (tooltip has the reason)
- Search accepts `category:photos` or the category dropdown
- Storage analysis has **By category** next to **By file type**
- Context menu **Classify as…** writes a user override that later scans do not replace
ZIP/7z files stay Archive until their contents are indexed; idle maintenance then promotes them when a category dominates.
---
## Git
Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, and **Open in Cursor** when a folder is in a repository.
Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind, merging/rebasing). In **Details**, the **Git** column is per file: blank when the file matches HEAD, otherwise Modified, Staged, Untracked, Unmerged, or Staged · Modified. Folders with dirty children show Modified or Untracked. A nested repository folder shows a compact repo badge. **Tools → Development** (and the folder context menu) offers **View changes…**, **Commit…**, **Fetch**, **Pull (fast-forward)**, **Pull (merge)**, **Push**, **Open terminal here**, **Open in Cursor**, and **Open in Notepad++** when a folder is in a repository.
**View changes** lists staged, unstaged, untracked, and unmerged paths from `git status`. Double-click opens a unified **diff** (`git diff` / `git diff --cached`). **Open in Cursor** opens the file. Online-only cloud files are not opened or diffed (that would download them). Stage, unstage, and discard call the matching `git` commands. Discard asks first.
@@ -283,7 +374,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.
---
@@ -291,28 +382,28 @@ Missing `git.exe` means no badge and no Git actions. Path can be set in Settings
When a cloud folder is added:
- Status text on items (available / online-only / syncing)
- Status on items (Details **Cloud** column, and next to the name in List): Online-only, Local, Pinned, Syncing, Error
- **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate
- Quota in capacity/free space where the provider reports it
Workbench never starts a cloud vendors own two-way sync. It never hydrates a file as a side effect of browse, size, search, hash, or archive.
Workbench never starts a cloud vendors own two-way sync. It never hydrates a file as a side effect of browse, size, search, hash, archive, or convert.
---
## 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
- Path to 7-Zip
- Path to git.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.
---
@@ -336,13 +427,12 @@ Left open on purpose:
- Two-way sync and conflict resolution UI
- Undo for copy/move
- Concurrent copies across different disks
- FFmpeg / media conversion
- GPU / trim / filter conversion
- Multi-PC search, sharing, encrypted vaults
- Robocopy as a second transfer engine
- Scheduled profiles and folder-watcher triggers
- MIME/EXIF classification
- Full EXIF / ffprobe classification
- Duplicate “backup copy” auto-tagging
- Rename placeholders from EXIF or dates
---

View File

@@ -1,3 +1,4 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -5,6 +6,8 @@ namespace Explorer.Analysis;
public sealed class AnalysisService
{
private const int DuplicateHashChunk = 24;
private readonly IIndexStore _store;
private readonly AnalysisResultCache _cache = new();
private readonly SemaphoreSlim _ready = new(1, 1);
@@ -73,6 +76,16 @@ public sealed class AnalysisService
ct => _store.Analysis.UsageByExtensionAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<CategoryUsage>> UsageByCategoryAsync(
long? sourceId,
string? pathRelPrefix,
int take = AppConstants.AnalysisTopN,
CancellationToken cancellationToken = default)
=> CachedAsync(
$"categories:{sourceId}:{pathRelPrefix}:{take}",
ct => _store.Analysis.UsageByCategoryAsync(sourceId, pathRelPrefix, take, ct),
cancellationToken);
public Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
=> CachedAsync("sources", ct => _store.Analysis.UsageBySourceAsync(ct), cancellationToken);
@@ -93,15 +106,152 @@ public sealed class AnalysisService
CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
var raw = await _store.Hashes.GetDuplicateGroupsAsync(null, null, Math.Max(take * 8, 400), ct)
.ConfigureAwait(false);
var ids = raw.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
var relations = await _store.Relations.GetAmongAsync(ids, ct).ConfigureAwait(false);
return (IReadOnlyList<ClassifiedDuplicateGroup>)raw
.Select(g => DuplicateClassifier.ClassifyGroup(g, DuplicateClassifier.RelationsFor(g.Entries, relations)))
.ToList();
var list = new List<ClassifiedDuplicateGroup>();
await StreamCoreAsync(take, list.Add, verifyPresence: true, ct).ConfigureAwait(false);
return (IReadOnlyList<ClassifiedDuplicateGroup>)list;
}, cancellationToken);
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
int take,
IProgress<ClassifiedDuplicateGroup> progress,
CancellationToken cancellationToken = default)
=> StreamClassifiedDuplicatesAsync(take, progress, verifyPresence: false, cancellationToken);
public Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamClassifiedDuplicatesAsync(
int take,
IProgress<ClassifiedDuplicateGroup> progress,
bool verifyPresence,
CancellationToken cancellationToken = default)
=> RunOffUiAsync(
ct => StreamCoreAsync(take, progress.Report, verifyPresence, ct),
cancellationToken);
private async Task<IReadOnlyList<(long SourceId, string PathRel)>> StreamCoreAsync(
int take,
Action<ClassifiedDuplicateGroup> emit,
bool verifyPresence,
CancellationToken cancellationToken)
{
var limit = Math.Max(take * 8, 400);
var hashes = await _store.Hashes.GetDuplicateHashesAsync(null, null, limit, cancellationToken)
.ConfigureAwait(false);
var sources = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false))
.ToDictionary(s => s.Id);
var missing = new List<MissingCopy>();
for (var offset = 0; offset < hashes.Count; offset += DuplicateHashChunk)
{
cancellationToken.ThrowIfCancellationRequested();
var chunk = hashes.Skip(offset).Take(DuplicateHashChunk).ToList();
var groups = await _store.Hashes.GetDuplicateGroupsByHashesAsync(chunk, cancellationToken)
.ConfigureAwait(false);
var kept = new List<DuplicateGroup>();
foreach (var group in groups)
{
var present = verifyPresence
? KeepPresentCopies(group, sources, missing)
: group.Entries.ToList();
if (present.Count < 2)
{
continue;
}
kept.Add(new DuplicateGroup
{
SizeBytes = group.SizeBytes,
Hash = group.Hash,
Entries = present,
SameFileId = DuplicateClassifier.IsHardlinkOnly(present)
});
}
var ids = kept.SelectMany(g => g.Entries.Select(e => e.Id)).Distinct().ToList();
var relations = await _store.Relations.GetAmongAsync(ids, cancellationToken).ConfigureAwait(false);
foreach (var group in kept)
{
emit(DuplicateClassifier.ClassifyGroup(
group,
DuplicateClassifier.RelationsFor(group.Entries, relations)));
}
}
if (_store.CanWrite && missing.Count > 0)
{
await TombstoneMissingAsync(missing, sources, cancellationToken).ConfigureAwait(false);
}
return missing
.Select(m => (m.SourceId, IndexedPathPresence.ReconcilePath(m.PathRel, m.Prefix)))
.Distinct()
.ToList();
}
private static List<IndexEntry> KeepPresentCopies(
DuplicateGroup group,
IReadOnlyDictionary<long, Source> sources,
List<MissingCopy> missing)
{
var kept = new List<IndexEntry>(group.Entries.Count);
foreach (var entry in group.Entries)
{
if (!sources.TryGetValue(entry.SourceId, out var source)
|| string.IsNullOrWhiteSpace(source.LastRootPath)
|| !IndexedPathPresence.RootReachable(source.LastRootPath))
{
kept.Add(entry);
continue;
}
if (IndexedPathPresence.FileExists(source.LastRootPath, entry.PathRel))
{
kept.Add(entry);
continue;
}
var prefix = IndexedPathPresence.HighestMissingPrefix(source.LastRootPath, entry.PathRel)
?? entry.PathRel;
missing.Add(new MissingCopy(entry.SourceId, entry.Id, entry.PathRel, prefix, source.LastRootPath));
}
return kept;
}
private async Task TombstoneMissingAsync(
IReadOnlyList<MissingCopy> missing,
IReadOnlyDictionary<long, Source> sources,
CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
var prefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in missing)
{
if (!item.Prefix.Equals(item.PathRel, StringComparison.OrdinalIgnoreCase)
&& sources.TryGetValue(item.SourceId, out var source)
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
&& !IndexedPathPresence.DirectoryExists(PathRules.Combine(source.LastRootPath, item.Prefix)))
{
var key = item.SourceId + "|" + item.Prefix;
if (!prefixes.Add(key))
{
continue;
}
var folder = await _store.Entries.GetByPathAsync(item.SourceId, item.Prefix, cancellationToken)
.ConfigureAwait(false);
if (folder is not null)
{
await _store.Entries.TombstoneAsync(folder.Id, now, cancellationToken).ConfigureAwait(false);
}
await _store.Entries.TombstoneByPathPrefixAsync(item.SourceId, item.Prefix, now, cancellationToken)
.ConfigureAwait(false);
}
else
{
await _store.Entries.TombstoneAsync(item.EntryId, now, cancellationToken).ConfigureAwait(false);
}
}
}
public Task MarkDuplicateGroupAsync(
IReadOnlyList<IndexEntry> entries,
FileRelationKind kind,
@@ -156,4 +306,11 @@ public sealed class AnalysisService
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
.ConfigureAwait(false);
}
private readonly record struct MissingCopy(
long SourceId,
long EntryId,
string PathRel,
string Prefix,
string Root);
}

View File

@@ -7,29 +7,102 @@ 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 IHostActivitySink _activity;
private readonly ILogger<DuplicateHashWorker> _logger;
private volatile bool _paused;
private readonly object _pendingGate = new();
private long _pendingCount;
private DateTimeOffset _pendingCountUtc = DateTimeOffset.MinValue;
private int _pendingRefreshBusy;
private volatile bool _paused = true;
private volatile bool _userRequested;
private volatile string? _currentPath;
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
public DuplicateHashWorker(
IIndexStore store,
IHydrationGuard hydration,
ILogger<DuplicateHashWorker> logger,
IHostActivitySink? activity = null)
{
_store = store;
_hydration = hydration;
_logger = logger;
_activity = activity ?? NullHostActivitySink.Instance;
}
public bool IsPaused => _paused && !_userRequested;
public string? CurrentPath => _currentPath;
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 Task<long> CountPendingAsync(CancellationToken cancellationToken = default)
{
long cached;
var never = false;
lock (_pendingGate)
{
cached = _pendingCount;
never = _pendingCountUtc == DateTimeOffset.MinValue;
if (!never && DateTimeOffset.UtcNow - _pendingCountUtc < TimeSpan.FromSeconds(15))
{
return Task.FromResult(cached);
}
}
if (never)
{
return RefreshPendingCountAsync(cancellationToken);
}
if (Interlocked.CompareExchange(ref _pendingRefreshBusy, 1, 0) == 0)
{
_ = RefreshPendingCountInBackgroundAsync();
}
return Task.FromResult(cached);
}
private async Task RefreshPendingCountInBackgroundAsync()
{
try
{
await RefreshPendingCountAsync(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Background hash-pending count failed");
}
finally
{
Interlocked.Exchange(ref _pendingRefreshBusy, 0);
}
}
private async Task<long> RefreshPendingCountAsync(CancellationToken cancellationToken)
{
var count = await _store.Hashes.CountPendingAsync(cancellationToken).ConfigureAwait(false);
lock (_pendingGate)
{
_pendingCount = count;
_pendingCountUtc = DateTimeOffset.UtcNow;
}
return count;
}
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;
}
@@ -40,6 +113,8 @@ public sealed class DuplicateHashWorker : BackgroundService
}
var path = PathRules.Combine(item.RootPath, item.PathRel);
_currentPath = path;
_activity.Record("Hash", item.State + " · " + path);
try
{
if (!File.Exists(path))
@@ -84,6 +159,10 @@ public sealed class DuplicateHashWorker : BackgroundService
_logger.LogDebug(ex, "Hash failed for {Path}", path);
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
}
finally
{
_currentPath = null;
}
}
}
@@ -92,7 +171,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 +179,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 +206,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 +233,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;
}
}

View File

@@ -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>

View File

@@ -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"/>
@@ -55,7 +57,33 @@
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="NameWithOverlays">
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Converter={StaticResource ShellIcon}}"/>
<TextBlock Tag="ItemName" Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsRenaming}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBox Style="{StaticResource InlineRenameBox}"
Tag="InlineRename"
Text="{Binding EditName, UpdateSourceTrigger=PropertyChanged}"
local:InlineRenameBehavior.Enable="True"
Visibility="{Binding IsRenaming, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding StatusGlyph}" Margin="6,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasStatusGlyph, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding CloudLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasCloudStatus, Converter={StaticResource BoolVis}}"/>
<TextBlock Text="{Binding GitLabel}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
@@ -141,67 +169,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="&#xE977;" 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 +673,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">

View File

@@ -1,18 +1,26 @@
using System.IO;
using System.Windows;
using System.Windows.Controls;
using Explorer.Hosting;
using Explorer.Hosting.Ipc;
using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Extensions.Logging;
namespace Explorer.App;
public partial class App : System.Windows.Application
{
private IHost? _host;
private WorkbenchPipeClient? _workbenchClient;
protected override async void OnStartup(StartupEventArgs e)
protected override void OnStartup(StartupEventArgs e)
{
// Closing the splash must not quit the process before the main window exists.
ShutdownMode = ShutdownMode.OnExplicitShutdown;
base.OnStartup(e);
DispatcherUnhandledException += (_, args) =>
{
@@ -29,27 +37,80 @@ public partial class App : System.Windows.Application
retainedFileCountLimit: 14)
.CreateLogger();
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) => services.AddExplorer())
.Build();
var splash = ShowStartupSplash();
_ = StartWorkbenchAsync(splash);
}
var vm = _host.Services.GetRequiredService<MainViewModel>();
var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm;
window.Show();
private async Task StartWorkbenchAsync(Window splash)
{
using var loggerFactory = new SerilogLoggerFactory(Log.Logger);
try
{
await vm.InitializeAsync().ConfigureAwait(true);
try
{
var logger = loggerFactory.CreateLogger("HostConnector");
_workbenchClient = await WorkbenchHostConnector.ConnectOrStartAsync(
TimeSpan.FromSeconds(60),
logger)
.ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Could not connect to Explorer.Host.exe");
}
if (_workbenchClient is null)
{
var hostExe = HostLogonAutostart.FindHostExecutable();
MessageBox.Show(
hostExe is null
? "Explorer.Host.exe was not found beside Explorer.App.exe. Copy the host executable next to the window, then start again."
: "Explorer.Host.exe did not accept a connection in time. The host process you just started is still initializing; wait until its CPU usage drops, then start Explorer.App.exe again. You do not need to close Explorer.Host.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Error);
Shutdown(-1);
return;
}
_host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureServices((_, services) =>
{
services.AddExplorerClient(_workbenchClient);
services.AddExplorerUi();
})
.Build();
var vm = _host.Services.GetRequiredService<MainViewModel>();
var window = _host.Services.GetRequiredService<MainWindow>();
vm.PrepareUi();
window.DataContext = vm;
MainWindow = window;
window.Show();
ShutdownMode = ShutdownMode.OnMainWindowClose;
try
{
await vm.InitializeAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Startup initialization failed");
vm.Footer = "Started with errors. See logs.";
}
await _host.StartAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Log.Error(ex, "Startup initialization failed");
vm.Footer = "Started with errors. See logs.";
Log.Error(ex, "Could not start Explorer Workbench");
Shutdown(-1);
}
finally
{
splash.Close();
}
await _host.StartAsync().ConfigureAwait(true);
}
protected override async void OnExit(ExitEventArgs e)
@@ -60,7 +121,35 @@ public partial class App : System.Windows.Application
_host.Dispose();
}
if (_workbenchClient is not null)
{
await _workbenchClient.DisposeAsync().ConfigureAwait(true);
}
Log.CloseAndFlush();
base.OnExit(e);
}
private static Window ShowStartupSplash()
{
var splash = new Window
{
Title = "Explorer Workbench",
Width = 420,
Height = 120,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.ToolWindow,
ShowInTaskbar = true,
Content = new TextBlock
{
Text = "Starting Explorer Workbench…",
Margin = new Thickness(20),
TextWrapping = TextWrapping.Wrap,
VerticalAlignment = VerticalAlignment.Center
}
};
splash.Show();
return splash;
}
}

View File

@@ -1,127 +1,24 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
using Explorer.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App;
public static class AppServices
{
public static IServiceCollection AddExplorer(this IServiceCollection services)
public static IServiceCollection AddExplorerUi(this IServiceCollection services)
{
services.AddSingleton<IClock, SystemClock>();
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
services.AddSingleton<IVolumeService, WindowsVolumeService>();
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
services.AddSingleton<IIndexStore>(sp =>
{
var env = sp.GetRequiredService<IAppEnvironment>();
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
return new SqliteIndexStore(env.DatabasePath, logger);
});
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
services.AddSingleton<IStorageProvider, GoogleDriveStorageProvider>();
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<WindowsGitStatusProvider>();
services.AddSingleton<IGitStatusProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IGitCommandProvider>(sp => sp.GetRequiredService<WindowsGitStatusProvider>());
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
services.AddSingleton<UiPreferencesStore>();
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
services.AddSingleton<IndexingCoordinator>();
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenamePlanner>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>();
services.AddSingleton<MainWindow>();
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<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
}
public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(forceRefresh: true, stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}
}

View File

@@ -68,7 +68,14 @@
<TextBlock Text="Extension" FontWeight="SemiBold" Margin="0,0,0,8"/>
<CheckBox Content="Change extension" IsChecked="{Binding ChangeExtension}" Margin="0,0,0,8"/>
<TextBox Text="{Binding NewExtension, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="{Binding ChangeExtension}"/>
IsEnabled="{Binding ChangeExtension}" Margin="0,0,0,16"/>
<TextBlock Text="Name pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ComboBox IsEditable="True" ItemsSource="{Binding PatternChoices}"
Text="{Binding NamePattern, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<Button Content="Save pattern" Height="28" Command="{Binding SavePatternCommand}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
Text="Optional. Replaces the current name. Placeholders: {Artist} {Title} {Album} {Track} {Year} {Genre} {CreatedDate} {TakenDate} {ModifiedDate} {Width} {Height} {Name} {Extension} {Parent} {Project} {Counter}. Dates accept {TakenDate:yyyyMMdd}."/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"

View File

@@ -0,0 +1,47 @@
<Window x:Class="Explorer.App.ConvertWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Convert"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="560" Width="820"
MinHeight="420" MinWidth="640"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Conversion" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding Kinds}" DisplayMemberPath="Label" SelectedValuePath="Kind"
SelectedValue="{Binding Kind}" Margin="0,0,0,16"/>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
Text="FFmpeg is not bundled. Jobs run on the background host through the File Operations Queue. Online-only cloud files are skipped. Output names are unique so existing files are not overwritten."/>
</StackPanel>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Action" Width="90" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Output" Width="240" DisplayMemberBinding="{Binding Path}"/>
<GridViewColumn Header="Source" Width="160" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,41 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class ConvertWindow : Window
{
public ConvertWindow(ConvertViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
// not shown as a dialog
}
Close();
};
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Convert to",
Multiselect = false
};
if (picker.ShowDialog(this) == true
&& !string.IsNullOrWhiteSpace(picker.FolderName)
&& DataContext is ConvertViewModel vm)
{
vm.DestPath = picker.FolderName;
}
}
}

View File

@@ -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)

View File

@@ -0,0 +1,78 @@
<Window x:Class="Explorer.App.DatabaseWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Database"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="1100"
MinHeight="480" MinWidth="800"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="12">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close DB" MinWidth="80" Height="28"
Command="{Binding CloseDatabaseCommand}" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file…" MinWidth="90" Height="28"
Click="OnOpenFile" Margin="6,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open Workbench index" MinWidth="150" Height="28"
Command="{Binding OpenWorkbenchIndexCommand}" Margin="6,0,0,0"/>
<StackPanel>
<TextBlock Text="{Binding PathLabel}" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding ModeLabel}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="↻" Width="28" Height="26"
Command="{Binding RefreshTablesCommand}" ToolTip="Refresh tables"/>
<TextBlock Text="Tables" FontWeight="SemiBold" VerticalAlignment="Center"/>
</DockPanel>
<ListBox ItemsSource="{Binding Tables}" SelectedItem="{Binding SelectedTable}"/>
</DockPanel>
<DockPanel Grid.Column="2">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Run SQL" MinWidth="80" Height="28"
Command="{Binding RunSqlCommand}" Margin="8,0,0,0"/>
<TextBox Text="{Binding Sql, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="56" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto"
FontFamily="Consolas"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<TextBlock DockPanel.Dock="Left" Text="{Binding PageLabel}" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Prev" MinWidth="60" Height="26"
Command="{Binding PrevPageCommand}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Next" MinWidth="60" Height="26"
Command="{Binding NextPageCommand}" Margin="0,0,12,0"/>
<Button DockPanel.Dock="Left" Content="Edit cell…" MinWidth="80" Height="26"
Click="OnEditCell" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Insert row…" MinWidth="90" Height="26"
Click="OnInsertRow" IsEnabled="{Binding CanWrite}" Margin="0,0,6,0"/>
<Button DockPanel.Dock="Left" Content="Delete row" MinWidth="80" Height="26"
Command="{Binding DeleteSelectedRowCommand}" IsEnabled="{Binding CanWrite}"/>
</DockPanel>
<ListView x:Name="GridViewHost"
ItemsSource="{Binding Rows}"
SelectedItem="{Binding SelectedRow}"
MouseDoubleClick="OnRowDoubleClick">
<ListView.View>
<GridView x:Name="ResultGrid"/>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,159 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Input;
using Explorer.Presentation.ViewModels;
using Microsoft.Win32;
namespace Explorer.App;
public partial class DatabaseWindow : Window
{
public DatabaseWindow(DatabaseViewerViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
vm.GridChanged += (_, _) => RebuildColumns();
ModelessWindowClose.EnableEscape(this);
Closed += async (_, _) => await vm.DisposeAsync().ConfigureAwait(true);
Loaded += async (_, _) => await vm.OpenIndexAsync(preferWrite: false).ConfigureAwait(true);
}
public DatabaseViewerViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void RebuildColumns()
{
ResultGrid.Columns.Clear();
for (var i = 0; i < ViewModel.Columns.Count; i++)
{
var index = i;
var header = ViewModel.Columns[i];
ResultGrid.Columns.Add(new GridViewColumn
{
Header = header,
Width = header.Equals("_rowid_", StringComparison.OrdinalIgnoreCase) ? 70 : 140,
DisplayMemberBinding = new Binding($"Cells[{index}]")
});
}
}
private void OnOpenFile(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog
{
Title = "Open SQLite database",
Filter = "SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*"
};
if (dlg.ShowDialog(this) == true)
{
var write = MessageBox.Show(
this,
"Open for writing? Choose No for read-only.",
"Database",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question);
if (write == MessageBoxResult.Cancel)
{
return;
}
_ = ViewModel.OpenPathAsync(dlg.FileName, preferWrite: write == MessageBoxResult.Yes);
}
}
private async void OnEditCell(object sender, RoutedEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await EditSelectedCellAsync().ConfigureAwait(true);
private async Task EditSelectedCellAsync()
{
if (!ViewModel.CanWrite || ViewModel.SelectedRow is null || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
return;
}
var column = Prompt("Column to edit", string.Join(", ", columns.Take(8)) + (columns.Count > 8 ? "…" : ""), columns[0]);
if (column is null || !columns.Contains(column, StringComparer.OrdinalIgnoreCase))
{
return;
}
var index = ViewModel.Columns.ToList().FindIndex(c => c.Equals(column, StringComparison.OrdinalIgnoreCase));
var current = index >= 0 && index < ViewModel.SelectedRow.Cells.Count
? ViewModel.SelectedRow.Cells[index]
: "";
var next = Prompt("New value for " + column, "Leave empty for NULL.", current);
if (next is null)
{
return;
}
try
{
await ViewModel.UpdateSelectedCellAsync(column, string.IsNullOrEmpty(next) ? null : next)
.ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async void OnInsertRow(object sender, RoutedEventArgs e)
{
if (!ViewModel.CanWrite || string.IsNullOrWhiteSpace(ViewModel.SelectedTable))
{
return;
}
var columns = ViewModel.EditableColumns();
if (columns.Count == 0)
{
MessageBox.Show(this, "Load a table first so columns are known.", "Database");
return;
}
var values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var column in columns)
{
var value = Prompt("Value for " + column, "Cancel skips this column. Empty = NULL.", "");
if (value is null)
{
continue;
}
values[column] = string.IsNullOrEmpty(value) ? null : value;
}
if (values.Count == 0)
{
return;
}
try
{
await ViewModel.InsertRowAsync(values).ConfigureAwait(true);
}
catch (Exception ex)
{
MessageBox.Show(this, ex.Message, "Database", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private string? Prompt(string title, string message, string initial)
{
var dlg = new PromptWindow(title, message, initial) { Owner = this };
return dlg.ShowDialog() == true ? dlg.Value : null;
}
}

View File

@@ -0,0 +1,59 @@
using System.Windows;
using System.Windows.Controls;
namespace Explorer.App;
public static class DetailsColumnLayout
{
public static readonly DependencyProperty ShowFreeSpaceProperty =
DependencyProperty.RegisterAttached(
"ShowFreeSpace",
typeof(bool),
typeof(DetailsColumnLayout),
new PropertyMetadata(true, OnShowFreeSpaceChanged));
public static void SetShowFreeSpace(ListView element, bool value)
=> element.SetValue(ShowFreeSpaceProperty, value);
public static bool GetShowFreeSpace(ListView element)
=> (bool)element.GetValue(ShowFreeSpaceProperty);
private static void OnShowFreeSpaceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ListView list)
{
Apply(list);
list.Loaded -= OnLoaded;
list.Loaded += OnLoaded;
}
}
private static void OnLoaded(object sender, RoutedEventArgs e)
{
if (sender is ListView list)
{
Apply(list);
}
}
private static void Apply(ListView list)
{
if (list.View is not GridView view)
{
return;
}
var show = GetShowFreeSpace(list);
foreach (var column in view.Columns)
{
var title = column.Header?.ToString() ?? "";
title = title.Replace(" ▲", "", StringComparison.Ordinal).Replace(" ▼", "", StringComparison.Ordinal).Trim();
if (title == "Free space")
{
column.Width = show ? 110 : 0;
}
}
ListViewLayout.Stretch(list);
}
}

View File

@@ -9,9 +9,9 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="16,8,16,16">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
x:Name="OpenFileButton" Margin="8,0,0,0"/>
x:Name="OpenFileButton" IsEnabled="False" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Reload" MinWidth="88" Height="32" Click="OnReload" Margin="8,0,0,0"/>
<TextBlock x:Name="SourceLabel" VerticalAlignment="Center" TextWrapping="Wrap"
Foreground="{DynamicResource FgMuted}"/>

View File

@@ -11,14 +11,19 @@ public partial class DocumentationWindow : Window
{
private FlowDocument? _document;
private bool _suppressToc;
private int _renderGeneration;
public DocumentationWindow()
{
InitializeComponent();
Loaded += (_, _) => Render();
ModelessWindowClose.EnableEscape(this);
Loaded += async (_, _) => await RenderAsync().ConfigureAwait(true);
}
private void OnReload(object sender, RoutedEventArgs e) => Render();
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnReload(object sender, RoutedEventArgs e)
=> await RenderAsync().ConfigureAwait(true);
private void OnOpenFile(object sender, RoutedEventArgs e)
{
@@ -44,10 +49,35 @@ public partial class DocumentationWindow : Window
}
}
private void Render()
private async Task RenderAsync()
{
var loaded = DocumentationLoader.Load();
var parsed = MarkdownParser.Parse(loaded.Markdown);
var generation = Interlocked.Increment(ref _renderGeneration);
SourceLabel.Text = "Loading…";
LoadedDocumentation loaded;
MarkdownDocument parsed;
try
{
(loaded, parsed) = await Task.Run(() =>
{
var document = DocumentationLoader.Load();
return (document, MarkdownParser.Parse(document.Markdown));
}).ConfigureAwait(true);
}
catch (Exception ex)
{
if (generation == _renderGeneration)
{
SourceLabel.Text = "Could not load documentation: " + ex.Message;
}
return;
}
if (generation != _renderGeneration || !IsLoaded)
{
return;
}
var brushes = new DocumentationBrushes(
Brush("Fg"),
Brush("FgMuted"),

View File

@@ -22,21 +22,26 @@
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
<ProjectReference Include="..\Explorer.Plugin.GoogleDrive\Explorer.Plugin.GoogleDrive.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Nextcloud\Explorer.Plugin.Nextcloud.csproj" />
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
<ProjectReference Include="..\Explorer.Host\Explorer.Host.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<GlobalPropertiesToRemove>SelfContained;RuntimeIdentifier;PublishSingleFile</GlobalPropertiesToRemove>
</ProjectReference>
<ProjectReference Include="..\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
</ItemGroup>
<Target Name="CopyExplorerHost" AfterTargets="Build">
<PropertyGroup>
<_HostDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\Explorer.Host\bin\$(Configuration)\net10.0-windows\'))</_HostDir>
</PropertyGroup>
<ItemGroup>
<_HostFiles Include="$(_HostDir)*.*" Condition="Exists('$(_HostDir)Explorer.Host.exe')" />
</ItemGroup>
<Copy SourceFiles="@(_HostFiles)" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" Condition="'@(_HostFiles)' != ''" />
</Target>
</Project>

View 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="&#xE977;" 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>

View 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;
}
}

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="32" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Commit…" MinWidth="88" Height="32"
Click="OnCommit" IsEnabled="{Binding CanCommit}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Push" MinWidth="72" Height="32"
@@ -34,6 +34,9 @@
<Button Content="Open in Cursor" MinWidth="120" Height="32"
Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}" Margin="0,0,8,8"/>
<Button Content="Open in Notepad++" MinWidth="140" Height="32"
Command="{Binding OpenSelectedInNotepadPlusPlusCommand}"
IsEnabled="{Binding CanOpenInCursor}" Margin="0,0,8,8"/>
<Button Content="Stage" MinWidth="72" Height="32" Click="OnStage"
IsEnabled="{Binding CanStage}" Margin="0,0,8,8"/>
<Button Content="Unstage" MinWidth="72" Height="32" Click="OnUnstage"
@@ -58,6 +61,8 @@
<MenuItem Header="View diff" Click="OnDiff" IsEnabled="{Binding CanDiff}"/>
<MenuItem Header="Open in Cursor" Command="{Binding OpenSelectedInCursorCommand}"
IsEnabled="{Binding CanOpenInCursor}"/>
<MenuItem Header="Open in Notepad++" Command="{Binding OpenSelectedInNotepadPlusPlusCommand}"
IsEnabled="{Binding CanOpenInCursor}"/>
<Separator/>
<MenuItem Header="Stage" Click="OnStage" IsEnabled="{Binding CanStage}"/>
<MenuItem Header="Unstage" Click="OnUnstage" IsEnabled="{Binding CanUnstage}"/>

View File

@@ -12,10 +12,13 @@ public partial class GitChangesWindow : Window
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
}
public GitChangesViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private async void OnRowDoubleClick(object sender, MouseButtonEventArgs e)
=> await ShowDiffAsync().ConfigureAwait(true);

View File

@@ -9,7 +9,7 @@
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
IsCancel="True" Margin="0,12,0,0"/>
Click="OnClose" Margin="0,12,0,0"/>
<TextBlock DockPanel.Dock="Top" Text="{Binding EmptyText}" Margin="0,0,0,8"
Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"
Visibility="{Binding ShowEmpty, Converter={StaticResource BoolVis}}"/>

View File

@@ -10,5 +10,8 @@ public partial class GitDiffWindow : Window
InitializeComponent();
DataContext = vm;
Title = vm.Title;
ModelessWindowClose.EnableEscape(this);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
}

View File

@@ -0,0 +1,130 @@
<Window x:Class="Explorer.App.HostActivityMonitorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Host activity"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="960"
MinHeight="420" MinWidth="720"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="CanResize"
Background="{DynamicResource Bg}"
Foreground="{DynamicResource Fg}"
UseLayoutRounding="True"
SnapsToDevicePixels="True">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome CaptionHeight="40"
ResizeBorderThickness="6"
GlassFrameThickness="0"
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<DockPanel>
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
MouseLeftButtonDown="OnTitleBarMouseDown">
<Grid>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False">
<Image Width="20" Height="20" Margin="0,0,8,0" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<TextBlock Text="Host activity" FontWeight="SemiBold" VerticalAlignment="Center"
Foreground="{DynamicResource Fg}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CaptionButton}" Content="─" Click="OnMinimize" ToolTip="Minimize"/>
<Button x:Name="MaxRestoreButton" Style="{StaticResource CaptionButton}" Content="☐"
Click="OnMaxRestore" ToolTip="Maximize"/>
<Button Style="{StaticResource CaptionCloseButton}" Content="✕" Click="OnClose" ToolTip="Close"/>
</StackPanel>
</Grid>
</Border>
<DockPanel Margin="14">
<DockPanel DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="Close" MinWidth="88" Height="30" Click="OnClose" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Run maintenance now" MinWidth="140" Height="30"
Command="{Binding RunMaintenanceNowCommand}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Refresh" MinWidth="88" Height="30"
Command="{Binding RefreshCommand}" Margin="8,0,0,0"/>
<TextBlock VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<Run Text="{Binding Status, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding IsLive, Mode=OneWay, StringFormat=Live: {0}}"/>
</TextBlock>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Summary}" FontWeight="SemiBold" FontSize="15"
TextWrapping="Wrap" Margin="0,0,0,10"/>
<UniformGrid DockPanel.Dock="Top" Rows="2" Columns="2" Margin="0,0,0,10">
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,0,6,6">
<StackPanel>
<TextBlock Text="Maintenance" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding MaintenanceText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,0,0,6">
<StackPanel>
<TextBlock Text="Indexing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding IndexingText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="0,6,6,0">
<StackPanel>
<TextBlock Text="Hashing" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding HashText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="10" Margin="6,6,0,0">
<StackPanel>
<TextBlock Text="Transfers" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
<TextBlock Text="{Binding TransferText}" TextWrapping="Wrap" Margin="0,4,0,0"/>
</StackPanel>
</Border>
</UniformGrid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="10"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Current indexing jobs" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView ItemsSource="{Binding Jobs}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListView.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,2">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Detail}" Foreground="{DynamicResource FgMuted}" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding Origin}" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
</StackPanel>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</DockPanel>
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="{Binding LogCaption}" FontWeight="SemiBold" Margin="0,0,0,6"/>
<ListView x:Name="EventList" ItemsSource="{Binding Events}" BorderBrush="{DynamicResource Stroke}"
ScrollViewer.ScrollChanged="OnEventScrollChanged"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling">
<ListView.View>
<GridView>
<GridViewColumn Header="Time" Width="70" DisplayMemberBinding="{Binding Time}"/>
<GridViewColumn Header="Area" Width="100" DisplayMemberBinding="{Binding Category}"/>
<GridViewColumn Header="Detail" Width="440" DisplayMemberBinding="{Binding Message}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,139 @@
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 HostActivityMonitorWindow : Window
{
private bool _stickToEnd = true;
private bool _scrollQueued;
private ScrollViewer? _eventScroll;
public HostActivityMonitorWindow(HostActivityMonitorViewModel vm)
{
InitializeComponent();
DataContext = vm;
ViewModel = vm;
ModelessWindowClose.EnableEscape(this);
StateChanged += (_, _) => SyncMaxRestoreButton();
vm.EventsReplaced += OnEventsReplaced;
Closed += (_, _) =>
{
vm.EventsReplaced -= OnEventsReplaced;
vm.Dispose();
};
Loaded += (_, _) =>
{
SyncMaxRestoreButton();
_eventScroll = FindScrollViewer(EventList);
vm.Start();
};
}
public HostActivityMonitorViewModel ViewModel { get; }
private void OnClose(object sender, RoutedEventArgs e) => Close();
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton != MouseButton.Left)
{
return;
}
if (e.ClickCount == 2)
{
ToggleMaximized();
return;
}
DragMove();
}
private void ToggleMaximized()
{
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
SyncMaxRestoreButton();
}
private void SyncMaxRestoreButton()
=> MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
private void OnEventScrollChanged(object sender, ScrollChangedEventArgs e)
{
if (e.OriginalSource is not ScrollViewer viewer)
{
return;
}
_eventScroll = viewer;
if (e.ExtentHeightChange != 0)
{
return;
}
// Only treat intentional user scroll as leaving the live tail.
if (e.VerticalChange == 0)
{
return;
}
_stickToEnd = viewer.VerticalOffset >= viewer.ScrollableHeight - 8;
}
private void OnEventsReplaced(object? sender, EventArgs e)
{
if (!_stickToEnd || _scrollQueued)
{
return;
}
_scrollQueued = true;
Dispatcher.BeginInvoke(() =>
{
_scrollQueued = false;
if (!_stickToEnd)
{
return;
}
_eventScroll ??= FindScrollViewer(EventList);
if (_eventScroll is not null)
{
_eventScroll.ScrollToEnd();
}
else if (EventList.Items.Count > 0)
{
EventList.ScrollIntoView(EventList.Items[^1]);
}
}, DispatcherPriority.Background);
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer scroll)
{
return scroll;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var child = VisualTreeHelper.GetChild(root, i);
var found = FindScrollViewer(child);
if (found is not null)
{
return found;
}
}
return null;
}
}

View 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);
}
}
}

View File

@@ -23,6 +23,98 @@
CornerRadius="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Window.Resources>
<ContextMenu x:Key="FolderListContextMenu" x:Shared="false">
<MenuItem Header="Open" Click="OnCtxOpen"/>
<Separator Tag="ShellVerbAnchor" Visibility="Collapsed"/>
<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="Tags…" Click="OnTags"
Visibility="{Binding ShowTags, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Move to…" Click="OnMoveTo"
Visibility="{Binding ShowMoveTo, 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}}"/>
<MenuItem Header="Classify as" Visibility="{Binding ShowClassifyAs, Converter={StaticResource BoolVis}}">
<MenuItem Header="Photos" Command="{Binding ClassifyAsCommand}" CommandParameter="Photos"/>
<MenuItem Header="Video" Command="{Binding ClassifyAsCommand}" CommandParameter="Video"/>
<MenuItem Header="Audio" Command="{Binding ClassifyAsCommand}" CommandParameter="Audio"/>
<MenuItem Header="Documents" Command="{Binding ClassifyAsCommand}" CommandParameter="Documents"/>
<MenuItem Header="Installer" Command="{Binding ClassifyAsCommand}" CommandParameter="Installer"/>
<MenuItem Header="Archive" Command="{Binding ClassifyAsCommand}" CommandParameter="Archive"/>
<MenuItem Header="Backup" Command="{Binding ClassifyAsCommand}" CommandParameter="Backup"/>
<MenuItem Header="Code repository" Command="{Binding ClassifyAsCommand}" CommandParameter="CodeRepository"/>
<MenuItem Header="Unknown" Command="{Binding ClassifyAsCommand}" CommandParameter="Unknown"/>
</MenuItem>
<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="Open in Notepad++" Command="{Binding OpenInNotepadPlusPlusCommand}"
Visibility="{Binding ShowOpenInNotepadPlusPlus, 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"
@@ -47,6 +139,8 @@
<MenuItem Header="New _tab" InputGestureText="Ctrl+T" Command="{Binding NewTabCommand}"/>
<MenuItem Header="_Split pane" Command="{Binding SplitCommand}"/>
<MenuItem Header="_Close tab" InputGestureText="Ctrl+W" Command="{Binding CloseTabCommand}" CommandParameter="{Binding ActiveTab}"/>
<Separator/>
<MenuItem Header="Stop _background host…" Click="OnStopBackgroundHost"/>
</MenuItem>
<MenuItem Header="_View">
<MenuItem Header="_Details" Command="{Binding SetViewCommand}" CommandParameter="Details"/>
@@ -59,6 +153,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}"/>
@@ -69,6 +164,10 @@
</MenuItem>
<MenuItem Header="_File Operations">
<MenuItem Header="_Batch rename…" Click="OnBatchRename"/>
<MenuItem Header="_Tags…" Click="OnTags"
IsEnabled="{Binding ShowTags}"/>
<MenuItem Header="_Move to…" Click="OnMoveTo"
IsEnabled="{Binding ShowMoveTo}"/>
<MenuItem Header="_Undo last rename batch" Command="{Binding UndoRenameBatchCommand}"
IsEnabled="{Binding CanUndoRenameBatch}"/>
<Separator/>
@@ -86,6 +185,8 @@
<MenuItem Header="_Verify archive" Click="OnVerifyArchive"
IsEnabled="{Binding ShowVerifyArchive}"/>
</MenuItem>
<MenuItem Header="_Convert…" Click="OnConvert"
IsEnabled="{Binding ShowConvert}"/>
<MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/>
</MenuItem>
<MenuItem Header="_Automation">
@@ -93,6 +194,9 @@
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="_Host activity…" Click="OnHostActivity"/>
<MenuItem Header="_Database…" Click="OnDatabase"/>
<Separator/>
<MenuItem Header="View _changes…" Click="OnGitChanges"
IsEnabled="{Binding ShowGitActions}"/>
<MenuItem Header="_Commit…" Click="OnGitCommit"
@@ -111,6 +215,8 @@
IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
IsEnabled="{Binding ShowOpenInCursor}"/>
<MenuItem Header="Open in _Notepad++" Command="{Binding OpenInNotepadPlusPlusCommand}"
IsEnabled="{Binding ShowOpenInNotepadPlusPlus}"/>
</MenuItem>
<MenuItem Header="_Recycle Bin">
<MenuItem Header="_Open Recycle Bin" Click="OnOpenRecycleBin"/>
@@ -180,12 +286,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}"/>
@@ -196,12 +298,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}}"/>
@@ -263,7 +368,7 @@
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
Text="Copy, move, delete, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
Text="Copy, move, delete, convert, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}">
@@ -359,6 +464,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>
@@ -401,8 +508,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>
@@ -413,6 +519,9 @@
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -430,87 +539,33 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Date created" Width="148" DisplayMemberBinding="{Binding CreatedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Category" Width="110">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding CategoryLabel}" ToolTip="{Binding CategoryTooltip}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Git" Width="110" DisplayMemberBinding="{Binding GitLabel}"/>
<GridViewColumn Header="Cloud" Width="100" DisplayMemberBinding="{Binding CloudLabel}"/>
<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}}"/>
<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"
@@ -532,11 +587,14 @@
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithOverlays}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
@@ -581,8 +639,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>
@@ -593,6 +650,9 @@
</Border>
<Grid>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -610,19 +670,33 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:DetailsColumnLayout.ShowFreeSpace="{Binding ShowFreeSpaceColumn}"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
<GridViewColumn Header="Date created" Width="148" DisplayMemberBinding="{Binding CreatedLabel}"/>
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
<GridViewColumn Header="Category" Width="110">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding CategoryLabel}" ToolTip="{Binding CategoryTooltip}"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Git" Width="110" DisplayMemberBinding="{Binding GitLabel}"/>
<GridViewColumn Header="Cloud" Width="100" DisplayMemberBinding="{Binding CloudLabel}"/>
<GridViewColumn Header="Free space" Width="110" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
MouseDoubleClick="OnItemDoubleClick"
SelectionChanged="OnSelectionChanged"
AllowDrop="True"
@@ -644,11 +718,14 @@
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithOverlays}"/>
</GridView>
</ListView.View>
</ListView>
<ListView ItemsSource="{Binding Items}"
SelectionMode="Extended"
IsSynchronizedWithCurrentItem="False"
ContextMenu="{StaticResource FolderListContextMenu}"
ItemTemplate="{StaticResource PreviewTile}"
ItemContainerStyle="{StaticResource IconListItem}"
MouseDoubleClick="OnItemDoubleClick"
@@ -716,6 +793,11 @@
<TextBlock Grid.Row="1" Text="Extension" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,16,0">
<TextBox Width="90" Text="{Binding Search.Extension, UpdateSourceTrigger=PropertyChanged}"/>
<ComboBox Width="140" Margin="8,0,0,0"
ItemsSource="{Binding Search.CategoryChoices}"
DisplayMemberPath="Label"
SelectedValuePath="Value"
SelectedValue="{Binding Search.Category}"/>
<CheckBox Content="Files" Margin="16,0,8,0" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
IsChecked="{Binding Search.FilesOnly}"/>
<CheckBox Content="Folders" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
@@ -741,8 +823,9 @@
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithOverlays}"/>
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
<GridViewColumn Header="Category" Width="110" DisplayMemberBinding="{Binding CategoryLabel}"/>
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
<GridViewColumn Header="Free space" Width="100" DisplayMemberBinding="{Binding FreeSpaceLabel}"/>
</GridView>
@@ -937,68 +1020,94 @@
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1"
Margin="48" Padding="16" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Duplicates.CloseCommand}"/>
<TextBlock Text="Duplicates" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Grid.Row="1" Text="{Binding Duplicates.Status}" Foreground="{DynamicResource Fg}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,0,0,8">
<CheckBox Content="Show intentional" IsChecked="{Binding Duplicates.ShowIntentional}" Margin="0,0,16,0"/>
<CheckBox Content="Show hard links" IsChecked="{Binding Duplicates.ShowHardlinks}"/>
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
<ProgressBar Grid.Row="3" Margin="0,0,0,8" IsIndeterminate="True"
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding Summary, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ListBox Grid.Row="4"
ItemsSource="{Binding Duplicates.Groups}"
Background="Transparent"
BorderThickness="0"
Padding="0"
HorizontalContentAlignment="Stretch"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Visible"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter HorizontalAlignment="Stretch"/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Border>
</Grid>
</DockPanel>
</AdornerDecorator>
</Window>

View File

@@ -8,6 +8,7 @@ using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
@@ -17,10 +18,18 @@ public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private GitChangesWindow? _gitChanges;
private HostActivityMonitorWindow? _hostActivity;
private DatabaseWindow? _database;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
private FolderItemViewModel? _dragItem;
private ListView? _dragList;
private bool _draggingFromHere;
private FolderItemViewModel[] _dragSelection = [];
private bool _dragFromMultiSelect;
private bool _syncingSelection;
private readonly ListMarquee _marquee = new();
private bool _suppressItemContextMenu;
private bool _incomingRightDrag;
private bool _sourceRightDrag;
@@ -34,6 +43,8 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
SourceInitialized += (_, _) => MaximizedWorkArea.Hook(this);
StateChanged += (_, _) => SyncMaxRestoreButton();
DataContextChanged += (_, _) =>
{
if (_wiredVm is not null)
@@ -186,34 +197,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 +228,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
@@ -546,8 +570,11 @@ public partial class MainWindow : Window
{
"Name" => "Name",
"Date modified" => "Modified",
"Date created" => "Created",
"Type" => "Type",
"Size" => "Size",
"Git" => "Git",
"Cloud" => "Cloud",
"Free space" => "Free",
_ => null
};
@@ -560,12 +587,17 @@ public partial class MainWindow : Window
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not ListView list)
if (_syncingSelection || sender is not ListView { IsVisible: true } list)
{
return;
}
ActivatePaneFromList(list);
if (_dragFromMultiSelect && list == _dragList && _dragSelection.Length > 1)
{
RestoreListSelection(list, _dragSelection);
}
if (_clickRenameItem is not null && !list.SelectedItems.Contains(_clickRenameItem))
{
CancelClickRename();
@@ -580,6 +612,26 @@ public partial class MainWindow : Window
Vm.RefreshCloudActions();
}
private void RestoreListSelection(ListView list, IReadOnlyList<FolderItemViewModel> items)
{
_syncingSelection = true;
try
{
list.SelectedItems.Clear();
foreach (var item in items)
{
if (list.Items.Contains(item))
{
list.SelectedItems.Add(item);
}
}
}
finally
{
_syncingSelection = false;
}
}
private void OnPaneFocus(object sender, RoutedEventArgs e)
{
if (sender is ListView list)
@@ -603,11 +655,47 @@ public partial class MainWindow : Window
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
{
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject))
{
_dragPending = false;
_marquee.Disarm();
CancelClickRename();
return;
}
_suppressItemContextMenu = false;
_dragStart = e.GetPosition(null);
_dragStart = e.GetPosition(this);
_dragPending = true;
_dragButton = e.ChangedButton;
_dragList = sender as ListView;
_dragItem = HitTestFolderItem(sender as DependencyObject, e.GetPosition((IInputElement)sender));
_dragSelection = _dragList is not null
? _dragList.SelectedItems.OfType<FolderItemViewModel>().ToArray()
: [];
var additive = (Keyboard.Modifiers & (ModifierKeys.Control | ModifierKeys.Shift)) != 0;
_dragFromMultiSelect = !additive
&& _dragItem is not null
&& _dragSelection.Length > 1
&& Array.IndexOf(_dragSelection, _dragItem) >= 0;
if (_dragFromMultiSelect && e.ChangedButton == MouseButton.Left && _dragList is not null)
{
e.Handled = true;
_dragList.Focus();
_dragList.CaptureMouse();
}
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);
}
@@ -623,6 +711,15 @@ public partial class MainWindow : Window
if (sender is FrameworkElement { ContextMenu: { } menu })
{
menu.DataContext = DataContext;
try
{
FillShellContextVerbs(menu);
}
catch (Exception)
{
// Shell extras must not take down the Workbench menu.
}
_ = FillRunProfileMenuAsync(menu);
}
}
@@ -645,11 +742,12 @@ public partial class MainWindow : Window
&& dest is not null
&& NavigationTreeViewModel.PathsEqual(hover.FullPath, dest)
&& !DragDropPolicy.IsInvalidTarget(files, dest)
&& !IsRedundantLeftDrop(files, dest)
? hover
: null;
SetListDropTarget(folderTarget);
SetTreeDropTarget(null);
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest))
if (dest is null || DragDropPolicy.IsInvalidTarget(files, dest) || IsRedundantLeftDrop(files, dest))
{
e.Effects = DragDropEffects.None;
e.Handled = true;
@@ -692,6 +790,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -729,32 +832,61 @@ 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;
}
var pos = e.GetPosition(null);
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
if (IsInsideInlineRenameBox(e.OriginalSource as DependencyObject)
|| _dragItem is { IsRenaming: true })
{
_dragPending = false;
return;
}
var pos = e.GetPosition(this);
if (!DragMovedEnough(pos, DragDropPolicy.DragStartDistance))
{
return;
}
_dragPending = false;
CancelClickRename();
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
IReadOnlyList<string> paths;
if (_dragItem is not null && (selected.Count == 0
|| selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
if (_dragList is { IsMouseCaptured: true })
{
paths = [_dragItem.FullPath];
}
else
{
paths = selected;
_dragList.ReleaseMouseCapture();
}
var paths = DragPaths();
_dragFromMultiSelect = false;
if (paths.Count == 0)
{
return;
@@ -763,10 +895,18 @@ public partial class MainWindow : Window
_sourceRightDrag = _dragButton == MouseButton.Right;
_suppressItemContextMenu = _sourceRightDrag;
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
_sourceRightDrag = false;
_incomingRightDrag = false;
ClearDropTargets();
_draggingFromHere = true;
try
{
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move | DragDropEffects.Link);
}
finally
{
_draggingFromHere = false;
_sourceRightDrag = false;
_incomingRightDrag = false;
ClearDropTargets();
}
}
protected override void OnQueryContinueDrag(QueryContinueDragEventArgs e)
@@ -784,13 +924,98 @@ 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)
{
base.OnPreviewMouseUp(e);
if (e.ChangedButton == _dragButton)
if (CompleteMarqueeMouseUp(e))
{
_dragPending = false;
return;
}
base.OnPreviewMouseUp(e);
if (e.ChangedButton != _dragButton)
{
return;
}
if (_dragFromMultiSelect && _dragPending && _dragList is not null && _dragItem is not null)
{
_dragFromMultiSelect = false;
_dragList.SelectedItems.Clear();
_dragList.SelectedItem = _dragItem;
}
_dragPending = false;
_dragFromMultiSelect = false;
_marquee.Disarm();
if (_dragList is { IsMouseCaptured: true })
{
_dragList.ReleaseMouseCapture();
}
}
private IReadOnlyList<string> DragPaths()
{
if (_dragFromMultiSelect && _dragSelection.Length > 0)
{
return _dragSelection.Select(i => i.FullPath).ToList();
}
var selected = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
if (_dragItem is not null && (selected.Count == 0
|| selected.TrueForAll(p => !p.Equals(_dragItem.FullPath, StringComparison.OrdinalIgnoreCase))))
{
return [_dragItem.FullPath];
}
return selected;
}
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)
@@ -808,16 +1033,58 @@ public partial class MainWindow : Window
private string? ResolveDropDirectory(ListView list, DragEventArgs e)
{
var item = HitTestFolderItem(list, e.GetPosition(list));
if (item is { IsDirectory: true })
if (!_draggingFromHere)
{
if (item is { IsDirectory: true })
{
return item.FullPath;
}
return CurrentListDirectory(list);
}
var pos = e.GetPosition(this);
if (item is { IsDirectory: true }
&& DragMovedEnough(pos, FolderDropDistance(list)))
{
return item.FullPath;
}
if (!ReferenceEquals(list, _dragList)
&& !DragMovedEnough(pos, DragDropPolicy.DropCrossViewDistance))
{
return null;
}
return CurrentListDirectory(list);
}
private string? CurrentListDirectory(ListView list)
{
var tab = Vm.ActiveTab;
var path = list.ItemsSource == tab.Right.Items ? tab.Right.CurrentPath : tab.Left.CurrentPath;
return LocationRoots.IsVirtual(path) ? null : path;
}
private double FolderDropDistance(ListView list)
=> ReferenceEquals(list, _dragList)
? DragDropPolicy.DropIntoItemDistance
: DragDropPolicy.DropCrossViewDistance;
private bool DragMovedEnough(Point current, double preferred)
=> DragDropPolicy.ExceedsDistance(
current.X - _dragStart.X,
current.Y - _dragStart.Y,
Math.Max(preferred, SystemDragMinimum));
private static double SystemDragMinimum
=> Math.Max(SystemParameters.MinimumHorizontalDragDistance, SystemParameters.MinimumVerticalDragDistance);
private bool IsRedundantLeftDrop(IReadOnlyList<string> files, string dest)
=> !_sourceRightDrag
&& !_incomingRightDrag
&& DragDropPolicy.AllAlreadyInDirectory(files, dest);
private static FolderItemViewModel? HitTestFolderItem(DependencyObject? origin, Point point)
{
if (origin is not Visual visual)
@@ -909,6 +1176,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 +1212,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))
{
@@ -951,6 +1236,11 @@ public partial class MainWindow : Window
return;
}
if (IsRedundantLeftDrop(files, dest))
{
return;
}
await ApplyDropAsync(files, dest, ResolveDropAction(e, files, dest)).ConfigureAwait(true);
}
@@ -965,6 +1255,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);
@@ -1152,6 +1445,36 @@ public partial class MainWindow : Window
}
}
private void OnTags(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateTagRenameViewModel();
if (vm is null)
{
return;
}
var dlg = new TagRenameWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Tag or rename work queued.";
}
}
private void OnMoveTo(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateMoveToViewModel();
if (vm is null)
{
return;
}
var dlg = new MoveToWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Move queued.";
}
}
private async void OnExtractHere(object sender, RoutedEventArgs e)
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
@@ -1195,6 +1518,21 @@ public partial class MainWindow : Window
private async void OnVerifyArchive(object sender, RoutedEventArgs e)
=> await Vm.VerifySelectedAsync().ConfigureAwait(true);
private void OnConvert(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateConvertViewModel();
if (vm is null)
{
return;
}
var dlg = new ConvertWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Convert queued.";
}
}
private async void OnFolderSync(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateFolderSyncViewModel();
@@ -1218,6 +1556,66 @@ public partial class MainWindow : Window
dlg.ShowDialog();
}
private void FillShellContextVerbs(ContextMenu menu)
{
const string anchor = "ShellVerbAnchor";
const string tagPrefix = "shell:";
for (var i = menu.Items.Count - 1; i >= 0; i--)
{
if (menu.Items[i] is FrameworkElement { Tag: string tag }
&& tag.StartsWith(tagPrefix, StringComparison.Ordinal))
{
menu.Items.RemoveAt(i);
}
}
var anchorItem = menu.Items.OfType<Separator>().FirstOrDefault(item => Equals(item.Tag, anchor));
if (anchorItem is null)
{
return;
}
var verbs = Vm.ListShellContextVerbs();
anchorItem.Visibility = verbs.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
if (verbs.Count == 0)
{
return;
}
var index = menu.Items.IndexOf(anchorItem) + 1;
foreach (var verb in verbs)
{
menu.Items.Insert(index++, BuildShellMenuItem(verb));
}
}
private MenuItem BuildShellMenuItem(ShellContextVerb verb)
{
var item = new MenuItem { Header = verb.Label, Tag = "shell:" + verb.Id };
if (verb.Children is { Count: > 0 })
{
foreach (var child in verb.Children)
{
item.Items.Add(BuildShellMenuItem(child));
}
return item;
}
item.Click += OnShellContextVerb;
return item;
}
private void OnShellContextVerb(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: string tag } || !tag.StartsWith("shell:", StringComparison.Ordinal))
{
return;
}
Vm.InvokeShellContextVerb(tag["shell:".Length..]);
}
private async Task FillRunProfileMenuAsync(ContextMenu menu)
{
var host = menu.Items.OfType<MenuItem>().FirstOrDefault(i => Equals(i.Tag, "RunProfileMenu"));
@@ -1474,6 +1872,33 @@ public partial class MainWindow : Window
return null;
}
private async void OnStopBackgroundHost(object sender, RoutedEventArgs e)
{
if (!Vm.CanStopBackgroundHost)
{
MessageBox.Show(
this,
"The background host is not connected.",
"Explorer Workbench",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var confirm = MessageBox.Show(
this,
"Stop the background host? Indexing and the file operations queue will stop until you start Explorer Workbench again.",
"Explorer Workbench",
MessageBoxButton.OKCancel,
MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK)
{
return;
}
await Vm.StopBackgroundHostAsync().ConfigureAwait(true);
}
private async void OnOpenSettings(object sender, RoutedEventArgs e)
{
var dlg = new SettingsWindow(Vm) { Owner = this };
@@ -1486,6 +1911,48 @@ public partial class MainWindow : Window
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private void OnHostActivity(object sender, RoutedEventArgs e)
{
if (_hostActivity is { IsVisible: true })
{
_hostActivity.Activate();
return;
}
var vm = Vm.CreateHostActivityMonitorViewModel();
if (vm is null)
{
MessageBox.Show(this, "Host activity is unavailable until the background host is connected.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_hostActivity = new HostActivityMonitorWindow(vm) { Owner = this };
_hostActivity.Closed += (_, _) => _hostActivity = null;
_hostActivity.Show();
}
private void OnDatabase(object sender, RoutedEventArgs e)
{
if (_database is { IsVisible: true })
{
_database.Activate();
return;
}
var vm = Vm.CreateDatabaseViewerViewModel();
if (vm is null)
{
MessageBox.Show(this, "Database tools are unavailable.",
"Explorer Workbench", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_database = new DatabaseWindow(vm) { Owner = this };
_database.Closed += (_, _) => _database = null;
_database.Show();
}
private async void OnGitChanges(object sender, RoutedEventArgs e)
{
if (_gitChanges is { IsVisible: true })
@@ -1663,6 +2130,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);
@@ -1699,6 +2203,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);
@@ -1729,4 +2238,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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,23 @@
using System.Windows;
using System.Windows.Input;
namespace Explorer.App;
/// <summary>
/// Close helpers for windows opened with <see cref="Window.Show"/>.
/// <see cref="Button.IsCancel"/> sets <see cref="Window.DialogResult"/>, which throws on modeless windows.
/// </summary>
internal static class ModelessWindowClose
{
public static void EnableEscape(Window window)
=> window.PreviewKeyDown += (_, e) =>
{
if (e.Key != Key.Escape)
{
return;
}
window.Close();
e.Handled = true;
};
}

View File

@@ -0,0 +1,66 @@
<Window x:Class="Explorer.App.MoveToWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Move to"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="560" Width="820"
MinHeight="420" MinWidth="640"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<TextBlock Text="Destination pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="PatternBox" MinHeight="32" VerticalContentAlignment="Center" Padding="8,4"
Text="{Binding Pattern, UpdateSourceTrigger=PropertyChanged}"/>
<Button Grid.Column="1" Content="Browse…" MinWidth="88" Height="32" Click="OnBrowse" Margin="8,0,0,0"/>
<Button Grid.Column="2" Content="Save" MinWidth="72" Height="32" Command="{Binding SavePatternCommand}" Margin="8,0,0,0"/>
</Grid>
<Grid Margin="0,0,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Text="Recent" VerticalAlignment="Center" Margin="0,0,8,0" Foreground="{DynamicResource FgMuted}"/>
<ComboBox Grid.Column="1" MinHeight="28" ItemsSource="{Binding PatternChoices}"
SelectedItem="{Binding SelectedChoice}" SelectedIndex="-1"/>
</Grid>
<ItemsControl ItemsSource="{Binding Tokens}" Margin="0,0,0,8">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding}" MinWidth="0" Height="26" Padding="8,0" Margin="0,0,8,8"
Click="OnInsertToken" Tag="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
Text="Type a full UNC or drive path, then click a token to insert it at the caret. Example: \\10.0.0.31\media\movies\%filename_noext% creates a folder named after the file and places the file inside it. %year% / %month% use last-write time. Online-only cloud files are skipped."/>
</StackPanel>
<ListView ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Action" Width="70" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Source" Width="200" DisplayMemberBinding="{Binding Detail}"/>
<GridViewColumn Header="Destination" Width="460" DisplayMemberBinding="{Binding Path}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>

View File

@@ -0,0 +1,69 @@
using System.Windows;
using System.Windows.Controls;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class MoveToWindow : Window
{
public MoveToWindow(MoveToViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
// not shown as a dialog
}
Close();
};
Loaded += (_, _) =>
{
PatternBox.CaretIndex = PatternBox.Text?.Length ?? 0;
PatternBox.Focus();
};
}
private void OnBrowse(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Move to",
Multiselect = false
};
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
{
return;
}
if (DataContext is MoveToViewModel vm)
{
vm.Pattern = picker.FolderName;
}
PatternBox.CaretIndex = PatternBox.Text?.Length ?? 0;
PatternBox.Focus();
}
private void OnInsertToken(object sender, RoutedEventArgs e)
{
if (sender is not Button { Tag: string token } || DataContext is not MoveToViewModel vm)
{
return;
}
var text = PatternBox.Text ?? vm.Pattern ?? "";
var caret = Math.Clamp(PatternBox.CaretIndex, 0, text.Length);
var slash = caret > 0 && text[caret - 1] != '\\' ? "\\" : "";
var insert = slash + token;
vm.Pattern = text.Insert(caret, insert);
PatternBox.Focus();
PatternBox.CaretIndex = caret + insert.Length;
}
}

View File

@@ -82,11 +82,15 @@
<ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format"
SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8"
IsEnabled="{Binding CompressOptionsEnabled}"/>
<CheckBox Content="Convert" IsChecked="{Binding DoConvert}" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding ConversionKinds}" DisplayMemberPath="Label" SelectedValuePath="Kind"
SelectedValue="{Binding ConversionKind}" Margin="0,0,0,8"
IsEnabled="{Binding ConvertOptionsEnabled}"/>
<CheckBox Content="Copy to destination" IsChecked="{Binding DoCopy}" Margin="0,0,0,8"/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Auto-run is Copy only — not Rename or Compress. Drive letters can change; the volume identity is stored."/>
Text="Auto-run is Copy only — not Rename, Compress, or Convert. Drive letters can change; the volume identity is stored."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>

View File

@@ -0,0 +1,22 @@
<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">
<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>

View 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 (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"Git executable",
"Git|git.exe|Executables|*.exe|All files|*.*",
draft.GitPath,
out var path))
{
draft.GitPath = path;
}
}
}

View File

@@ -0,0 +1,18 @@
<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">
<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>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class AppearanceSettingsPage : UserControl
{
public AppearanceSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,42 @@
<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">
<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>

View File

@@ -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 (SettingsPageContext.DraftOf(this) is { } 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 (SettingsPageContext.DraftOf(this) is { } draft
&& SettingsPathBrowse.TryPick(
Window.GetWindow(this),
"FFmpeg executable",
"FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
draft.FfmpegPath,
out var path))
{
draft.FfmpegPath = path;
}
}
}

View File

@@ -0,0 +1,18 @@
<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">
<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>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class GeneralSettingsPage : UserControl
{
public GeneralSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,58 @@
<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"
xmlns:sys="clr-namespace:System;assembly=System.Runtime">
<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 archives 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}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,14">
<ComboBox MinWidth="88" Width="88"
SelectedItem="{Binding IdleMaintenanceMinutes, Mode=TwoWay}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"
AutomationProperties.Name="Idle threshold in minutes">
<ComboBox.ItemsSource>
<x:Array Type="sys:Int32">
<sys:Int32>5</sys:Int32>
<sys:Int32>10</sys:Int32>
<sys:Int32>30</sys:Int32>
</x:Array>
</ComboBox.ItemsSource>
</ComboBox>
<TextBlock Text="minutes" VerticalAlignment="Center" Margin="10,0,0,0"
Foreground="{DynamicResource FgMuted}"
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
</StackPanel>
<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>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class IndexingSettingsPage : UserControl
{
public IndexingSettingsPage() => InitializeComponent();
}

View File

@@ -0,0 +1,46 @@
<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">
<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>

View File

@@ -0,0 +1,8 @@
using System.Windows.Controls;
namespace Explorer.App.Settings.Pages;
public partial class NavigationSettingsPage : UserControl
{
public NavigationSettingsPage() => InitializeComponent();
}

View 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())
];
}
}

View 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; }
}

View File

@@ -0,0 +1,13 @@
namespace Explorer.App.Settings;
public enum SettingsCategoryId
{
General,
Appearance,
Navigation,
FileOperations,
Indexing,
StorageAnalysis,
NetworkCloud,
Advanced
}

View 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();
}

View File

@@ -0,0 +1,11 @@
using System.Windows;
namespace Explorer.App.Settings;
internal static class SettingsPageContext
{
public static SettingsDraft? DraftOf(FrameworkElement page)
=> page.DataContext as SettingsDraft
?? (page.DataContext as SettingsShell)?.Draft
?? (Window.GetWindow(page) as SettingsWindow)?.Draft;
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
namespace Explorer.App.Settings;
public static class SettingsSession
{
public static SettingsCategoryId? LastCategory { get; set; }
}

View 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];
}
}

View 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>

View File

@@ -3,81 +3,69 @@
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 archives size on disk. Online-only cloud archives are skipped."/>
<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"/>
</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>
</StackPanel>
</ScrollViewer>
<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>
<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">
<Grid x:Name="PageHost" HorizontalAlignment="Stretch" VerticalAlignment="Top"/>
</ScrollViewer>
</Border>
</Grid>
</DockPanel>
</Window>

View File

@@ -1,5 +1,7 @@
using System.ComponentModel;
using System.Windows;
using Explorer.Application;
using Explorer.App.Settings;
using Explorer.Hosting;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
@@ -7,80 +9,97 @@ namespace Explorer.App;
public partial class SettingsWindow : Window
{
private readonly MainViewModel _vm;
private readonly SettingsDraft _draft;
private readonly string _originalTheme;
public SettingsDraft Draft => _draft;
public SettingsWindow(MainViewModel vm)
{
InitializeComponent();
_vm = vm;
var prefs = vm.CurrentPreferences();
_originalTheme = prefs.Theme;
ThemeDark.IsChecked = prefs.Theme != "Light";
ThemeLight.IsChecked = prefs.Theme == "Light";
GroupNetwork.IsChecked = prefs.GroupNetworkPlaces;
GroupCloud.IsChecked = prefs.GroupCloudPlaces;
IndexArchives.IsChecked = prefs.IndexArchiveContents;
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? "";
_draft = SettingsDraft.From(prefs);
_draft.PropertyChanged += OnDraftChanged;
var shell = new SettingsShell(_draft, SettingsCatalog.Create());
DataContext = shell;
InitializeComponent();
shell.PropertyChanged += OnShellChanged;
ShowSelectedPage();
Closed += (_, _) =>
{
_draft.PropertyChanged -= OnDraftChanged;
shell.PropertyChanged -= OnShellChanged;
};
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
private void OnShellChanged(object? sender, PropertyChangedEventArgs e)
{
if (!IsLoaded)
if (e.PropertyName is nameof(SettingsShell.SelectedCategory) or null)
{
ShowSelectedPage();
}
}
private void ShowSelectedPage()
{
PageHost.Children.Clear();
if (DataContext is not SettingsShell shell)
{
return;
}
_vm.Theme = ThemeLight.IsChecked == true ? "Light" : "Dark";
var page = shell.SelectedCategory.Page;
PageHost.Children.Add(page);
page.DataContext = _draft;
}
private void OnDraftChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SettingsDraft.Theme)
or nameof(SettingsDraft.IsDarkTheme)
or nameof(SettingsDraft.IsLightTheme))
{
_vm.Theme = _draft.Theme;
}
}
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,
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()
};
var prefs = _draft.ApplyTo(_vm.CurrentPreferences());
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
DialogResult = true;
Close();
}
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
private void ApplyBackgroundHostAutostart(bool enabled)
{
var dlg = new Microsoft.Win32.OpenFileDialog
if (enabled)
{
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;
}
}
var exe = HostLogonAutostart.FindHostExecutable();
if (exe is null)
{
MessageBox.Show(
this,
"Explorer.Host.exe was not found next to Explorer Workbench, so the sign-in task was not registered.",
"Background host",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
private void OnBrowseGit(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
if (!HostLogonAutostart.TryRegister(exe, out var error))
{
MessageBox.Show(this, error, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
return;
}
if (!HostLogonAutostart.TryUnregister(out var unregisterError))
{
Title = "Git executable",
Filter = "Git|git.exe|Executables|*.exe|All files|*.*",
FileName = GitPath.Text
};
if (dlg.ShowDialog(this) == true)
{
GitPath.Text = dlg.FileName;
MessageBox.Show(this, unregisterError, "Background host", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}

View File

@@ -0,0 +1,50 @@
<Window x:Class="Explorer.App.TagRenameWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Tags"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="980"
MinHeight="480" MinWidth="760"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue rename" MinWidth="120" Height="32"
Command="{Binding QueueRenameCommand}" IsEnabled="{Binding CanQueueRename}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue tags" MinWidth="110" Height="32" IsDefault="True"
Command="{Binding QueueTagsCommand}" IsEnabled="{Binding CanQueueTags}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,12">
<TextBlock DockPanel.Dock="Top" Text="Filename pattern" FontWeight="SemiBold" Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="8,0,0,0">
<Button Content="Save pattern" MinWidth="110" Height="28" Command="{Binding SavePatternCommand}" Margin="0,0,8,0"/>
<Button Content="Filename → tags" MinWidth="130" Height="28" Command="{Binding ApplyFilenameToTagsCommand}" Margin="0,0,8,0"/>
<Button Content="Tags → filename" MinWidth="130" Height="28" Command="{Binding ApplyTagsToNamesCommand}"/>
</StackPanel>
<ComboBox IsEditable="True" ItemsSource="{Binding PatternChoices}"
Text="{Binding Pattern, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,12" TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12"
Text="Edit tags, then Queue tags to write them into the files (ID3 on audio, EXIF on photos). Tags → filename fills New name from the pattern; Queue rename applies those names. Online-only cloud files are skipped. {Project} is the Git repo or parent folder."/>
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False"
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}" RowBackground="{DynamicResource Panel}"
AlternatingRowBackground="{DynamicResource Panel}"
HorizontalGridLinesBrush="{DynamicResource Stroke}">
<DataGrid.Columns>
<DataGridTextColumn Header="File" Binding="{Binding FileName}" Width="180" IsReadOnly="True"/>
<DataGridTextColumn Header="Artist" Binding="{Binding Artist, UpdateSourceTrigger=PropertyChanged}" Width="120"/>
<DataGridTextColumn Header="Title" Binding="{Binding Title, UpdateSourceTrigger=PropertyChanged}" Width="160"/>
<DataGridTextColumn Header="Album" Binding="{Binding Album, UpdateSourceTrigger=PropertyChanged}" Width="140"/>
<DataGridTextColumn Header="Track" Binding="{Binding Track, UpdateSourceTrigger=PropertyChanged}" Width="60"/>
<DataGridTextColumn Header="Year" Binding="{Binding Year, UpdateSourceTrigger=PropertyChanged}" Width="60"/>
<DataGridTextColumn Header="Genre" Binding="{Binding Genre, UpdateSourceTrigger=PropertyChanged}" Width="100"/>
<DataGridTextColumn Header="New name" Binding="{Binding ProposedName}" Width="180" IsReadOnly="True"/>
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="120" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,26 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class TagRenameWindow : Window
{
public TagRenameWindow(TagRenameViewModel vm)
{
InitializeComponent();
DataContext = vm;
vm.CloseRequested += (_, _) =>
{
try
{
DialogResult = true;
}
catch (InvalidOperationException)
{
// not shown as a dialog
}
Close();
};
}
}

View File

@@ -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;

View File

@@ -0,0 +1,63 @@
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; }
string? CurrentPath { get; }
void Pause();
void Resume();
void BeginUserRequested();
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
}
public interface IHistoryMaintenance
{
Task<bool> TryCaptureAsync(CancellationToken cancellationToken = default);
}
public interface IIdleClassifyWork
{
bool IsPaused { get; }
string? CurrentPath { get; }
void Pause();
void Resume();
/// <summary>Returns true when any classification work was performed.</summary>
Task<bool> ProcessPendingAsync(CancellationToken cancellationToken = default);
}
public sealed class NullIdleClassifyWork : IIdleClassifyWork
{
public static NullIdleClassifyWork Instance { get; } = new();
public bool IsPaused => true;
public string? CurrentPath => null;
public void Pause() { }
public void Resume() { }
public Task<bool> ProcessPendingAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(false);
}
public interface IForegroundWorkSignal
{
bool HasForegroundWork();
}

View 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)));
}

View 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"
};
}

View File

@@ -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;

View File

@@ -11,22 +11,24 @@ public sealed class BrowseService
private readonly IVolumeService _volumes;
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly StorageProviderRegistry _providers;
private readonly ICloudOverlay _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly IElevatedScanService? _elevation;
private readonly IRecycleBinCatalog? _recycle;
private readonly IKnownUserFolderCatalog? _knownFolders;
public BrowseService(
IFileSystemEnumerator enumerator,
IVolumeService volumes,
IIndexStore store,
SourceManager sources,
StorageProviderRegistry providers,
ICloudOverlay providers,
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 (!reachable)
if (MightBeArchiveListing(path, reachable))
{
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
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(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,
@@ -288,51 +395,52 @@ public sealed class BrowseService
Cloud = entry.CloudAvailability is { } availability
? new CloudPresence(null, availability, entry.SizeBytes, entry.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null,
Hydration = ItemHydrationFlags.All
Hydration = ItemHydrationFlags.All,
IndexEntryId = entry.Id,
Category = entry.Category,
CategoryReason = entry.CategoryReason,
CategorySource = entry.CategorySource
};
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 +448,7 @@ public sealed class BrowseService
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
batch.Clear();
_ = KickIndex();
}
}
@@ -352,11 +461,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 +473,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)
@@ -375,7 +481,7 @@ public sealed class BrowseService
yield return new BrowseDelta { Path = path, Updated = accessUpdates };
}
var constrained = _providers.Find(path) is not null;
var constrained = _providers.FindProviderId(path) is not null;
if (constrained)
{
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken)
@@ -506,7 +612,12 @@ public sealed class BrowseService
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
fileId: item.FileId ?? entry.FileId,
cloud: cloud,
hydration: item.Hydration | ItemHydrationFlags.Index);
indexedChildCount: entry.ChildFileCount + entry.ChildDirCount,
hydration: item.Hydration | ItemHydrationFlags.Index,
indexEntryId: entry.Id,
category: entry.Category,
categoryReason: entry.CategoryReason,
categorySource: entry.CategorySource);
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
}
@@ -682,7 +793,11 @@ public sealed class BrowseService
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes
AllocatedSizeBytes = c.AllocatedSizeBytes,
IndexEntryId = c.Id,
Category = c.Category,
CategoryReason = c.CategoryReason,
CategorySource = c.CategorySource
}).ToList();
var hint = isArchiveFile && items.Count == 0
@@ -757,6 +872,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 +950,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"];

View File

@@ -0,0 +1,148 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class ConversionPlanner
{
public OperationPlan Build(
IReadOnlyList<string> sourcePaths,
string destDirectory,
ConversionKind kind,
IFileSystemEnumerator enumerator,
bool ffmpegAvailable,
string missingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
var sources = sourcePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToList();
if (sources.Count == 0)
{
return Error("Select files or a folder to convert.");
}
if (string.IsNullOrWhiteSpace(destDirectory))
{
return Error("Choose a destination folder.");
}
if (!ffmpegAvailable)
{
return Error(missingHint);
}
var files = Collect(sources, enumerator, wouldHydrate, issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
var operations = new List<PlannedOperation>();
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in files)
{
if (!ConversionFormats.Matches(item.Name, kind))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Skipped — not a match for this conversion.", item.FullPath));
continue;
}
var dest = UniqueOutputPath(destDirectory, Path.GetFileNameWithoutExtension(item.Name), ConversionFormats.Extension(kind), pathExists, claimed);
claimed.Add(dest);
operations.Add(new PlannedOperation(TransferOp.Convert, item.FullPath, dest, kind.ToString()));
preview.Add(new ProfilePreviewRow("Convert", dest, item.Name));
}
if (operations.Count == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Nothing to convert for this conversion kind."));
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
ProfilePreview = preview
};
}
public static string UniqueOutputPath(
string directory,
string stem,
string extension,
Func<string, bool>? pathExists,
ISet<string>? claimed)
{
extension = extension.Trim().TrimStart('.');
var dest = PathRules.Combine(directory, stem + "." + extension);
var i = 2;
while (IsTaken(dest, pathExists, claimed))
{
dest = PathRules.Combine(directory, $"{stem} ({i++}).{extension}");
}
return dest;
}
private static bool IsTaken(string dest, Func<string, bool>? pathExists, ISet<string>? claimed)
=> claimed?.Contains(dest) == true || pathExists?.Invoke(dest) == true;
private static List<FileSystemItem> Collect(
IReadOnlyList<string> sources,
IFileSystemEnumerator enumerator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues)
{
var items = new List<FileSystemItem>();
foreach (var path in sources)
{
var item = enumerator.GetItem(path);
if (item is null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Source was not found.", path));
continue;
}
if (item.IsDirectory)
{
var children = enumerator.EnumerateChildrenSafe(item.FullPath, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, item.FullPath));
continue;
}
foreach (var child in children.Where(c => !c.IsDirectory))
{
Add(child, wouldHydrate, issues, items);
}
}
else
{
Add(item, wouldHydrate, issues, items);
}
}
return items;
}
private static void Add(
FileSystemItem item,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
List<FileSystemItem> items)
{
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
return;
}
items.Add(item);
}
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
}

View File

@@ -0,0 +1,193 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public sealed record MoveToFields(
string FileName,
string Stem,
string Extension,
string Parent,
string SourceDrive,
DateTimeOffset? Modified,
DateTimeOffset? Created);
public static class DestinationPattern
{
private static readonly Regex TokenRx = new(
@"%([A-Za-z_]+)%|\{([A-Za-z_]+)\}",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static bool TryResolve(
string pattern,
RenameSubject subject,
out string destination,
out string? error)
{
destination = "";
var fields = FromSubject(subject);
if (!TryExpand(pattern, fields, out var expanded, out error))
{
return false;
}
destination = subject.IsDirectory || EndsWithFileNameToken(pattern)
? expanded
: PathRules.Combine(expanded.TrimEnd('\\'), fields.FileName);
if (!IsRooted(destination))
{
error = "Enter a full destination path (drive or UNC).";
return false;
}
foreach (var segment in NameSegments(destination))
{
if (!WindowsFileNames.IsValid(segment, out error))
{
return false;
}
}
error = null;
return true;
}
public static bool TryExpand(string pattern, MoveToFields fields, out string expanded, out string? error)
{
expanded = "";
if (string.IsNullOrWhiteSpace(pattern))
{
error = "Enter a destination path. Use placeholders such as %filename_noext%.";
return false;
}
Match leftover = Match.Empty;
expanded = PathRules.NormalizeDirectorySeparators(TokenRx.Replace(pattern.Trim(), match =>
{
var name = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
var value = Resolve(name, fields);
if (value is null)
{
leftover = match;
return match.Value;
}
return value;
}));
if (leftover.Success)
{
error = "Unknown placeholder " + leftover.Value + ".";
return false;
}
if (string.IsNullOrWhiteSpace(expanded))
{
error = "The destination path is empty.";
return false;
}
error = null;
return true;
}
public static MoveToFields FromSubject(RenameSubject subject)
{
var tags = FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory);
var (stem, extension) = WindowsFileNames.Split(subject.Name);
return new MoveToFields(
subject.Name,
subject.IsDirectory ? subject.Name : stem,
subject.IsDirectory ? "" : extension,
tags.Parent ?? "",
PathRules.VolumeRoot(subject.FullPath),
tags.Modified,
tags.Created);
}
public static bool EndsWithFileNameToken(string pattern)
{
var last = LastSegment(pattern);
foreach (Match match in TokenRx.Matches(last))
{
switch (TokenName(match))
{
case "filename":
case "ext":
case "extension":
return true;
}
}
return false;
}
private static string TokenName(Match match)
=> (match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value).ToLowerInvariant();
private static string LastSegment(string path)
{
var normalized = path.Replace('/', '\\').TrimEnd('\\');
var idx = normalized.LastIndexOf('\\');
return idx >= 0 ? normalized[(idx + 1)..] : normalized;
}
private static string? Resolve(string name, MoveToFields fields)
=> name.ToLowerInvariant() switch
{
"filename" => Sanitize(fields.FileName),
"filename_noext" or "stem" => Sanitize(fields.Stem),
"ext" or "extension" => Sanitize(fields.Extension),
"parent" => Sanitize(fields.Parent),
"source_drive" or "drive" => fields.SourceDrive,
"year" => DatePart(fields, "yyyy"),
"month" => DatePart(fields, "MM"),
_ => null
};
private static string Sanitize(string? value)
=> WindowsFileNames.SanitizeForFileName(value ?? "");
private static string DatePart(MoveToFields fields, string format)
{
var stamp = (fields.Modified ?? fields.Created ?? DateTimeOffset.Now).ToLocalTime();
return stamp.ToString(format, CultureInfo.InvariantCulture);
}
private static bool IsRooted(string path)
{
var p = PathRules.FromExtended(path);
return PathRules.IsUnc(p) || (p.Length >= 2 && p[1] == ':');
}
internal static IEnumerable<string> NameSegments(string path)
{
var p = PathRules.FromExtended(path).TrimEnd('\\');
string rest;
if (PathRules.IsUnc(p))
{
var root = PathRules.CanonicalUncRoot(p);
if (p.Length <= root.Length)
{
yield break;
}
rest = p[(root.Length + 1)..];
}
else if (p.Length >= 2 && p[1] == ':')
{
rest = p.Length > 3 ? p[3..].TrimStart('\\') : "";
}
else
{
rest = p;
}
foreach (var part in rest.Split('\\', StringSplitOptions.RemoveEmptyEntries))
{
yield return part;
}
}
}

View File

@@ -0,0 +1,88 @@
namespace Explorer.Application;
public static class DestinationPatterns
{
public const int MaxCount = 24;
public const int MaxLength = 500;
public static IReadOnlyList<string> BuiltIn { get; } =
[
@"\\host\share\movies\%filename_noext%",
@"%source_drive%\sorted\%year%\%month%"
];
public static IReadOnlyList<string> Combine(IEnumerable<string>? saved)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pattern in (saved ?? []).Concat(BuiltIn))
{
if (!TryNormalize(pattern, out var text) || !seen.Add(text))
{
continue;
}
result.Add(text);
if (result.Count >= MaxCount)
{
break;
}
}
return result;
}
public static IReadOnlyList<string> Add(IEnumerable<string>? saved, string pattern)
{
if (!TryNormalize(pattern, out var text))
{
return Normalize(saved);
}
var next = new List<string> { text };
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { text };
foreach (var existing in Normalize(saved))
{
if (seen.Add(existing))
{
next.Add(existing);
}
if (next.Count >= MaxCount)
{
break;
}
}
return next;
}
public static IReadOnlyList<string> Normalize(IEnumerable<string>? saved)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in saved ?? [])
{
if (!TryNormalize(raw, out var text)
|| BuiltIn.Contains(text, StringComparer.OrdinalIgnoreCase)
|| !seen.Add(text))
{
continue;
}
result.Add(text);
if (result.Count >= MaxCount)
{
break;
}
}
return result;
}
public static bool TryNormalize(string? raw, out string pattern)
{
pattern = (raw ?? "").Trim();
return pattern.Length is > 0 && pattern.Length <= MaxLength;
}
}

View File

@@ -0,0 +1,173 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class EntryClassificationService : IIdleClassifyWork
{
private readonly IIndexStore _store;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly ILogger<EntryClassificationService> _logger;
private readonly IHostActivitySink _activity;
private volatile bool _paused = true;
private volatile string? _currentPath;
public EntryClassificationService(
IIndexStore store,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration,
ILogger<EntryClassificationService> logger,
IHostActivitySink? activity = null)
{
_store = store;
_enumerator = enumerator;
_hydration = hydration;
_logger = logger;
_activity = activity ?? NullHostActivitySink.Instance;
}
public bool IsPaused => _paused;
public string? CurrentPath => _currentPath;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public async Task<bool> ProcessPendingAsync(CancellationToken cancellationToken = default)
{
if (_paused)
{
return false;
}
var backfill = await _store.Entries.BackfillCheapCategoriesAsync(80, cancellationToken)
.ConfigureAwait(false);
if (backfill > 0)
{
_activity.Record("Classify", "Backfilled " + backfill + " extension categories");
return true;
}
var archives = await _store.Entries.GetArchiveIdsNeedingContentClassifyAsync(20, cancellationToken)
.ConfigureAwait(false);
if (archives.Count > 0)
{
foreach (var id in archives)
{
if (_paused || cancellationToken.IsCancellationRequested)
{
break;
}
await _store.Entries.RefreshCategoryFromChildrenAsync(id, cancellationToken)
.ConfigureAwait(false);
}
_activity.Record("Classify", "Updated " + archives.Count + " archive(s) from contents");
return true;
}
return await TrySignatureClassifyAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<bool> TrySignatureClassifyAsync(CancellationToken cancellationToken)
{
var unknowns = await _store.Entries.GetUnknownFilesForSignatureAsync(12, cancellationToken)
.ConfigureAwait(false);
if (unknowns.Count == 0)
{
return false;
}
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var byId = sources.ToDictionary(s => s.Id);
var changed = 0;
foreach (var entry in unknowns)
{
if (_paused || cancellationToken.IsCancellationRequested)
{
break;
}
if (!byId.TryGetValue(entry.SourceId, out var source) || string.IsNullOrEmpty(source.LastRootPath))
{
continue;
}
var full = PathRules.Combine(source.LastRootPath, entry.PathRel);
_currentPath = full;
try
{
var item = _enumerator.GetItem(full);
if (item is null)
{
MarkSignatureSkip(entry, "File was not found.");
await _store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
continue;
}
if (_hydration.WouldHydrateOnRead(item)
|| await _hydration.WouldHydrateOnReadAsync(full, cancellationToken).ConfigureAwait(false))
{
MarkSignatureSkip(entry, "Skipped online-only cloud file.");
await _store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
continue;
}
await using var stream = new FileStream(
PathRules.ToExtended(full),
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 64,
FileOptions.SequentialScan | FileOptions.Asynchronous);
var buffer = new byte[16];
var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken)
.ConfigureAwait(false);
var hit = FileSignatureClassifier.TryClassify(buffer.AsSpan(0, read));
if (hit is null)
{
entry.CategorySource = CategorySources.Mime;
entry.CategoryConfidence = 10;
entry.CategoryReason = "No known file signature.";
entry.CategoryUtc = DateTimeOffset.UtcNow;
await _store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
continue;
}
EntryCategoryAssigner.Apply(entry, hit, CategorySources.Mime, 70);
await _store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
changed++;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Signature classify failed for {Path}", full);
try
{
MarkSignatureSkip(entry, "Could not read file signature.");
await _store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
}
catch (Exception markEx) when (markEx is not OperationCanceledException)
{
_logger.LogDebug(markEx, "Could not mark signature skip for {Path}", full);
}
}
}
_currentPath = null;
if (changed > 0)
{
_activity.Record("Classify", "Signature-classified " + changed + " file(s)");
}
return changed > 0 || unknowns.Count > 0;
}
private static void MarkSignatureSkip(IndexEntry entry, string reason)
=> EntryCategoryAssigner.Apply(
entry,
new FileClassification(entry.Category, reason),
CategorySources.Mime,
5);
}

View 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();
}
}

View File

@@ -0,0 +1,41 @@
namespace Explorer.Application;
public static class FfmpegLocator
{
public const string MissingHint = "ffmpeg.exe was not found. Place a Windows build on PATH, under Program Files\\ffmpeg\\bin, or set the path in Settings.";
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(programFiles, "ffmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(programFiles, "FFmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(programFilesX86, "ffmpeg", "bin", "ffmpeg.exe");
yield return Path.Combine(local, "Microsoft", "WinGet", "Links", "ffmpeg.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "ffmpeg.exe");
}
}
}

View File

@@ -19,7 +19,9 @@ public sealed class FileOperationProfilePlanner
bool compressAvailable,
string compressMissingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
Func<FileSystemItem, bool>? wouldHydrate = null,
bool convertAvailable = true,
string? convertMissingHint = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
@@ -29,9 +31,9 @@ public sealed class FileOperationProfilePlanner
return Error("Choose a source folder or drop files onto the profile.");
}
if (!profile.DoCopy && !profile.DoCompress && !profile.HasRenameRules)
if (!profile.DoCopy && !profile.DoCompress && !profile.DoConvert && !profile.HasRenameRules)
{
return Error("Turn on Copy, Compress, or Rename.");
return Error("Turn on Copy, Compress, Convert, or Rename.");
}
if (profile.RequireGitClean)
@@ -52,7 +54,7 @@ public sealed class FileOperationProfilePlanner
}
}
var needsDest = profile.DoCopy || profile.DoCompress;
var needsDest = profile.DoCopy || profile.DoCompress || profile.DoConvert;
var destRoot = profile.DestPath?.Trim() ?? "";
if (needsDest)
{
@@ -144,6 +146,44 @@ public sealed class FileOperationProfilePlanner
preview.Add(new ProfilePreviewRow("Compress", archive, $"{working.Count} item(s)"));
}
if (profile.DoConvert)
{
if (!convertAvailable)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, convertMissingHint ?? FfmpegLocator.MissingHint));
return new OperationPlan { Issues = issues, ProfilePreview = preview, Preview = [] };
}
var claimed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var converted = 0;
foreach (var path in working)
{
var name = PathRules.GetFileName(path);
if (!ConversionFormats.Matches(name, profile.ConversionKind))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Skipped — not a match for this conversion.", path));
continue;
}
var dest = ConversionPlanner.UniqueOutputPath(
destRoot,
Path.GetFileNameWithoutExtension(name),
ConversionFormats.Extension(profile.ConversionKind),
pathExists,
claimed);
claimed.Add(dest);
operations.Add(new PlannedOperation(TransferOp.Convert, path, dest, profile.ConversionKind.ToString()));
preview.Add(new ProfilePreviewRow("Convert", dest, name));
converted++;
}
if (converted == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Nothing to convert for this conversion kind."));
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
}
if (profile.DoCopy)
{
var copyDest = payload.ContainerName is null

View File

@@ -0,0 +1,314 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public static class FilenamePattern
{
public const string DefaultAudioPattern = "{Artist} - {Title}";
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250);
private static readonly Regex TokenRx = new(
@"\{([A-Za-z]+)(?::([^}]+))?\}",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static string Expand(string pattern, MediaTagFields tags, string counter = "")
{
if (string.IsNullOrEmpty(pattern))
{
return pattern;
}
return TokenRx.Replace(pattern, match =>
{
var name = match.Groups[1].Value;
var format = match.Groups[2].Success ? match.Groups[2].Value : null;
return Resolve(name, format, tags, counter) ?? match.Value;
});
}
public static bool UsesMediaContent(string? text)
{
if (string.IsNullOrEmpty(text))
{
return false;
}
foreach (Match match in TokenRx.Matches(text))
{
switch (match.Groups[1].Value.ToLowerInvariant())
{
case "artist":
case "title":
case "album":
case "track":
case "trackcount":
case "year":
case "genre":
case "comment":
case "width":
case "height":
case "takendate":
case "datetaken":
return true;
}
}
return false;
}
public static bool TryParse(string pattern, string stem, out MediaTagFields tags, out string? error)
{
tags = new MediaTagFields();
error = null;
if (string.IsNullOrWhiteSpace(pattern))
{
error = "Enter a filename pattern such as {Artist} - {Title}.";
return false;
}
var regex = new StringBuilder("^");
var names = new List<string>();
var last = 0;
foreach (Match match in TokenRx.Matches(pattern))
{
regex.Append(Regex.Escape(pattern[last..match.Index]));
var name = match.Groups[1].Value;
names.Add(name);
regex.Append(Capture(name, names.Count == CountTokens(pattern)));
last = match.Index + match.Length;
}
regex.Append(Regex.Escape(pattern[last..]));
regex.Append('$');
Match parsed;
try
{
parsed = Regex.Match(stem, regex.ToString(), RegexOptions.CultureInvariant, RegexTimeout);
}
catch (RegexMatchTimeoutException)
{
error = "The filename pattern took too long.";
return false;
}
catch (ArgumentException ex)
{
error = "Invalid filename pattern: " + ex.Message;
return false;
}
if (!parsed.Success)
{
error = "The name does not match the pattern.";
return false;
}
var artist = Value(parsed, "Artist");
var title = Value(parsed, "Title");
var album = Value(parsed, "Album");
var genre = Value(parsed, "Genre");
var comment = Value(parsed, "Comment");
int? track = ParseInt(Value(parsed, "Track"));
int? year = ParseInt(Value(parsed, "Year"));
var taken = ParseDate(
Value(parsed, "TakenDate") ?? Value(parsed, "DateTaken") ?? Value(parsed, "CreatedDate") ?? Value(parsed, "Date"),
DateFormat(pattern));
tags = new MediaTagFields
{
Artist = artist,
Title = title,
Album = album,
Genre = genre,
Comment = comment,
Track = track,
Year = year ?? taken?.Year,
Taken = taken
};
return tags.HasWritableTags;
}
public static MediaTagFields FromFile(string path, bool isDirectory)
{
var name = PathRules.GetFileName(path);
var (stem, extension) = WindowsFileNames.Split(name);
var parent = PathRules.GetFileName(PathRules.Parent(path).TrimEnd('\\'));
DateTimeOffset? created = null;
DateTimeOffset? modified = null;
var disk = PathRules.ToExtended(path);
try
{
if (isDirectory)
{
if (Directory.Exists(disk))
{
created = Directory.GetCreationTime(disk);
modified = Directory.GetLastWriteTime(disk);
}
}
else if (System.IO.File.Exists(disk))
{
created = System.IO.File.GetCreationTime(disk);
modified = System.IO.File.GetLastWriteTime(disk);
}
}
catch (IOException)
{
// Timestamps are optional.
}
catch (UnauthorizedAccessException)
{
}
return new MediaTagFields
{
Stem = isDirectory ? name : stem,
Extension = isDirectory ? "" : extension,
Parent = parent,
Created = created,
Modified = modified
};
}
public static MediaTagFields WithProject(MediaTagFields fields, string? repoRoot)
{
var project = string.IsNullOrWhiteSpace(repoRoot)
? fields.Parent
: PathRules.GetFileName(repoRoot.TrimEnd('\\'));
return fields with { Project = string.IsNullOrWhiteSpace(project) ? fields.Parent : project };
}
private static int CountTokens(string pattern) => TokenRx.Matches(pattern).Count;
private static string Capture(string name, bool last)
{
var key = name.ToLowerInvariant() switch
{
"track" or "trackcount" or "year" or "width" or "height" or "counter" => @"\d+",
_ => last ? ".+" : ".+?"
};
return $"(?<{SanitizeGroup(name)}>{key})";
}
private static string SanitizeGroup(string name)
=> string.Concat(name.Where(char.IsLetterOrDigit));
private static string? Value(Match match, string name)
{
var group = SanitizeGroup(name);
return match.Groups[group].Success ? match.Groups[group].Value.Trim() : null;
}
private static int? ParseInt(string? text)
=> int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : null;
private static string? Resolve(string name, string? format, MediaTagFields tags, string counter)
=> name.ToLowerInvariant() switch
{
"artist" => Sanitize(tags.Artist),
"title" => Sanitize(tags.Title),
"album" => Sanitize(tags.Album),
"genre" => Sanitize(tags.Genre),
"comment" => Sanitize(tags.Comment),
"track" => Number(tags.Track, format),
"trackcount" => Number(tags.TrackCount, format),
"year" => Number(tags.Year, format),
"width" => Number(tags.Width, format),
"height" => Number(tags.Height, format),
"counter" => counter,
"name" or "stem" => Sanitize(tags.Stem),
"extension" => Sanitize(tags.Extension),
"parent" => Sanitize(tags.Parent),
"project" => Sanitize(tags.Project ?? tags.Parent),
"createddate" or "date" => Date(tags.Taken ?? tags.Created, format),
"takendate" or "datetaken" => Date(tags.Taken ?? tags.Created, format),
"modifieddate" => Date(tags.Modified, format),
_ => null
};
private static string Sanitize(string? value)
=> WindowsFileNames.SanitizeForFileName(value ?? "");
private static string Number(int? value, string? format)
{
if (value is null)
{
return "";
}
if (string.IsNullOrWhiteSpace(format))
{
return value.Value.ToString(CultureInfo.InvariantCulture);
}
try
{
return value.Value.ToString(format, CultureInfo.InvariantCulture);
}
catch (FormatException)
{
return value.Value.ToString(CultureInfo.InvariantCulture);
}
}
private static string Date(DateTimeOffset? value, string? format)
{
if (value is null)
{
return "";
}
var text = value.Value.ToLocalTime().ToString(
string.IsNullOrWhiteSpace(format) ? "yyyy-MM-dd" : format,
CultureInfo.InvariantCulture);
return WindowsFileNames.SanitizeForFileName(text);
}
private static string? DateFormat(string pattern)
{
foreach (Match match in TokenRx.Matches(pattern))
{
var name = match.Groups[1].Value.ToLowerInvariant();
if ((name is "takendate" or "datetaken" or "createddate" or "date")
&& match.Groups[2].Success)
{
return match.Groups[2].Value;
}
}
return null;
}
private static DateTimeOffset? ParseDate(string? text, string? format)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var formats = new List<string>();
if (!string.IsNullOrWhiteSpace(format))
{
formats.Add(format);
}
formats.AddRange(["yyyy-MM-dd", "yyyyMMdd", "yyyy-MM-dd HH-mm", "yyyy-MM-dd-HH-mm-ss"]);
foreach (var candidate in formats)
{
if (DateTime.TryParseExact(
text,
candidate,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeLocal,
out var exact))
{
return new DateTimeOffset(exact);
}
}
return DateTimeOffset.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed)
? parsed
: null;
}
}

View File

@@ -0,0 +1,88 @@
namespace Explorer.Application;
public static class FilenamePatterns
{
public const int MaxCount = 24;
public static IReadOnlyList<string> BuiltIn { get; } =
[
"{Artist} - {Title}",
"{Track:00} - {Title}",
"{TakenDate}_{Name}",
"{CreatedDate}_{Name}",
"{Project}_{Name}"
];
public static IReadOnlyList<string> Combine(IEnumerable<string>? saved)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pattern in BuiltIn.Concat(saved ?? []))
{
if (!TryNormalize(pattern, out var text) || !seen.Add(text))
{
continue;
}
result.Add(text);
if (result.Count >= MaxCount)
{
break;
}
}
return result;
}
public static IReadOnlyList<string> Add(IEnumerable<string>? saved, string pattern)
{
if (!TryNormalize(pattern, out var text))
{
return Normalize(saved);
}
var next = new List<string> { text };
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { text };
foreach (var existing in Normalize(saved))
{
if (seen.Add(existing))
{
next.Add(existing);
}
if (next.Count >= MaxCount)
{
break;
}
}
return next;
}
public static IReadOnlyList<string> Normalize(IEnumerable<string>? saved)
{
var result = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in saved ?? [])
{
if (!TryNormalize(raw, out var text) || BuiltIn.Contains(text, StringComparer.OrdinalIgnoreCase) || !seen.Add(text))
{
continue;
}
result.Add(text);
if (result.Count >= MaxCount)
{
break;
}
}
return result;
}
public static bool TryNormalize(string? raw, out string pattern)
{
pattern = (raw ?? "").Trim();
return pattern.Length is > 0 and <= 200;
}
}

View 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;
}

View File

@@ -39,6 +39,9 @@ public static class FolderListingSort
"Modified" => descending
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.ModifiedUtc).ThenBy(i => i.Name, names),
"Created" => descending
? items.OrderByDescending(i => i.CreatedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.CreatedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
: items.OrderBy(TypeKey).ThenBy(i => i.Name, names),

View 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);

View File

@@ -0,0 +1,148 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class GitListingOverlay
{
public static string ForNestedRepo(GitStatus status)
{
var parts = new List<string> { status.Branch };
if (!string.IsNullOrEmpty(status.OperationLabel))
{
parts.Add(status.OperationLabel);
}
if (status.ModifiedCount > 0)
{
parts.Add($"{status.ModifiedCount} modified");
}
if (status.UntrackedCount > 0)
{
parts.Add($"{status.UntrackedCount} untracked");
}
return string.Join(" · ", parts);
}
public static string ForItem(string itemFullPath, bool isDirectory, string repoRoot, GitStatus status)
{
var rel = ToGitRelative(repoRoot, itemFullPath);
if (rel is null)
{
return "";
}
return isDirectory ? FolderLabel(status.Changes, rel) : FileLabel(status.Changes, rel);
}
internal static string? ToGitRelative(string repoRoot, string fullPath)
{
var root = PathRules.FromExtended(repoRoot).TrimEnd('\\');
var full = PathRules.FromExtended(fullPath).TrimEnd('\\');
if (full.Equals(root, StringComparison.OrdinalIgnoreCase))
{
return "";
}
if (!full.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return full[(root.Length + 1)..].Replace('\\', '/');
}
private static string FileLabel(IReadOnlyList<GitChange> changes, string rel)
{
if (rel.Length == 0)
{
return "";
}
var hits = changes.Where(c => Matches(c, rel)).ToList();
if (hits.Count == 0)
{
return "";
}
if (hits.Exists(c => c.State == GitChangeState.Unmerged))
{
return "Unmerged";
}
if (hits.Exists(c => c.State == GitChangeState.Untracked))
{
return "Untracked";
}
var staged = hits.Find(c => c.State == GitChangeState.Staged);
var unstaged = hits.Find(c => c.State == GitChangeState.Unstaged);
if (staged is not null && unstaged is not null)
{
return "Staged · Modified";
}
if (staged is not null)
{
return staged.Index switch
{
'R' => "Renamed",
'A' => "Added",
'D' => "Deleted",
_ => "Staged"
};
}
return unstaged is null ? "" : Title(unstaged.ChangeLabel);
}
private static string FolderLabel(IReadOnlyList<GitChange> changes, string rel)
{
var hits = changes.Where(c => Under(c, rel)).ToList();
if (hits.Count == 0)
{
return "";
}
if (hits.Exists(c => c.State == GitChangeState.Unmerged))
{
return "Unmerged";
}
if (hits.TrueForAll(c => c.State == GitChangeState.Untracked))
{
return "Untracked";
}
return "Modified";
}
private static bool Matches(GitChange change, string rel)
=> Same(change.Path, rel)
|| (change.OriginalPath is { Length: > 0 } original && Same(original, rel));
private static bool Under(GitChange change, string rel)
{
if (Matches(change, rel))
{
return true;
}
if (rel.Length == 0)
{
return true;
}
var prefix = rel + "/";
return change.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|| (change.OriginalPath is { Length: > 0 } original
&& original.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}
private static bool Same(string left, string right)
=> left.Equals(right, StringComparison.OrdinalIgnoreCase);
private static string Title(string value)
=> string.IsNullOrEmpty(value) ? "" : char.ToUpperInvariant(value[0]) + value[1..];
}

View File

@@ -0,0 +1,87 @@
using Explorer.Contracts;
namespace Explorer.Application;
public interface IHostActivitySink
{
void Record(string category, string message);
IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120);
}
public sealed class NullHostActivitySink : IHostActivitySink
{
public static NullHostActivitySink Instance { get; } = new();
public void Record(string category, string message)
{
}
public IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120) => [];
}
/// <summary>Bounded ring of host activity lines for the live monitor. Cheap to write; snapshot is a copy.</summary>
public sealed class HostActivityLog : IHostActivitySink
{
private readonly object _gate = new();
private readonly HostActivityEvent[] _ring;
private int _next;
private int _count;
private string? _lastKey;
private DateTimeOffset _lastUtc;
public HostActivityLog(int capacity = 48)
{
_ring = new HostActivityEvent[Math.Clamp(capacity, 32, 200)];
}
public void Record(string category, string message)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
var cat = string.IsNullOrWhiteSpace(category) ? "Host" : category.Trim();
var msg = message.Trim();
var key = cat + "\u001f" + msg;
var now = DateTimeOffset.UtcNow;
lock (_gate)
{
if (key == _lastKey && (now - _lastUtc) < TimeSpan.FromMilliseconds(750))
{
return;
}
_lastKey = key;
_lastUtc = now;
_ring[_next] = new HostActivityEvent { Utc = now, Category = cat, Message = msg };
_next = (_next + 1) % _ring.Length;
if (_count < _ring.Length)
{
_count++;
}
}
}
public IReadOnlyList<HostActivityEvent> TakeRecent(int max = 120)
{
max = Math.Clamp(max, 1, _ring.Length);
lock (_gate)
{
var take = Math.Min(max, _count);
if (take == 0)
{
return [];
}
var result = new HostActivityEvent[take];
var start = (_next - take + _ring.Length) % _ring.Length;
for (var i = 0; i < take; i++)
{
result[i] = _ring[(start + i) % _ring.Length];
}
return result;
}
}
}

View File

@@ -12,9 +12,9 @@ public interface IHydrationGuard
public sealed class HydrationGuard : IHydrationGuard
{
private readonly StorageProviderRegistry _registry;
private readonly ICloudOverlay _overlay;
public HydrationGuard(StorageProviderRegistry registry) => _registry = registry;
public HydrationGuard(ICloudOverlay overlay) => _overlay = overlay;
public bool WouldHydrateOnRead(FileSystemItem item)
{
@@ -38,7 +38,7 @@ public sealed class HydrationGuard : IHydrationGuard
public async Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
{
var state = await _registry.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
var state = await _overlay.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return false;

View File

@@ -0,0 +1,20 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public interface ICloudOverlay
{
IReadOnlyList<ProviderPlace> GetPlaces();
string? FindProviderId(string path);
bool HasCapability(string path, ProviderCapability capability);
Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default);
Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default);
Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default);
Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default);
}

View File

@@ -1,5 +1,10 @@
namespace Explorer.Application;
/// <summary>
/// Detects whether this process already has administrator rights.
/// Elevation is never requested automatically. A future helper may scan USN
/// under elevation; it must not host the transfer queue or index writer.
/// </summary>
public interface IElevatedScanService
{
bool IsElevated { get; }

View File

@@ -14,4 +14,5 @@ public interface IWorkspaceLauncher
{
void OpenTerminal(string directory);
bool TryOpenInCursor(string path);
bool TryOpenInNotepadPlusPlus(IReadOnlyList<string> paths);
}

View 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() => [];
}

View File

@@ -0,0 +1,18 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record ConversionProgress(int Percent, string? CurrentPath);
public interface IMediaConversionProvider
{
bool IsAvailable { get; }
string MissingHint { get; }
Task ConvertAsync(
string sourcePath,
string destinationPath,
ConversionKind kind,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,9 @@
using Explorer.Domain;
namespace Explorer.Application;
public interface IMediaTagService
{
bool TryRead(string path, out MediaTagFields fields);
bool TryWrite(string path, MediaTagFields fields, out string? error);
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Application;
public interface ISqliteDatabaseSessionFactory
{
ISqliteDatabaseSession Create();
}
public interface ISqliteDatabaseSession : IAsyncDisposable
{
string? Path { get; }
bool IsOpen { get; }
bool CanWrite { get; }
string ModeLabel { get; }
Task OpenAsync(string path, bool preferWrite, CancellationToken cancellationToken = default);
Task CloseAsync();
Task<IReadOnlyList<string>> ListTablesAsync(CancellationToken cancellationToken = default);
Task<SqliteTablePage> ReadTableAsync(string table, int offset, int take, CancellationToken cancellationToken = default);
Task<SqliteQueryResult> ExecuteAsync(string sql, CancellationToken cancellationToken = default);
Task UpdateCellAsync(string table, long rowId, string column, object? value, CancellationToken cancellationToken = default);
Task DeleteRowAsync(string table, long rowId, CancellationToken cancellationToken = default);
Task InsertRowAsync(string table, IReadOnlyDictionary<string, object?> values, CancellationToken cancellationToken = default);
}
public sealed class SqliteTablePage
{
public required IReadOnlyList<string> Columns { get; init; }
public required IReadOnlyList<IReadOnlyList<object?>> Rows { get; init; }
public long TotalRows { get; init; }
public int Offset { get; init; }
}
public sealed class SqliteQueryResult
{
public bool IsQuery { get; init; }
public IReadOnlyList<string> Columns { get; init; } = [];
public IReadOnlyList<IReadOnlyList<object?>> Rows { get; init; } = [];
public int RecordsAffected { get; init; }
public string Message { get; init; } = "";
}

View File

@@ -0,0 +1,55 @@
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.Application;
public sealed class IndexStoreLifetime : IHostedService
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly ILogger<IndexStoreLifetime> _logger;
private Task? _initialize;
public IndexStoreLifetime(IIndexStore store, SourceManager sources, ILogger<IndexStoreLifetime> logger)
{
_store = store;
_sources = sources;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
_initialize = InitializeInBackgroundAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_initialize is not null)
{
try
{
await _initialize.WaitAsync(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
{
_logger.LogDebug(ex, "Source refresh still running while the host stopped");
}
}
await _store.CloseAsync().ConfigureAwait(false);
}
private async Task InitializeInBackgroundAsync(CancellationToken cancellationToken)
{
try
{
await _sources.InitializeAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Background source refresh failed");
}
}
}

View File

@@ -0,0 +1,116 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class IndexedPathPresence
{
public static bool FileExists(string root, string pathRel)
{
if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(pathRel))
{
return false;
}
if (TryArchiveFile(pathRel, out var archiveRel))
{
return ExistsFile(PathRules.Combine(root, archiveRel));
}
return ExistsFile(PathRules.Combine(root, pathRel));
}
public static bool RootReachable(string root)
=> !string.IsNullOrWhiteSpace(root) && ExistsDirectory(root);
public static bool DirectoryExists(string path) => ExistsDirectory(path);
public static string? HighestMissingPrefix(string root, string pathRel)
{
if (FileExists(root, pathRel))
{
return null;
}
if (TryArchiveFile(pathRel, out var archiveRel))
{
return archiveRel;
}
var rootNorm = PathRules.FromExtended(root).TrimEnd('\\');
var current = PathRules.FromExtended(PathRules.Combine(root, pathRel));
var missing = pathRel;
while (true)
{
var parent = PathRules.Parent(current);
if (string.IsNullOrEmpty(parent)
|| parent.Equals(rootNorm, StringComparison.OrdinalIgnoreCase)
|| parent.Equals(rootNorm + "\\", StringComparison.OrdinalIgnoreCase))
{
break;
}
if (ExistsDirectory(parent) || ExistsFile(parent))
{
break;
}
missing = PathRules.MakeRelative(root, parent);
current = parent;
}
return missing;
}
public static string ReconcilePath(string pathRel, string missingPrefix)
{
if (!missingPrefix.Equals(pathRel, StringComparison.OrdinalIgnoreCase))
{
return missingPrefix;
}
var slash = pathRel.LastIndexOf('\\');
return slash <= 0 ? "" : pathRel[..slash];
}
internal static bool TryArchiveFile(string pathRel, out string archiveRel)
{
archiveRel = "";
var parts = pathRel.Split('\\', StringSplitOptions.RemoveEmptyEntries);
var acc = new List<string>();
for (var i = 0; i < parts.Length; i++)
{
acc.Add(parts[i]);
if (i < parts.Length - 1 && ArchiveFormats.IsArchive(parts[i]))
{
archiveRel = string.Join('\\', acc);
return true;
}
}
return false;
}
private static bool ExistsFile(string path)
{
var normal = PathRules.FromExtended(path);
if (File.Exists(normal))
{
return true;
}
var ext = PathRules.ToExtended(normal);
return ext != normal && File.Exists(ext);
}
private static bool ExistsDirectory(string path)
{
var normal = PathRules.FromExtended(path);
if (Directory.Exists(normal))
{
return true;
}
var ext = PathRules.ToExtended(normal);
return ext != normal && Directory.Exists(ext);
}
}

View 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;
}
}

View File

@@ -0,0 +1,116 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed class MoveToPlanner
{
public OperationPlan Build(
IReadOnlyList<RenameSubject> subjects,
string pattern,
Func<string, bool>? pathExists = null,
Func<string, bool>? wouldHydrate = null)
{
if (subjects.Count == 0)
{
return new OperationPlan
{
Issues = [new PlanIssue(PlanIssueSeverity.Error, "Select files or folders to move.")]
};
}
var issues = new List<PlanIssue>();
var operations = new List<PlannedOperation>();
var preview = new List<ProfilePreviewRow>();
var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var subject in subjects)
{
if (wouldHydrate?.Invoke(subject.FullPath) == true)
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"This file is online-only. Moving it would download it.",
subject.FullPath));
preview.Add(new ProfilePreviewRow("Skip", subject.Name, "Online-only"));
continue;
}
if (!DestinationPattern.TryResolve(pattern, subject, out var dest, out var error))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error ?? "Invalid destination.", subject.FullPath));
preview.Add(new ProfilePreviewRow("Skip", subject.Name, error));
continue;
}
dest = PathRules.FromExtended(dest);
if (IsExampleHost(dest))
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
@"Replace \\host\share with a real server or mapped drive, or use Browse….",
dest));
preview.Add(new ProfilePreviewRow("Skip", dest, "Example host — not a real share"));
continue;
}
var source = PathRules.FromExtended(subject.FullPath);
if (Same(source, dest))
{
preview.Add(new ProfilePreviewRow("Skip", dest, "Already there"));
continue;
}
if (subject.IsDirectory && SameOrUnder(source, dest))
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"Would move a folder into itself.",
subject.FullPath));
preview.Add(new ProfilePreviewRow("Skip", dest, "Would move a folder into itself."));
continue;
}
if (!taken.Add(dest) || (pathExists?.Invoke(dest) == true && !Same(source, dest)))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", dest));
preview.Add(new ProfilePreviewRow("Skip", dest, "A file with that name already exists."));
continue;
}
operations.Add(new PlannedOperation(TransferOp.Move, subject.FullPath, dest));
preview.Add(new ProfilePreviewRow("Move", dest, subject.Name));
}
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
return new OperationPlan
{
Operations = operations,
Issues = issues,
ProfilePreview = preview
};
}
private static bool Same(string left, string right)
{
var a = PathRules.FromExtended(left).TrimEnd('\\');
var b = PathRules.FromExtended(right).TrimEnd('\\');
return a.Equals(b, StringComparison.OrdinalIgnoreCase);
}
private static bool SameOrUnder(string parent, string child)
{
var p = PathRules.FromExtended(parent).TrimEnd('\\');
var c = PathRules.FromExtended(child).TrimEnd('\\');
return c.StartsWith(p + "\\", StringComparison.OrdinalIgnoreCase);
}
private static bool IsExampleHost(string destination)
{
var root = PathRules.CanonicalUncRoot(destination);
return root.Equals(@"\\host", StringComparison.OrdinalIgnoreCase)
|| root.StartsWith(@"\\host\", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,42 @@
namespace Explorer.Application;
public static class NotepadPlusPlusLocator
{
public const string MissingHint = "Notepad++ is not installed.";
public static string? Find(Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
"Notepad++",
"notepad++.exe");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
"Notepad++",
"notepad++.exe");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Programs",
"Notepad++",
"notepad++.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "notepad++.exe");
}
}
}

View File

@@ -0,0 +1,32 @@
using Explorer.Domain;
using Explorer.Plugin.Abstractions;
namespace Explorer.Application;
public sealed class NullCloudOverlay : ICloudOverlay
{
public static NullCloudOverlay Instance { get; } = new();
public IReadOnlyList<ProviderPlace> GetPlaces() => [];
public string? FindProviderId(string path) => null;
public bool HasCapability(string path, ProviderCapability capability) => false;
public Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
IReadOnlyList<FileSystemItem> items,
CancellationToken cancellationToken = default)
=> Task.FromResult(items);
public Task<ProviderActionResult> InvokeAsync(
ProviderAction action,
IReadOnlyList<string> paths,
CancellationToken cancellationToken = default)
=> Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "No cloud provider is available."));
public Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderItemState?>(null);
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
=> Task.FromResult<ProviderQuota?>(null);
}

View File

@@ -0,0 +1,23 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class RemovableAutoIndexPlanner
{
public static IReadOnlyList<long> SourceIdsToScan(IEnumerable<Source> sources, bool autoIndexRemovable)
{
if (!autoIndexRemovable)
{
return [];
}
return sources
.Where(source =>
source.Kind == SourceKind.Removable
&& source.Status == SourceStatus.Online
&& !source.IsIndexed
&& !string.IsNullOrWhiteSpace(source.LastRootPath))
.Select(source => source.Id)
.ToList();
}
}

View File

@@ -10,7 +10,8 @@ public sealed class RenamePlanner
public OperationPlan Build(
IReadOnlyList<RenameSubject> subjects,
RenameRuleSet rules,
Func<string, bool>? pathExists = null)
Func<string, bool>? pathExists = null,
IReadOnlyDictionary<string, MediaTagFields>? tagsByPath = null)
{
var issues = new List<PlanIssue>();
Regex? regex = null;
@@ -42,7 +43,21 @@ public sealed class RenamePlanner
string newName;
try
{
newName = Apply(subject.Name, rules, index, regex);
var tags = tagsByPath is not null && tagsByPath.TryGetValue(subject.FullPath, out var found)
? found
: FilenamePattern.FromFile(subject.FullPath, subject.IsDirectory);
if (NeedsMedia(rules) && tags.HydrationBlocked)
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"This file is online-only. Reading tags would download it.",
subject.FullPath));
rows.Add((subject, subject.Name, subject.FullPath, true, "Online-only"));
index++;
continue;
}
newName = Apply(subject.Name, rules, index, regex, tags);
}
catch (RegexMatchTimeoutException)
{
@@ -186,27 +201,38 @@ public sealed class RenamePlanner
return new OperationPlan { Operations = operations };
}
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex)
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex, MediaTagFields? tags = null)
{
var (stem, extension) = WindowsFileNames.Split(name);
var text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
tags ??= new MediaTagFields { Stem = stem, Extension = extension };
var counter = rules.UseCounter
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
: "";
string text;
if (!string.IsNullOrWhiteSpace(rules.NamePattern))
{
text = FilenamePattern.Expand(rules.NamePattern, tags, counter);
}
else
{
text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
}
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
{
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
}
else if (rules.UseCounter)
else if (rules.UseCounter && string.IsNullOrWhiteSpace(rules.NamePattern))
{
text += counter;
}
text = FilenamePattern.Expand(text, tags, counter);
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension && string.IsNullOrWhiteSpace(rules.NamePattern))
{
return text;
}
@@ -215,6 +241,13 @@ public sealed class RenamePlanner
return WindowsFileNames.Join(text, newExt);
}
private static bool NeedsMedia(RenameRuleSet rules)
=> FilenamePattern.UsesMediaContent(rules.NamePattern)
|| FilenamePattern.UsesMediaContent(rules.Prefix)
|| FilenamePattern.UsesMediaContent(rules.Suffix)
|| FilenamePattern.UsesMediaContent(rules.Replace)
|| FilenamePattern.UsesMediaContent(rules.Search);
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
{
if (string.IsNullOrEmpty(rules.Search))

View File

@@ -15,7 +15,8 @@ public sealed class ReorganizePlanner
Func<string, bool> isRepoRoot,
Func<FileSystemItem, bool>? wouldHydrate = null,
Func<string, bool>? pathExists = null,
DateTimeOffset? now = null)
DateTimeOffset? now = null,
IReadOnlyDictionary<string, IndexEntry>? indexedByName = null)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
{
@@ -63,13 +64,16 @@ public sealed class ReorganizePlanner
var childCategories = item.IsDirectory && !FileClassifier.SkipDescent(item.Name)
? ChildCategories(item, enumerator, isRepoRoot)
: null;
var classification = FileClassifier.Classify(
item.Name,
item.FullPath,
item.IsDirectory,
item.Attributes,
item.IsDirectory && isRepoRoot(item.FullPath),
childCategories);
var classification = PreferIndexed(
item,
indexedByName,
FileClassifier.Classify(
item.Name,
item.FullPath,
item.IsDirectory,
item.Attributes,
item.IsDirectory && isRepoRoot(item.FullPath),
childCategories));
if (FileClassifier.ShouldLeave(classification.Category))
{
@@ -143,6 +147,28 @@ public sealed class ReorganizePlanner
};
}
private static FileClassification PreferIndexed(
FileSystemItem item,
IReadOnlyDictionary<string, IndexEntry>? indexedByName,
FileClassification fallback)
{
if (indexedByName is null
|| !indexedByName.TryGetValue(item.Name, out var stored))
{
return fallback;
}
if (CategorySources.IsUser(stored.CategorySource)
|| stored.Category is not FileCategory.Unknown)
{
return new FileClassification(
stored.Category,
stored.CategoryReason ?? "Indexed category.");
}
return fallback;
}
private static IReadOnlyList<FileCategory> ChildCategories(
FileSystemItem folder,
IFileSystemEnumerator enumerator,

View File

@@ -0,0 +1,203 @@
namespace Explorer.Application;
public static class ShellContextVerbFilter
{
private static readonly HashSet<string> HiddenVerbs = new(StringComparer.OrdinalIgnoreCase)
{
"open",
"opennewprocess",
"opennewwindow",
"explore",
"find",
"print",
"printto",
"cut",
"copy",
"paste",
"pastelink",
"delete",
"rename",
"link",
"properties",
"pintohome",
"unpinfromhome",
"windows.pin",
"windows.share",
"share",
"copypath",
"copyaspath",
"modernshare"
};
private static readonly HashSet<string> HiddenLabels = new(StringComparer.OrdinalIgnoreCase)
{
"Open",
"Cut",
"Copy",
"Paste",
"Delete",
"Rename",
"Properties",
"Open in Cursor",
"Open in Notepad++",
"Edit with Notepad++",
"Open terminal here"
};
public static string CanonicalLabel(string header)
=> header.Replace("&", "", StringComparison.Ordinal).Trim();
public static bool ShouldInclude(string? verb, string label)
{
var text = CanonicalLabel(label);
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
if (!string.IsNullOrWhiteSpace(verb) && HiddenVerbs.Contains(verb.Trim('\0')))
{
return false;
}
return !HiddenLabels.Contains(text);
}
public static IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> Prune(
IEnumerable<Explorer.Domain.Abstractions.ShellContextVerb> items,
int maxTopLevel = 30)
{
var kept = new List<Explorer.Domain.Abstractions.ShellContextVerb>();
foreach (var item in items)
{
if (kept.Count >= maxTopLevel)
{
break;
}
if (item.Children is not null)
{
var children = Prune(item.Children, 40);
if (children.Count == 0)
{
continue;
}
kept.Add(item with { Children = children });
continue;
}
if (!ShouldInclude(VerbFromId(item.Id), item.Label))
{
continue;
}
kept.Add(item);
}
return kept;
}
public static IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> MergeByLabel(
IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> primary,
IReadOnlyList<Explorer.Domain.Abstractions.ShellContextVerb> extra)
{
var merged = new List<Explorer.Domain.Abstractions.ShellContextVerb>(primary);
var seen = new HashSet<string>(
primary.Select(item => CanonicalLabel(item.Label)),
StringComparer.OrdinalIgnoreCase);
foreach (var item in extra)
{
if (seen.Add(CanonicalLabel(item.Label)))
{
merged.Add(item);
}
}
return merged;
}
public static string? VerbFromId(string id)
{
const string prefix = "verb:";
return id.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
? id[prefix.Length..]
: null;
}
}
public static class ShellVerbCommand
{
public static bool TrySplit(string command, out string executable, out string arguments)
{
executable = "";
arguments = "";
var text = command.Trim();
if (text.Length == 0)
{
return false;
}
if (text.StartsWith('"'))
{
var close = text.IndexOf('"', 1);
if (close <= 1)
{
return false;
}
executable = text[1..close];
arguments = text[(close + 1)..].Trim();
return executable.Length > 0;
}
var space = text.IndexOf(' ');
if (space < 0)
{
executable = text;
return true;
}
executable = text[..space];
arguments = text[(space + 1)..].Trim();
return executable.Length > 0;
}
public static IReadOnlyList<string> ExpandInvocations(string arguments, IReadOnlyList<string> paths)
{
if (paths.Count == 0)
{
return [];
}
var quoted = paths.Select(Quote).ToList();
var all = string.Join(' ', quoted);
var first = quoted[0];
if (arguments.Contains("%*", StringComparison.OrdinalIgnoreCase))
{
return [ReplaceTokens(arguments, first, all)];
}
if (paths.Count > 1
&& (arguments.Contains("%1", StringComparison.OrdinalIgnoreCase)
|| arguments.Contains("%L", StringComparison.OrdinalIgnoreCase)
|| arguments.Contains("%V", StringComparison.OrdinalIgnoreCase)))
{
return paths.Select(path => ReplaceTokens(arguments, Quote(path), Quote(path))).ToList();
}
return [ReplaceTokens(arguments, first, all)];
}
private static string ReplaceTokens(string arguments, string first, string all)
=> arguments
.Replace("%1", first, StringComparison.OrdinalIgnoreCase)
.Replace("%L", first, StringComparison.OrdinalIgnoreCase)
.Replace("%V", first, StringComparison.OrdinalIgnoreCase)
.Replace("%*", all, StringComparison.OrdinalIgnoreCase);
public static string Quote(string path)
=> path.Contains(' ', StringComparison.Ordinal) || path.Contains('\t', StringComparison.Ordinal)
? "\"" + path.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""
: path;
}

View File

@@ -1,4 +1,5 @@
using System.Diagnostics;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Microsoft.Extensions.Logging;
@@ -12,29 +13,45 @@ public sealed class SourceManager
private readonly IAppEnvironment _env;
private readonly IClock _clock;
private readonly ILogger<SourceManager> _logger;
private readonly ISourceHost? _remote;
private readonly object _refreshLock = new();
private Task<IReadOnlyList<Source>>? _refreshInFlight;
private long _refreshCacheTimestamp;
private static readonly TimeSpan RefreshCacheTtl = TimeSpan.FromSeconds(2);
public event EventHandler<Source>? PresenceChanged;
public SourceManager(
IIndexStore store,
IVolumeService volumes,
IAppEnvironment env,
IClock clock,
ILogger<SourceManager> logger)
ILogger<SourceManager> logger,
ISourceHost? remote = null)
{
_store = store;
_volumes = volumes;
_env = env;
_clock = clock;
_logger = logger;
_remote = remote;
}
public async Task InitializeAsync(CancellationToken cancellationToken = default)
{
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
InvalidateRefreshCache();
return;
}
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
@@ -70,6 +87,22 @@ public sealed class SourceManager
private async Task<IReadOnlyList<Source>> RefreshOnlineStateCoreAsync(CancellationToken cancellationToken)
{
if (!_store.CanWrite)
{
if (_remote is not null)
{
await _remote.RefreshAsync(cancellationToken).ConfigureAwait(false);
}
var snapshot = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
lock (_refreshLock)
{
_refreshCacheTimestamp = Stopwatch.GetTimestamp();
}
return snapshot;
}
var started = Stopwatch.GetTimestamp();
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
var online = _volumes.EnumerateOnlineVolumes();
@@ -145,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;
}
@@ -201,6 +235,18 @@ public sealed class SourceManager
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
{
if (!_store.CanWrite)
{
if (_remote is null)
{
throw new InvalidOperationException("Cannot add a network location while the index is read-only.");
}
var added = await _remote.AddUncAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return added;
}
var root = PathRules.CanonicalUncRoot(path);
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var fp = new VolumeFingerprint
@@ -239,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();
@@ -269,6 +386,18 @@ public sealed class SourceManager
public async Task<bool> ForgetDisconnectedAsync(string path, CancellationToken cancellationToken = default)
{
if (!_store.CanWrite)
{
if (_remote is null)
{
return false;
}
var forgotten = await _remote.ForgetAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return forgotten;
}
var source = await FindSourceRootAsync(path, cancellationToken).ConfigureAwait(false);
if (source is null || !CanForget(source))
{
@@ -324,6 +453,18 @@ public sealed class SourceManager
return null;
}
if (!_store.CanWrite)
{
if (_remote is null)
{
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
}
var ensured = await _remote.EnsureForPathAsync(path, cancellationToken).ConfigureAwait(false);
InvalidateRefreshCache();
return ensured;
}
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
@@ -461,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

Some files were not shown because too many files have changed in this diff Show More