Compare commits
6 Commits
feature/ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 222b5d9969 | |||
| b33a78dbbe | |||
| b72c375e87 | |||
| ff070beba4 | |||
| 66ea25993f | |||
| e2916aef9c |
30
Backlog.md
30
Backlog.md
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -839,7 +839,7 @@ Bitte diese Punkte klären. Empfohlene Defaults in Klammern:
|
||||
|
||||
## Hintergrundprozess: Explorer.Host.exe
|
||||
|
||||
Indexing, USN/watchers, transfer queue, hash worker, and history rollup run in **`Explorer.Host.exe`**, not in the WPF window.
|
||||
Indexing, USN/watchers, transfer queue, hash worker, and history rollup run in **`Explorer.Host.exe`**, not in the WPF window. Idle-aware background maintenance is coordinated there as well (`GetLastInputInfo` / power status — no WPF dependency).
|
||||
|
||||
| | Explorer.Host.exe (current) | Windows Service |
|
||||
|---|---|---|
|
||||
@@ -893,7 +893,7 @@ Deviations from the design above, with reasons:
|
||||
3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable.
|
||||
4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain.
|
||||
5. **App data folder** — `%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer.
|
||||
6. **Background work** — indexer, transfer queue, hash worker, history rollup, and watchers run as `IHostedService` instances inside **`Explorer.Host.exe`**. The WPF window is a named-pipe client (`Explorer.Hosting.Client`) with a read-only SQLite store. No Windows Service; optional current-user sign-in (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`).
|
||||
6. **Background work** — indexer, transfer queue, hash worker, and watchers run as `IHostedService` instances inside **`Explorer.Host.exe`**. `BackgroundMaintenanceCoordinator` is the one extra 1s timer: it uses `IUserIdleMonitor` / `BackgroundWorkPolicy` to pause or resume duplicate hashing, enqueue at most one idle local full scan (`IndexWorkOrigin.Idle`, distinct from user/watcher/USN work), and call `HistoryRollupService` (no longer its own hosted loop). Copy/move/delete and explicit scans are never idle work. The WPF window is a named-pipe client (`Explorer.Hosting.Client`) with a read-only SQLite store. No Windows Service; optional current-user sign-in (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`).
|
||||
7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7).
|
||||
8. **UNIQUE identity** — `UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct.
|
||||
9. **INSERT ids** — `Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`.
|
||||
@@ -901,4 +901,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 folder’s children in SQL (not a client-side filter after paging).
|
||||
13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read.
|
||||
14. **Index freshness** — folder sizes in the listing come from `aggregate_size`. Watcher reconcile stays one directory deep. Opening a folder enqueues a 1-level reconcile of that path, then `FolderDisplayRefresh` probes up to 8 largest/visible child dirs (child count). Mismatches get `EnqueueVerify` for that child only and are cancelled when the browse generation changes. Idle maintenance still verifies each local indexed source (deep walk, cap 48). USN directory deletes also tombstone the path prefix.
|
||||
15. **Browse names first** — live folder listing starts without waiting for source lookup, archive index, `MarkReachable`, or child-index overlay. The first name is published immediately (`BrowseHydration.FirstPublish`); folder sizes, cloud state, and Git badges arrive as later `BrowseDelta` updates. Archive paths still resolve through the index before live enumeration.
|
||||
16. **Shell context verbs** — the compact item menu only reads static registry verbs (no `IContextMenu` on right-click; that AV’d 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.
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
| --- | --- |
|
||||
| `%LocalAppData%\ExplorerWorkbench\index.db` | Index, queue, profiles |
|
||||
| `%LocalAppData%\ExplorerWorkbench\logs\` | Rolling logs |
|
||||
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations |
|
||||
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations, favorite folders, saved name patterns, Move to history |
|
||||
|
||||
---
|
||||
|
||||
@@ -52,9 +52,10 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
|
||||
- **Title bar** — Explorer Workbench; minimize / maximize / close.
|
||||
- **Menu** — File (including Stop background host), View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives and Convert), Automation, Development, Recycle Bin.
|
||||
- **Toolbar** — path, navigation, view mode, search, storage, queue summary.
|
||||
- **Tree** — This PC, Network, Cloud (grouping is optional in Settings).
|
||||
- **Folder pane** — details, list, or preview. Split pane is optional.
|
||||
- **Toolbar** — navigation, view mode, search.
|
||||
- **Tree** — Favorites, Home, This PC, Network, Cloud (Network/Cloud grouping and tree synchronization are in Settings → Navigation).
|
||||
- **Folder pane** — breadcrumb bar (click the empty space, or `Ctrl+L` / `Alt+D`, to type a path), then details, list, or preview. Split pane is optional.
|
||||
- **Status bar** — item count and size for the active folder, or for the current selection; Git badge; queue.
|
||||
- **Queue** — compact status; expand for the full File Operations Queue.
|
||||
|
||||
### Tabs and panes
|
||||
@@ -65,6 +66,7 @@ Specialized tools still do specialized jobs. 7-Zip compresses. FFmpeg converts a
|
||||
| Close tab | File → Close tab, or `Ctrl+W` |
|
||||
| Split pane | File → Split pane |
|
||||
| Details / List / Preview | View menu or toolbar |
|
||||
| Type a path | Click empty space in the tab breadcrumb bar, or `Ctrl+L` / `Alt+D` |
|
||||
| Refresh | View → Refresh, or `F5` |
|
||||
|
||||
Tabs, split panes, and the folder shown in each pane are restored the next time you open Workbench.
|
||||
@@ -73,6 +75,18 @@ Tabs, split panes, and the folder shown in each pane are restored the next time
|
||||
|
||||
## Locations
|
||||
|
||||
### Favorites
|
||||
|
||||
Pinned folders of your choosing, at the top of the tree. Add a folder with **Add to Favorites** on the folder or tree context menu, or drop a folder onto the Favorites root. Remove it with **Remove from Favorites**. Unpinning only removes the pin — it does not delete files, forget a location, or change the index.
|
||||
|
||||
Favorites are stored in `ui-preferences.txt`. A pin that points at a missing folder still appears, marked Offline, so you can unpin it.
|
||||
|
||||
The folder pane always shows the real filesystem path. Breadcrumbs stay the same. The locations tree is separate: by default it keeps the branch you are already in. Opening a folder under This PC, Home, Network, or Cloud does not switch the tree to Favorites just because that folder is also pinned. If you opened the folder from a Favorite, the tree stays under Favorites. **Settings → Navigation → Prefer Favorites when synchronizing the locations tree** selects a matching Favorite pin instead.
|
||||
|
||||
### Home
|
||||
|
||||
Documents, Downloads, Pictures, Videos, and Music from Windows known folders. They have their own root, not under This PC. Folders that do not exist on this PC are omitted.
|
||||
|
||||
### This PC
|
||||
|
||||
Local NTFS volumes and removable disks. Capacity and free space show next to size where Windows reports them.
|
||||
@@ -81,6 +95,8 @@ Local NTFS volumes and removable disks. Capacity and free space show next to siz
|
||||
|
||||
Add a UNC path with **Tools → Locations → Add network…** (`\\server\share`). Mapped Windows drive letters can be imported when Workbench discovers them. Forgetting a Workbench location does **not** disconnect the Windows mapping.
|
||||
|
||||
After a Windows start, shares can show **Offline** until they answer. Opening a folder or a file on the share marks it online in the tree; sleeping NAS boxes may take a second or two.
|
||||
|
||||
### Cloud
|
||||
|
||||
OneDrive, Google Drive, and Nextcloud appear when you add their **mounted Windows folders** (**Tools → Locations → Add OneDrive…** / **Google Drive…** / **Nextcloud…**). Workbench browses those paths with Win32 like any other folder. Plugins only overlay status, pin/dehydrate actions, and quota. Cloud folders stay ordinary locations — they are not a separate “cloud filesystem.”
|
||||
@@ -102,13 +118,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 Workbench’s 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 |
|
||||
| --- | --- |
|
||||
@@ -118,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -134,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.
|
||||
|
||||
@@ -152,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 |
|
||||
| --- | --- |
|
||||
@@ -198,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 |
|
||||
| --- | --- |
|
||||
@@ -221,7 +280,7 @@ Jobs go through the queue. Online-only cloud archives are refused so Workbench w
|
||||
|
||||
## Convert
|
||||
|
||||
Needs **`ffmpeg.exe`** on the machine. FFmpeg is usually a zip, not an installer: unpack a Windows build and either put `ffmpeg.exe` on PATH, under `Program Files\ffmpeg\bin\`, or point Settings at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
|
||||
Needs **`ffmpeg.exe`** on the machine. FFmpeg is usually a zip, not an installer: unpack a Windows build and either put `ffmpeg.exe` on PATH, under `Program Files\ffmpeg\bin\`, or point Settings → File Operations at the file. `ffprobe` / `ffplay` are not required. FFmpeg is not bundled. This is not HandBrake — a few conversions only.
|
||||
|
||||
| Kind | Output |
|
||||
| --- | --- |
|
||||
@@ -284,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.
|
||||
|
||||
@@ -300,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -308,7 +382,7 @@ 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
|
||||
|
||||
@@ -318,21 +392,18 @@ Workbench never starts a cloud vendor’s own two-way sync. It never hydrates a
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings** in the menu:
|
||||
**Settings** opens a window with categories on the left. The last category you opened is remembered until you quit Workbench. Empty categories are omitted.
|
||||
|
||||
- Dark / Light theme
|
||||
- Group network under Network; group cloud under Cloud (independent)
|
||||
- Show hidden files
|
||||
- Show protected system locations
|
||||
- Auto-clear queue when done
|
||||
- Include archive contents in the index
|
||||
- Automatically index removable drives when they appear
|
||||
- Start Explorer.Host.exe at Windows sign-in (current-user Startup, no administrator rights)
|
||||
- Path to 7-Zip
|
||||
- Path to git.exe
|
||||
- Path to ffmpeg.exe
|
||||
| Category | Options |
|
||||
| --- | --- |
|
||||
| General | Start Explorer.Host.exe at Windows sign-in (current-user Startup, no administrator rights) |
|
||||
| Appearance | Dark / Light theme |
|
||||
| Navigation | Group network under Network; group cloud under Cloud (independent); prefer Favorites when synchronizing the locations tree (off by default); show hidden files; show protected system locations |
|
||||
| File Operations | Auto-clear queue when done; path to 7-Zip; path to ffmpeg.exe |
|
||||
| Indexing | Include archive contents in the index; automatically index removable drives when they appear; enable idle background maintenance; idle threshold (5 / 10 / 30 minutes); only run expensive maintenance on AC power |
|
||||
| Advanced | Path to git.exe |
|
||||
|
||||
These options change what Workbench shows and indexes. They do not change Windows Explorer settings.
|
||||
These options change what Workbench shows and indexes. They do not change Windows Explorer settings. Layout, favorite pins, organize destinations, and open tabs are stored in `ui-preferences.txt` but are not edited here.
|
||||
|
||||
---
|
||||
|
||||
@@ -360,9 +431,8 @@ Left open on purpose:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<RootNamespace>Explorer.Analysis</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Dark.xaml"/>
|
||||
<ResourceDictionary Source="Settings/SettingsStyles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<FontFamily x:Key="Symbol">Segoe MDL2 Assets</FontFamily>
|
||||
<BooleanToVisibilityConverter x:Key="BoolVis"/>
|
||||
<local:InverseBooleanToVisibilityConverter x:Key="InvBoolVis"/>
|
||||
<local:ActiveThicknessConverter x:Key="ActiveThickness"/>
|
||||
<local:FractionWidthConverter x:Key="FractionWidth"/>
|
||||
<local:IndentConverter x:Key="Indent"/>
|
||||
@@ -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="" 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">
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
78
src/Explorer.App/DatabaseWindow.xaml
Normal file
78
src/Explorer.App/DatabaseWindow.xaml
Normal 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>
|
||||
159
src/Explorer.App/DatabaseWindow.xaml.cs
Normal file
159
src/Explorer.App/DatabaseWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
src/Explorer.App/DetailsColumnLayout.cs
Normal file
59
src/Explorer.App/DetailsColumnLayout.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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}"/>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
79
src/Explorer.App/ExplorerAddressBar.xaml
Normal file
79
src/Explorer.App/ExplorerAddressBar.xaml
Normal file
@@ -0,0 +1,79 @@
|
||||
<UserControl x:Class="Explorer.App.ExplorerAddressBar"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Focusable="False">
|
||||
<DockPanel LastChildFill="True" Margin="6,6,6,4">
|
||||
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
|
||||
Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding UpCommand}"
|
||||
IsEnabled="{Binding CanGoUp}"
|
||||
ToolTip="Up one folder"/>
|
||||
<Grid MinHeight="28">
|
||||
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="4,0"
|
||||
Cursor="IBeam"
|
||||
MouseLeftButtonDown="OnBreadcrumbMouseDown"
|
||||
Visibility="{Binding IsEditingPath, Converter={StaticResource InvBoolVis}}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
Focusable="False" HorizontalAlignment="Stretch">
|
||||
<ItemsControl ItemsSource="{Binding Breadcrumb}" HorizontalAlignment="Left">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Style="{StaticResource CrumbButton}"
|
||||
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||
CommandParameter="{Binding}"
|
||||
Cursor="Hand"
|
||||
ToolTip="{Binding Path}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock FontFamily="{StaticResource Symbol}" Text="" FontSize="14"
|
||||
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Label}" Value="This PC">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<TextBlock Text="›" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<ComboBox x:Name="PathCombo"
|
||||
Style="{StaticResource PathComboBox}"
|
||||
VerticalAlignment="Stretch"
|
||||
ItemsSource="{Binding DataContext.PathHistory, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
Text="{Binding PathEditText, UpdateSourceTrigger=PropertyChanged}"
|
||||
Visibility="{Binding IsEditingPath, Converter={StaticResource BoolVis}}"
|
||||
PreviewKeyDown="OnPathKeyDown"
|
||||
SelectionChanged="OnPathHistorySelected"
|
||||
LostKeyboardFocus="OnPathLostKeyboardFocus"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
178
src/Explorer.App/ExplorerAddressBar.xaml.cs
Normal file
178
src/Explorer.App/ExplorerAddressBar.xaml.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
public partial class ExplorerAddressBar : UserControl
|
||||
{
|
||||
private bool _suppressLostFocus;
|
||||
|
||||
public ExplorerAddressBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
IsVisibleChanged += (_, _) =>
|
||||
{
|
||||
if (IsVisible && DataContext is ExplorerPaneViewModel { IsEditingPath: true })
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void FocusEditor()
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
PathCombo.ApplyTemplate();
|
||||
if (PathCombo.Template.FindName("PART_EditableTextBox", PathCombo) is TextBox box)
|
||||
{
|
||||
box.Focus();
|
||||
box.SelectAll();
|
||||
}
|
||||
else
|
||||
{
|
||||
PathCombo.Focus();
|
||||
}
|
||||
}, DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is ExplorerPaneViewModel previous)
|
||||
{
|
||||
previous.PropertyChanged -= OnPanePropertyChanged;
|
||||
}
|
||||
|
||||
if (e.NewValue is ExplorerPaneViewModel pane)
|
||||
{
|
||||
pane.PropertyChanged += OnPanePropertyChanged;
|
||||
if (pane.IsEditingPath)
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanePropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(ExplorerPaneViewModel.IsEditingPath)
|
||||
&& sender is ExplorerPaneViewModel { IsEditingPath: true })
|
||||
{
|
||||
FocusEditor();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBreadcrumbMouseDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pane.BeginEditPath();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnPathKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
CommitPath();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
_suppressLostFocus = true;
|
||||
pane.CancelEditPath();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is not ExplorerPaneViewModel pane)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (NavigationTreeViewModel.PathsEqual(path, pane.CurrentPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
|
||||
{
|
||||
pane.PathEditText = path;
|
||||
CommitPath();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPathLostKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
|
||||
{
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
if (_suppressLostFocus)
|
||||
{
|
||||
_suppressLostFocus = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (PathCombo.IsDropDownOpen || PathCombo.IsKeyboardFocusWithin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DataContext is ExplorerPaneViewModel pane)
|
||||
{
|
||||
pane.CancelEditPath();
|
||||
}
|
||||
}, DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
private void CommitPath()
|
||||
{
|
||||
if (Window.GetWindow(this)?.DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.GoCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInsideButton(DependencyObject origin)
|
||||
{
|
||||
for (var current = origin; current is not null;)
|
||||
{
|
||||
if (current is Button)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current is Visual
|
||||
? VisualTreeHelper.GetParent(current)
|
||||
: LogicalTreeHelper.GetParent(current);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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}"/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}}"/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
130
src/Explorer.App/HostActivityMonitorWindow.xaml
Normal file
130
src/Explorer.App/HostActivityMonitorWindow.xaml
Normal 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>
|
||||
139
src/Explorer.App/HostActivityMonitorWindow.xaml.cs
Normal file
139
src/Explorer.App/HostActivityMonitorWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
462
src/Explorer.App/ListMarquee.cs
Normal file
462
src/Explorer.App/ListMarquee.cs
Normal file
@@ -0,0 +1,462 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
internal sealed class ListMarquee
|
||||
{
|
||||
private readonly DispatcherTimer _scroll = new() { Interval = TimeSpan.FromMilliseconds(20) };
|
||||
private ListView? _list;
|
||||
private MarqueeAdorner? _adorner;
|
||||
private Point _startContent;
|
||||
private Point _currentList;
|
||||
private object[] _kept = [];
|
||||
private MouseButton _button;
|
||||
|
||||
public ListMarquee() => _scroll.Tick += (_, _) => AutoScroll();
|
||||
|
||||
public bool IsArmed { get; private set; }
|
||||
public bool IsActive { get; private set; }
|
||||
|
||||
public void Arm(ListView list, MouseButtonEventArgs e, bool additive)
|
||||
{
|
||||
Cancel();
|
||||
_list = list;
|
||||
_button = e.ChangedButton;
|
||||
_kept = additive ? list.SelectedItems.Cast<object>().ToArray() : [];
|
||||
_startContent = ToContent(list, e.GetPosition(list));
|
||||
_currentList = e.GetPosition(list);
|
||||
IsArmed = true;
|
||||
list.Focus();
|
||||
if (!additive)
|
||||
{
|
||||
list.UnselectAll();
|
||||
}
|
||||
}
|
||||
|
||||
public void Disarm()
|
||||
{
|
||||
if (!IsActive)
|
||||
{
|
||||
IsArmed = false;
|
||||
_list = null;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryActivate(Point currentOnList)
|
||||
{
|
||||
if (!IsArmed || IsActive || _list is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Math.Abs(currentOnList.X - _currentList.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||
&& Math.Abs(currentOnList.Y - _currentList.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IsActive = true;
|
||||
_list.CaptureMouse();
|
||||
_list.Focus();
|
||||
var layer = AdornerLayer.GetAdornerLayer(_list);
|
||||
if (layer is not null)
|
||||
{
|
||||
_adorner = new MarqueeAdorner(_list);
|
||||
layer.Add(_adorner);
|
||||
}
|
||||
|
||||
_scroll.Start();
|
||||
Update(currentOnList);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Update(Point currentOnList)
|
||||
{
|
||||
if (!IsActive || _list is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentList = currentOnList;
|
||||
ApplyHits();
|
||||
UpdateAdorner();
|
||||
}
|
||||
|
||||
public bool TryActivateFromMouse()
|
||||
=> _list is not null && TryActivate(Mouse.GetPosition(_list));
|
||||
|
||||
public void UpdateFromMouse()
|
||||
{
|
||||
if (_list is not null)
|
||||
{
|
||||
Update(Mouse.GetPosition(_list));
|
||||
}
|
||||
}
|
||||
|
||||
public ListView? CompleteForContextMenu()
|
||||
{
|
||||
var list = _list;
|
||||
var openMenu = IsActive && _button == MouseButton.Right;
|
||||
Stop(restore: false);
|
||||
return openMenu ? list : null;
|
||||
}
|
||||
|
||||
public void Cancel() => Stop(restore: IsActive);
|
||||
|
||||
private void Stop(bool restore)
|
||||
{
|
||||
_scroll.Stop();
|
||||
if (_list is not null)
|
||||
{
|
||||
if (_adorner is not null)
|
||||
{
|
||||
AdornerLayer.GetAdornerLayer(_list)?.Remove(_adorner);
|
||||
}
|
||||
|
||||
if (_list.IsMouseCaptured)
|
||||
{
|
||||
_list.ReleaseMouseCapture();
|
||||
}
|
||||
|
||||
if (restore)
|
||||
{
|
||||
Restore(_list, _kept);
|
||||
}
|
||||
}
|
||||
|
||||
_adorner = null;
|
||||
_list = null;
|
||||
_kept = [];
|
||||
IsArmed = false;
|
||||
IsActive = false;
|
||||
}
|
||||
|
||||
private void ApplyHits()
|
||||
{
|
||||
if (_list is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var next = new HashSet<object>(_kept);
|
||||
foreach (var item in HitItems(_list, MarqueeListRect()))
|
||||
{
|
||||
next.Add(item);
|
||||
}
|
||||
|
||||
if (next.Count == _list.SelectedItems.Count && next.SetEquals(_list.SelectedItems.Cast<object>()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_list.UnselectAll();
|
||||
foreach (var item in next)
|
||||
{
|
||||
_list.SelectedItems.Add(item);
|
||||
if (_list.ItemContainerGenerator.ContainerFromItem(item) is ListViewItem row)
|
||||
{
|
||||
row.IsSelected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Rect MarqueeListRect()
|
||||
{
|
||||
var start = FromContent(_list!, _startContent);
|
||||
var rect = new Rect(start, _currentList);
|
||||
if (rect.Width < 1)
|
||||
{
|
||||
rect.Width = 1;
|
||||
}
|
||||
|
||||
if (rect.Height < 1)
|
||||
{
|
||||
rect.Height = 1;
|
||||
}
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> HitItems(ListView list, Rect marquee)
|
||||
{
|
||||
var wrap = FindWrapPanel(list);
|
||||
var viewport = ViewportBounds(list);
|
||||
var sampleIndex = -1;
|
||||
var sampleBounds = Rect.Empty;
|
||||
var hits = new HashSet<int>();
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.ItemContainerGenerator.ContainerFromIndex(i) is not ListViewItem row
|
||||
|| row.ActualHeight < 1)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var origin = row.TranslatePoint(new Point(0, 0), list);
|
||||
var bounds = wrap is null
|
||||
? new Rect(viewport.X, origin.Y, Math.Max(viewport.Width, 1), row.ActualHeight)
|
||||
: new Rect(origin, new Size(Math.Max(row.ActualWidth, 1), Math.Max(row.ActualHeight, 1)));
|
||||
if (sampleIndex < 0)
|
||||
{
|
||||
sampleIndex = i;
|
||||
sampleBounds = bounds;
|
||||
}
|
||||
|
||||
if (bounds.IntersectsWith(marquee))
|
||||
{
|
||||
hits.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrap is null && sampleIndex >= 0 && sampleBounds.Height > 0)
|
||||
{
|
||||
var first = (int)Math.Floor((marquee.Top - sampleBounds.Y) / sampleBounds.Height) + sampleIndex;
|
||||
var last = (int)Math.Ceiling((marquee.Bottom - sampleBounds.Y) / sampleBounds.Height) - 1 + sampleIndex;
|
||||
first = Math.Clamp(first, 0, list.Items.Count - 1);
|
||||
last = Math.Clamp(last, 0, list.Items.Count - 1);
|
||||
for (var i = first; i <= last; i++)
|
||||
{
|
||||
hits.Add(i);
|
||||
}
|
||||
}
|
||||
else if (wrap is not null)
|
||||
{
|
||||
foreach (var index in Hits(list, ToContent(list, marquee.TopLeft), ToContent(list, marquee.BottomRight)))
|
||||
{
|
||||
hits.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var index in hits.OrderBy(i => i))
|
||||
{
|
||||
yield return list.Items[index];
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdorner()
|
||||
{
|
||||
if (_list is null || _adorner is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = MarqueeListRect();
|
||||
rect.Intersect(ViewportBounds(_list));
|
||||
_adorner.Bounds = rect;
|
||||
_adorner.InvalidateVisual();
|
||||
}
|
||||
|
||||
private void AutoScroll()
|
||||
{
|
||||
if (_list is null || Mouse.LeftButton != MouseButtonState.Pressed && Mouse.RightButton != MouseButtonState.Pressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = Mouse.GetPosition(_list);
|
||||
_currentList = pos;
|
||||
var zone = 32d;
|
||||
var viewer = FindScrollViewer(_list);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var local = _list.TranslatePoint(pos, viewer);
|
||||
if (local.Y < zone)
|
||||
{
|
||||
viewer.LineUp();
|
||||
}
|
||||
else if (local.Y > viewer.ActualHeight - zone)
|
||||
{
|
||||
viewer.LineDown();
|
||||
}
|
||||
}
|
||||
|
||||
ApplyHits();
|
||||
UpdateAdorner();
|
||||
}
|
||||
|
||||
public static bool IsBackground(DependencyObject? source)
|
||||
{
|
||||
while (source is not null)
|
||||
{
|
||||
if (source is ListViewItem or GridViewColumnHeader or ScrollBar or Thumb)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (source is ListView)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
source = source is Visual
|
||||
? VisualTreeHelper.GetParent(source)
|
||||
: LogicalTreeHelper.GetParent(source);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> Hits(ListView list, Point startContent, Point currentContent)
|
||||
{
|
||||
var count = list.Items.Count;
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
return MarqueeRange.Wrap(
|
||||
startContent.X, startContent.Y, currentContent.X, currentContent.Y,
|
||||
count, wrap.Columns, wrap.ItemWidth, wrap.ItemHeight);
|
||||
}
|
||||
|
||||
return MarqueeRange.Stack(startContent.Y, currentContent.Y, count, RowHeight(list));
|
||||
}
|
||||
|
||||
private static Point ToContent(ListView list, Point listPoint)
|
||||
{
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
var p = list.TranslatePoint(listPoint, wrap);
|
||||
return new Point(p.X, p.Y + wrap.VerticalOffset);
|
||||
}
|
||||
|
||||
var presenter = (UIElement?)FindItemsPresenter(list) ?? list;
|
||||
var local = list.TranslatePoint(listPoint, presenter);
|
||||
var viewer = FindScrollViewer(list);
|
||||
var height = RowHeight(list);
|
||||
if (viewer is { CanContentScroll: true } && height > 0)
|
||||
{
|
||||
return new Point(local.X, viewer.VerticalOffset * height + local.Y);
|
||||
}
|
||||
|
||||
return new Point(local.X, (viewer?.VerticalOffset ?? 0) + local.Y);
|
||||
}
|
||||
|
||||
private static Point FromContent(ListView list, Point content)
|
||||
{
|
||||
if (FindWrapPanel(list) is { } wrap)
|
||||
{
|
||||
return wrap.TranslatePoint(new Point(content.X, content.Y - wrap.VerticalOffset), list);
|
||||
}
|
||||
|
||||
var presenter = FindItemsPresenter(list);
|
||||
if (presenter is null)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
var viewer = FindScrollViewer(list);
|
||||
var height = RowHeight(list);
|
||||
double y;
|
||||
if (viewer is { CanContentScroll: true } && height > 0)
|
||||
{
|
||||
y = content.Y - viewer.VerticalOffset * height;
|
||||
}
|
||||
else
|
||||
{
|
||||
y = content.Y - (viewer?.VerticalOffset ?? 0);
|
||||
}
|
||||
|
||||
return presenter.TranslatePoint(new Point(content.X, y), list);
|
||||
}
|
||||
|
||||
private static Rect ViewportBounds(ListView list)
|
||||
{
|
||||
var presenter = FindItemsPresenter(list);
|
||||
if (presenter is null)
|
||||
{
|
||||
return new Rect(list.RenderSize);
|
||||
}
|
||||
|
||||
var origin = presenter.TranslatePoint(new Point(0, 0), list);
|
||||
return new Rect(origin, presenter.RenderSize);
|
||||
}
|
||||
|
||||
private static double RowHeight(ListView list)
|
||||
{
|
||||
for (var i = 0; i < list.Items.Count; i++)
|
||||
{
|
||||
if (list.ItemContainerGenerator.ContainerFromIndex(i) is ListViewItem { ActualHeight: > 1 } row)
|
||||
{
|
||||
return row.ActualHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return 28;
|
||||
}
|
||||
|
||||
private static void Restore(ListView list, IReadOnlyList<object> kept)
|
||||
{
|
||||
list.UnselectAll();
|
||||
foreach (var item in kept)
|
||||
{
|
||||
list.SelectedItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private static ItemsPresenter? FindItemsPresenter(DependencyObject root) => FindChild<ItemsPresenter>(root);
|
||||
|
||||
private static ScrollViewer? FindScrollViewer(DependencyObject root) => FindChild<ScrollViewer>(root);
|
||||
|
||||
private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root) => FindChild<VirtualizingWrapPanel>(root);
|
||||
|
||||
private static T? FindChild<T>(DependencyObject root)
|
||||
where T : DependencyObject
|
||||
{
|
||||
if (root is T match)
|
||||
{
|
||||
return match;
|
||||
}
|
||||
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
|
||||
{
|
||||
var found = FindChild<T>(VisualTreeHelper.GetChild(root, i));
|
||||
if (found is not null)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private sealed class MarqueeAdorner : Adorner
|
||||
{
|
||||
public MarqueeAdorner(UIElement adorned) : base(adorned)
|
||||
{
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
|
||||
public Rect Bounds { get; set; }
|
||||
|
||||
protected override void OnRender(DrawingContext drawingContext)
|
||||
{
|
||||
if (Bounds.IsEmpty || Bounds.Width < 1 || Bounds.Height < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var accent = TryAccent();
|
||||
var fill = new SolidColorBrush(Color.FromArgb(0x55, accent.R, accent.G, accent.B));
|
||||
var stroke = new SolidColorBrush(Color.FromArgb(0xE0, accent.R, accent.G, accent.B));
|
||||
fill.Freeze();
|
||||
stroke.Freeze();
|
||||
drawingContext.DrawRectangle(fill, new Pen(stroke, 1), Bounds);
|
||||
}
|
||||
|
||||
private Color TryAccent()
|
||||
{
|
||||
if (AdornedElement is FrameworkElement fe
|
||||
&& fe.TryFindResource("Accent") is SolidColorBrush brush)
|
||||
{
|
||||
return brush.Color;
|
||||
}
|
||||
|
||||
return Color.FromRgb(0x60, 0xCD, 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,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"
|
||||
@@ -61,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}"/>
|
||||
@@ -71,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/>
|
||||
@@ -97,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"
|
||||
@@ -115,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"/>
|
||||
@@ -184,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}"/>
|
||||
@@ -200,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}}"/>
|
||||
@@ -363,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>
|
||||
@@ -405,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>
|
||||
@@ -417,6 +519,9 @@
|
||||
</Border>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -434,89 +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}}"/>
|
||||
<MenuItem Header="Convert…" Click="OnConvert"
|
||||
Visibility="{Binding ShowConvert, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
|
||||
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
|
||||
<MenuItem Header="View changes…" Click="OnGitChanges"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Commit…" Click="OnGitCommit"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Fetch" Click="OnGitFetch"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (fast-forward)" Click="OnGitPull"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Pull (merge)" Click="OnGitPullMerge"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Push" Click="OnGitPush"
|
||||
Visibility="{Binding ShowGitActions, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open terminal here" Command="{Binding OpenTerminalCommand}"
|
||||
Visibility="{Binding ShowOpenTerminal, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Open in Cursor" Command="{Binding OpenInCursorCommand}"
|
||||
Visibility="{Binding ShowOpenInCursor, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="Refresh" Command="{Binding RefreshCommand}"/>
|
||||
<MenuItem Header="Rescan folder" Command="{Binding RescanFolderCommand}"/>
|
||||
<Separator Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Remove from Explorer" Click="OnRemoveLocation"
|
||||
Visibility="{Binding ShowForgetSource, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Add to Workbench" Click="OnImportWindowsLocation"
|
||||
Visibility="{Binding ShowImportWindowsLocation, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Empty Recycle Bin" Click="OnEmptyRecycleBin"
|
||||
Visibility="{Binding ShowEmptyRecycleBin, Converter={StaticResource BoolVis}}"/>
|
||||
<Separator Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Always keep on this device"
|
||||
Command="{Binding PinCloudCommand}"
|
||||
Visibility="{Binding ShowCloudPin, Converter={StaticResource BoolVis}}"/>
|
||||
<MenuItem Header="Free up space"
|
||||
Command="{Binding FreeUpCloudSpaceCommand}"
|
||||
Visibility="{Binding ShowCloudDehydrate, Converter={StaticResource BoolVis}}"/>
|
||||
</ContextMenu>
|
||||
</ListView.ContextMenu>
|
||||
</ListView>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -538,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"
|
||||
@@ -587,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>
|
||||
@@ -599,6 +650,9 @@
|
||||
</Border>
|
||||
<Grid>
|
||||
<ListView ItemsSource="{Binding Items}"
|
||||
SelectionMode="Extended"
|
||||
IsSynchronizedWithCurrentItem="False"
|
||||
ContextMenu="{StaticResource FolderListContextMenu}"
|
||||
MouseDoubleClick="OnItemDoubleClick"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
AllowDrop="True"
|
||||
@@ -616,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"
|
||||
@@ -650,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"
|
||||
@@ -722,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}"
|
||||
@@ -747,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>
|
||||
@@ -943,22 +1020,55 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -973,15 +1083,8 @@
|
||||
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>
|
||||
<TextBlock Text="{Binding Header, Mode=OneWay}" Foreground="{DynamicResource Fg}" FontWeight="SemiBold"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</DockPanel>
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
@@ -999,12 +1102,12 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</AdornerDecorator>
|
||||
</Window>
|
||||
|
||||
@@ -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,11 +895,19 @@ public partial class MainWindow : Window
|
||||
_sourceRightDrag = _dragButton == MouseButton.Right;
|
||||
_suppressItemContextMenu = _sourceRightDrag;
|
||||
var data = new DataObject(DataFormats.FileDrop, paths.ToArray());
|
||||
_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 (!_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);
|
||||
|
||||
@@ -1233,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"));
|
||||
@@ -1528,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 })
|
||||
@@ -1705,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);
|
||||
@@ -1741,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);
|
||||
@@ -1771,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;
|
||||
}
|
||||
}
|
||||
|
||||
105
src/Explorer.App/MaximizedWorkArea.cs
Normal file
105
src/Explorer.App/MaximizedWorkArea.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace Explorer.App;
|
||||
|
||||
internal static class MaximizedWorkArea
|
||||
{
|
||||
private const int WmGetMinMaxInfo = 0x0024;
|
||||
private const uint MonitorDefaultToNearest = 2;
|
||||
|
||||
public static void Hook(Window window)
|
||||
{
|
||||
if (PresentationSource.FromVisual(window) is not HwndSource source)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
source.AddHook(WndProc);
|
||||
}
|
||||
|
||||
private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg != WmGetMinMaxInfo)
|
||||
{
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
var info = Marshal.PtrToStructure<MinMaxInfo>(lParam);
|
||||
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
var monitorInfo = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
|
||||
if (GetMonitorInfo(monitor, ref monitorInfo))
|
||||
{
|
||||
var work = monitorInfo.Work;
|
||||
var display = monitorInfo.Monitor;
|
||||
info.MaxPosition = new NativePoint(work.Left - display.Left, work.Top - display.Top);
|
||||
info.MaxSize = new NativePoint(work.Right - work.Left, work.Bottom - work.Top);
|
||||
info.MaxTrackSize = info.MaxSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (HwndSource.FromHwnd(hwnd)?.RootVisual is Window window)
|
||||
{
|
||||
var dpi = VisualTreeHelper.GetDpi(window);
|
||||
info.MinTrackSize = new NativePoint(
|
||||
(int)Math.Ceiling(window.MinWidth * dpi.DpiScaleX),
|
||||
(int)Math.Ceiling(window.MinHeight * dpi.DpiScaleY));
|
||||
}
|
||||
|
||||
Marshal.StructureToPtr(info, lParam, fDeleteOld: false);
|
||||
handled = true;
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
|
||||
public NativePoint(int x, int y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeRect
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MinMaxInfo
|
||||
{
|
||||
public NativePoint Reserved;
|
||||
public NativePoint MaxSize;
|
||||
public NativePoint MaxPosition;
|
||||
public NativePoint MinTrackSize;
|
||||
public NativePoint MaxTrackSize;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct MonitorInfo
|
||||
{
|
||||
public int Size;
|
||||
public NativeRect Monitor;
|
||||
public NativeRect Work;
|
||||
public uint Flags;
|
||||
}
|
||||
}
|
||||
23
src/Explorer.App/ModelessWindowClose.cs
Normal file
23
src/Explorer.App/ModelessWindowClose.cs
Normal 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;
|
||||
};
|
||||
}
|
||||
66
src/Explorer.App/MoveToWindow.xaml
Normal file
66
src/Explorer.App/MoveToWindow.xaml
Normal 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>
|
||||
69
src/Explorer.App/MoveToWindow.xaml.cs
Normal file
69
src/Explorer.App/MoveToWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
22
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
Normal file
22
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml
Normal 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>
|
||||
24
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
Normal file
24
src/Explorer.App/Settings/Pages/AdvancedSettingsPage.xaml.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Explorer.App.Settings;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class AdvancedSettingsPage : UserControl
|
||||
{
|
||||
public AdvancedSettingsPage() => InitializeComponent();
|
||||
|
||||
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
Normal file
18
src/Explorer.App/Settings/Pages/AppearanceSettingsPage.xaml
Normal 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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class AppearanceSettingsPage : UserControl
|
||||
{
|
||||
public AppearanceSettingsPage() => InitializeComponent();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
Normal file
18
src/Explorer.App/Settings/Pages/GeneralSettingsPage.xaml
Normal 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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class GeneralSettingsPage : UserControl
|
||||
{
|
||||
public GeneralSettingsPage() => InitializeComponent();
|
||||
}
|
||||
58
src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
Normal file
58
src/Explorer.App/Settings/Pages/IndexingSettingsPage.xaml
Normal 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 archive’s size on disk. Online-only cloud archives are skipped."/>
|
||||
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Automatically index removable drives when they appear"
|
||||
IsChecked="{Binding AutoIndexRemovable}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
|
||||
|
||||
<TextBlock Text="Idle maintenance" Style="{StaticResource SettingsHeading}" Margin="0,8,0,10"
|
||||
AutomationProperties.HeadingLevel="Level2"/>
|
||||
<TextBlock Style="{StaticResource SettingsIntro}"
|
||||
Text="When you are not using the PC, the background host can hash duplicates, refresh stale local indexes, and capture history. Copy, move, and other jobs you started keep running. Cloud files are never hydrated. Network and removable locations are not scanned automatically."/>
|
||||
<CheckBox Margin="0,0,0,6"
|
||||
Content="Enable background maintenance when idle"
|
||||
IsChecked="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
<TextBlock Style="{StaticResource SettingsHelp}"
|
||||
Text="The host watches Windows idle time even if this window is closed. Conservative defaults wait 10 minutes and prefer AC power."/>
|
||||
<TextBlock Text="Idle for" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
|
||||
IsEnabled="{Binding BackgroundMaintenanceWhenIdle}"/>
|
||||
<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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class IndexingSettingsPage : UserControl
|
||||
{
|
||||
public IndexingSettingsPage() => InitializeComponent();
|
||||
}
|
||||
46
src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml
Normal file
46
src/Explorer.App/Settings/Pages/NavigationSettingsPage.xaml
Normal 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>
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace Explorer.App.Settings.Pages;
|
||||
|
||||
public partial class NavigationSettingsPage : UserControl
|
||||
{
|
||||
public NavigationSettingsPage() => InitializeComponent();
|
||||
}
|
||||
20
src/Explorer.App/Settings/SettingsCatalog.cs
Normal file
20
src/Explorer.App/Settings/SettingsCatalog.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Explorer.App.Settings.Pages;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public static class SettingsCatalog
|
||||
{
|
||||
public static IReadOnlyList<SettingsCategory> Create()
|
||||
{
|
||||
// Storage & Analysis and Network & Cloud stay out of the list until they have settings.
|
||||
return
|
||||
[
|
||||
new(SettingsCategoryId.General, "General", new GeneralSettingsPage()),
|
||||
new(SettingsCategoryId.Appearance, "Appearance", new AppearanceSettingsPage()),
|
||||
new(SettingsCategoryId.Navigation, "Navigation", new NavigationSettingsPage()),
|
||||
new(SettingsCategoryId.FileOperations, "File Operations", new FileOperationsSettingsPage()),
|
||||
new(SettingsCategoryId.Indexing, "Indexing", new IndexingSettingsPage()),
|
||||
new(SettingsCategoryId.Advanced, "Advanced", new AdvancedSettingsPage())
|
||||
];
|
||||
}
|
||||
}
|
||||
17
src/Explorer.App/Settings/SettingsCategory.cs
Normal file
17
src/Explorer.App/Settings/SettingsCategory.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed class SettingsCategory
|
||||
{
|
||||
public SettingsCategory(SettingsCategoryId id, string title, FrameworkElement page)
|
||||
{
|
||||
Id = id;
|
||||
Title = title;
|
||||
Page = page;
|
||||
}
|
||||
|
||||
public SettingsCategoryId Id { get; }
|
||||
public string Title { get; }
|
||||
public FrameworkElement Page { get; }
|
||||
}
|
||||
13
src/Explorer.App/Settings/SettingsCategoryId.cs
Normal file
13
src/Explorer.App/Settings/SettingsCategoryId.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public enum SettingsCategoryId
|
||||
{
|
||||
General,
|
||||
Appearance,
|
||||
Navigation,
|
||||
FileOperations,
|
||||
Indexing,
|
||||
StorageAnalysis,
|
||||
NetworkCloud,
|
||||
Advanced
|
||||
}
|
||||
101
src/Explorer.App/Settings/SettingsDraft.cs
Normal file
101
src/Explorer.App/Settings/SettingsDraft.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed partial class SettingsDraft : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _theme = "Dark";
|
||||
[ObservableProperty] private bool _groupNetworkPlaces;
|
||||
[ObservableProperty] private bool _groupCloudPlaces;
|
||||
[ObservableProperty] private bool _preferFavoritesInTree;
|
||||
[ObservableProperty] private bool _showHiddenFiles = true;
|
||||
[ObservableProperty] private bool _showProtectedSystemLocations;
|
||||
[ObservableProperty] private bool _autoClearQueueWhenDone;
|
||||
[ObservableProperty] private bool _indexArchiveContents;
|
||||
[ObservableProperty] private bool _autoIndexRemovable;
|
||||
[ObservableProperty] private bool _backgroundHostAtLogon;
|
||||
[ObservableProperty] private bool _backgroundMaintenanceWhenIdle = true;
|
||||
[ObservableProperty] private int _idleMaintenanceMinutes = 10;
|
||||
[ObservableProperty] private bool _idleMaintenanceAcOnly = true;
|
||||
[ObservableProperty] private string _sevenZipPath = "";
|
||||
[ObservableProperty] private string _gitPath = "";
|
||||
[ObservableProperty] private string _ffmpegPath = "";
|
||||
|
||||
public IReadOnlyList<int> IdleThresholdChoices { get; } = [5, 10, 30];
|
||||
|
||||
public bool IsDarkTheme
|
||||
{
|
||||
get => Theme != "Light";
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
Theme = "Dark";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsLightTheme
|
||||
{
|
||||
get => Theme == "Light";
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
Theme = "Light";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static SettingsDraft From(UiPreferences preferences)
|
||||
=> new()
|
||||
{
|
||||
Theme = UiPreferencesStore.NormalizeTheme(preferences.Theme),
|
||||
GroupNetworkPlaces = preferences.GroupNetworkPlaces,
|
||||
GroupCloudPlaces = preferences.GroupCloudPlaces,
|
||||
PreferFavoritesInTree = preferences.PreferFavoritesInTree,
|
||||
ShowHiddenFiles = preferences.ShowHiddenFiles,
|
||||
ShowProtectedSystemLocations = preferences.ShowProtectedSystemLocations,
|
||||
AutoClearQueueWhenDone = preferences.AutoClearQueueWhenDone,
|
||||
IndexArchiveContents = preferences.IndexArchiveContents,
|
||||
AutoIndexRemovable = preferences.AutoIndexRemovable,
|
||||
BackgroundHostAtLogon = preferences.BackgroundHostAtLogon,
|
||||
BackgroundMaintenanceWhenIdle = preferences.BackgroundMaintenanceWhenIdle,
|
||||
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
|
||||
IdleMaintenanceAcOnly = preferences.IdleMaintenanceAcOnly,
|
||||
SevenZipPath = preferences.SevenZipPath ?? "",
|
||||
GitPath = preferences.GitPath ?? "",
|
||||
FfmpegPath = preferences.FfmpegPath ?? ""
|
||||
};
|
||||
|
||||
public UiPreferences ApplyTo(UiPreferences stored)
|
||||
=> stored with
|
||||
{
|
||||
Theme = UiPreferencesStore.NormalizeTheme(Theme),
|
||||
GroupNetworkPlaces = GroupNetworkPlaces,
|
||||
GroupCloudPlaces = GroupCloudPlaces,
|
||||
PreferFavoritesInTree = PreferFavoritesInTree,
|
||||
ShowHiddenFiles = ShowHiddenFiles,
|
||||
ShowProtectedSystemLocations = ShowProtectedSystemLocations,
|
||||
AutoClearQueueWhenDone = AutoClearQueueWhenDone,
|
||||
IndexArchiveContents = IndexArchiveContents,
|
||||
AutoIndexRemovable = AutoIndexRemovable,
|
||||
BackgroundHostAtLogon = BackgroundHostAtLogon,
|
||||
BackgroundMaintenanceWhenIdle = BackgroundMaintenanceWhenIdle,
|
||||
IdleMaintenanceMinutes = UiPreferencesStore.NormalizeIdleMinutes(IdleMaintenanceMinutes),
|
||||
IdleMaintenanceAcOnly = IdleMaintenanceAcOnly,
|
||||
SevenZipPath = EmptyToNull(SevenZipPath),
|
||||
GitPath = EmptyToNull(GitPath),
|
||||
FfmpegPath = EmptyToNull(FfmpegPath)
|
||||
};
|
||||
|
||||
partial void OnThemeChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsDarkTheme));
|
||||
OnPropertyChanged(nameof(IsLightTheme));
|
||||
}
|
||||
|
||||
private static string? EmptyToNull(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
11
src/Explorer.App/Settings/SettingsPageContext.cs
Normal file
11
src/Explorer.App/Settings/SettingsPageContext.cs
Normal 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;
|
||||
}
|
||||
25
src/Explorer.App/Settings/SettingsPathBrowse.cs
Normal file
25
src/Explorer.App/Settings/SettingsPathBrowse.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
internal static class SettingsPathBrowse
|
||||
{
|
||||
public static bool TryPick(Window? owner, string title, string filter, string? current, out string path)
|
||||
{
|
||||
var dlg = new OpenFileDialog
|
||||
{
|
||||
Title = title,
|
||||
Filter = filter,
|
||||
FileName = current ?? ""
|
||||
};
|
||||
if (dlg.ShowDialog(owner) == true)
|
||||
{
|
||||
path = dlg.FileName;
|
||||
return true;
|
||||
}
|
||||
|
||||
path = "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
6
src/Explorer.App/Settings/SettingsSession.cs
Normal file
6
src/Explorer.App/Settings/SettingsSession.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public static class SettingsSession
|
||||
{
|
||||
public static SettingsCategoryId? LastCategory { get; set; }
|
||||
}
|
||||
27
src/Explorer.App/Settings/SettingsShell.cs
Normal file
27
src/Explorer.App/Settings/SettingsShell.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Explorer.App.Settings;
|
||||
|
||||
public sealed partial class SettingsShell : ObservableObject
|
||||
{
|
||||
public SettingsShell(SettingsDraft draft, IReadOnlyList<SettingsCategory> categories)
|
||||
{
|
||||
Draft = draft;
|
||||
Categories = categories;
|
||||
_selectedCategory = Restore(categories);
|
||||
}
|
||||
|
||||
public SettingsDraft Draft { get; }
|
||||
public IReadOnlyList<SettingsCategory> Categories { get; }
|
||||
|
||||
[ObservableProperty] private SettingsCategory _selectedCategory;
|
||||
|
||||
partial void OnSelectedCategoryChanged(SettingsCategory value)
|
||||
=> SettingsSession.LastCategory = value.Id;
|
||||
|
||||
private static SettingsCategory Restore(IReadOnlyList<SettingsCategory> categories)
|
||||
{
|
||||
var last = SettingsSession.LastCategory;
|
||||
return categories.FirstOrDefault(c => c.Id == last) ?? categories[0];
|
||||
}
|
||||
}
|
||||
25
src/Explorer.App/Settings/SettingsStyles.xaml
Normal file
25
src/Explorer.App/Settings/SettingsStyles.xaml
Normal file
@@ -0,0 +1,25 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style x:Key="SettingsHeading" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsIntro" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="Margin" Value="0,0,0,12"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsHelp" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="24,0,0,14"/>
|
||||
</Style>
|
||||
<Style x:Key="SettingsPathHelp" TargetType="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -3,99 +3,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 archive’s size on disk. Online-only cloud archives are skipped."/>
|
||||
<CheckBox x:Name="AutoIndexRemovable" Margin="0,0,0,6"
|
||||
Content="Automatically index removable drives when they appear"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="USB and other removable volumes are queued for a full scan when they come online and are not indexed yet. Cloud locations are never auto-indexed. Online-only files are not hydrated."/>
|
||||
|
||||
<TextBlock Text="Background host" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<CheckBox x:Name="BackgroundHostAtLogon" Margin="0,0,0,6"
|
||||
Content="Start Explorer.Host.exe at Windows sign-in"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
|
||||
Text="Adds Explorer.Host.exe to your Windows sign-in programs for this user. No administrator rights. The window connects to Explorer.Host.exe for indexing and the queue. If the host is not running, the window starts it. Only the host opens the index for write. A tray icon stays while the host is running: open the window, or quit the host. File → Stop background host does the same from the window."/>
|
||||
|
||||
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
|
||||
<DockPanel Margin="0,0,0,18">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseSevenZip" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="SevenZipPath"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200" MinWidth="160"/>
|
||||
<ColumnDefinition Width="8"/>
|
||||
<ColumnDefinition Width="*" MinWidth="360"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Top" Text="Categories" FontWeight="SemiBold" Margin="0,0,0,8"/>
|
||||
<ListBox x:Name="CategoryList"
|
||||
ItemsSource="{Binding Categories}"
|
||||
SelectedItem="{Binding SelectedCategory}"
|
||||
DisplayMemberPath="Title"
|
||||
AutomationProperties.Name="Settings categories"
|
||||
KeyboardNavigation.TabIndex="0">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||
Padding="{TemplateBinding Padding}" CornerRadius="3" Margin="0,1">
|
||||
<ContentPresenter HorizontalAlignment="Stretch" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Repository badges and Git actions use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. There is no branch UI or credential dialog."/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="GitPath"/>
|
||||
</DockPanel>
|
||||
|
||||
<TextBlock Text="FFmpeg" FontSize="16" FontWeight="SemiBold" Margin="0,16,0,10"/>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
|
||||
Text="Convert uses ffmpeg.exe from a Windows zip/build (ffprobe and ffplay are not required). Leave the path empty to look in Program Files\ffmpeg\bin and PATH. FFmpeg is not bundled with Explorer Workbench."/>
|
||||
<DockPanel Margin="0,0,0,6">
|
||||
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseFfmpeg" Margin="8,0,0,0"/>
|
||||
<TextBox x:Name="FfmpegPath"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
<GridSplitter Grid.Column="1" Width="8" HorizontalAlignment="Stretch"
|
||||
Background="{DynamicResource Stroke}"
|
||||
ResizeBehavior="PreviousAndNext"/>
|
||||
<Border Grid.Column="2" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}"
|
||||
BorderThickness="1" Padding="16">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
|
||||
KeyboardNavigation.TabIndex="1">
|
||||
<Grid x:Name="PageHost" HorizontalAlignment="Stretch" VerticalAlignment="Top"/>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using Explorer.Application;
|
||||
using Explorer.App.Settings;
|
||||
using Explorer.Hosting;
|
||||
using Explorer.Presentation.ViewModels;
|
||||
|
||||
@@ -8,56 +9,64 @@ 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;
|
||||
AutoIndexRemovable.IsChecked = prefs.AutoIndexRemovable;
|
||||
BackgroundHostAtLogon.IsChecked = prefs.BackgroundHostAtLogon;
|
||||
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
|
||||
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
|
||||
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
|
||||
SevenZipPath.Text = prefs.SevenZipPath ?? "";
|
||||
GitPath.Text = prefs.GitPath ?? "";
|
||||
FfmpegPath.Text = prefs.FfmpegPath ?? "";
|
||||
_draft = SettingsDraft.From(prefs);
|
||||
_draft.PropertyChanged += OnDraftChanged;
|
||||
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,
|
||||
AutoIndexRemovable = AutoIndexRemovable.IsChecked == true,
|
||||
BackgroundHostAtLogon = BackgroundHostAtLogon.IsChecked == true,
|
||||
ShowHiddenFiles = ShowHidden.IsChecked == true,
|
||||
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
|
||||
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
|
||||
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
|
||||
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim(),
|
||||
FfmpegPath = string.IsNullOrWhiteSpace(FfmpegPath.Text) ? null : FfmpegPath.Text.Trim()
|
||||
};
|
||||
var prefs = _draft.ApplyTo(_vm.CurrentPreferences());
|
||||
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
|
||||
ApplyBackgroundHostAutostart(prefs.BackgroundHostAtLogon);
|
||||
DialogResult = true;
|
||||
@@ -94,48 +103,6 @@ public partial class SettingsWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseSevenZip(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "7-Zip executable",
|
||||
Filter = "7-Zip|7z.exe;7za.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = SevenZipPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
SevenZipPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseGit(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "Git executable",
|
||||
Filter = "Git|git.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = GitPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
GitPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnBrowseFfmpeg(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFileDialog
|
||||
{
|
||||
Title = "FFmpeg executable",
|
||||
Filter = "FFmpeg|ffmpeg.exe|Executables|*.exe|All files|*.*",
|
||||
FileName = FfmpegPath.Text
|
||||
};
|
||||
if (dlg.ShowDialog(this) == true)
|
||||
{
|
||||
FfmpegPath.Text = dlg.FileName;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCancel(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_vm.Theme = _originalTheme;
|
||||
|
||||
50
src/Explorer.App/TagRenameWindow.xaml
Normal file
50
src/Explorer.App/TagRenameWindow.xaml
Normal 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>
|
||||
26
src/Explorer.App/TagRenameWindow.xaml.cs
Normal file
26
src/Explorer.App/TagRenameWindow.xaml.cs
Normal 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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
63
src/Explorer.Application/BackgroundMaintenance.cs
Normal file
63
src/Explorer.Application/BackgroundMaintenance.cs
Normal 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();
|
||||
}
|
||||
44
src/Explorer.Application/BackgroundMaintenancePlanner.cs
Normal file
44
src/Explorer.Application/BackgroundMaintenancePlanner.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class BackgroundMaintenancePlanner
|
||||
{
|
||||
public static Source? NextLocalScan(
|
||||
IEnumerable<Source> sources,
|
||||
DateTimeOffset utc,
|
||||
IReadOnlySet<long>? alreadyQueued = null)
|
||||
{
|
||||
var cutoff = utc - TimeSpan.FromDays(AppConstants.IdleRescanAfterDays);
|
||||
return EligibleLocal(sources, alreadyQueued)
|
||||
.Where(source => source.Status == SourceStatus.Stale
|
||||
|| source.LastIndexedUtc is null
|
||||
|| source.LastIndexedUtc < cutoff)
|
||||
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
|
||||
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static Source? NextLocalVerify(
|
||||
IEnumerable<Source> sources,
|
||||
IReadOnlySet<long>? alreadyQueued = null)
|
||||
=> EligibleLocal(sources, alreadyQueued)
|
||||
.OrderBy(source => source.Status == SourceStatus.Stale ? 0 : 1)
|
||||
.ThenBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
|
||||
public static Source? NextHashCollisionSource(
|
||||
IEnumerable<Source> sources,
|
||||
IReadOnlySet<long>? alreadyEnqueued = null)
|
||||
=> EligibleLocal(sources, alreadyEnqueued)
|
||||
.OrderBy(source => source.LastIndexedUtc ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
|
||||
private static IEnumerable<Source> EligibleLocal(IEnumerable<Source> sources, IReadOnlySet<long>? skip)
|
||||
=> sources.Where(source =>
|
||||
source.Kind == SourceKind.NtfsLocal
|
||||
&& source.IsIndexed
|
||||
&& source.Status is SourceStatus.Online or SourceStatus.Stale
|
||||
&& !string.IsNullOrWhiteSpace(source.LastRootPath)
|
||||
&& (skip is null || !skip.Contains(source.Id)));
|
||||
}
|
||||
97
src/Explorer.Application/BackgroundWorkPolicy.cs
Normal file
97
src/Explorer.Application/BackgroundWorkPolicy.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public enum UserActivityState
|
||||
{
|
||||
Active,
|
||||
Idle
|
||||
}
|
||||
|
||||
public enum MaintenanceSkipReason
|
||||
{
|
||||
None,
|
||||
Disabled,
|
||||
UserActive,
|
||||
BelowIdleThreshold,
|
||||
OnBattery,
|
||||
ForegroundOperations,
|
||||
AlreadyRunning
|
||||
}
|
||||
|
||||
public readonly record struct BackgroundWorkInputs(
|
||||
bool Enabled,
|
||||
TimeSpan IdleThreshold,
|
||||
TimeSpan IdleDuration,
|
||||
bool AcOnly,
|
||||
bool OnAcPower,
|
||||
bool ForegroundOperations,
|
||||
bool RunNowRequested);
|
||||
|
||||
public readonly record struct BackgroundWorkDecision(
|
||||
UserActivityState Activity,
|
||||
bool MaintenanceAllowed,
|
||||
MaintenanceSkipReason SkipReason)
|
||||
{
|
||||
public static BackgroundWorkDecision Active(MaintenanceSkipReason reason)
|
||||
=> new(UserActivityState.Active, false, reason);
|
||||
|
||||
public static BackgroundWorkDecision IdleBlocked(MaintenanceSkipReason reason)
|
||||
=> new(UserActivityState.Idle, false, reason);
|
||||
|
||||
public static BackgroundWorkDecision Allowed(UserActivityState activity)
|
||||
=> new(activity, true, MaintenanceSkipReason.None);
|
||||
}
|
||||
|
||||
public static class BackgroundWorkPolicy
|
||||
{
|
||||
public static BackgroundWorkDecision Evaluate(BackgroundWorkInputs inputs)
|
||||
{
|
||||
var idle = inputs.IdleDuration >= inputs.IdleThreshold && inputs.IdleDuration >= TimeSpan.Zero;
|
||||
var activity = idle ? UserActivityState.Idle : UserActivityState.Active;
|
||||
|
||||
if (inputs.RunNowRequested)
|
||||
{
|
||||
if (inputs.ForegroundOperations)
|
||||
{
|
||||
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.ForegroundOperations);
|
||||
}
|
||||
|
||||
return BackgroundWorkDecision.Allowed(activity);
|
||||
}
|
||||
|
||||
if (!inputs.Enabled)
|
||||
{
|
||||
return new BackgroundWorkDecision(activity, false, MaintenanceSkipReason.Disabled);
|
||||
}
|
||||
|
||||
if (!idle)
|
||||
{
|
||||
return BackgroundWorkDecision.Active(
|
||||
inputs.IdleDuration <= TimeSpan.Zero
|
||||
? MaintenanceSkipReason.UserActive
|
||||
: MaintenanceSkipReason.BelowIdleThreshold);
|
||||
}
|
||||
|
||||
if (inputs.AcOnly && !inputs.OnAcPower)
|
||||
{
|
||||
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.OnBattery);
|
||||
}
|
||||
|
||||
if (inputs.ForegroundOperations)
|
||||
{
|
||||
return BackgroundWorkDecision.IdleBlocked(MaintenanceSkipReason.ForegroundOperations);
|
||||
}
|
||||
|
||||
return BackgroundWorkDecision.Allowed(UserActivityState.Idle);
|
||||
}
|
||||
|
||||
public static string Describe(MaintenanceSkipReason reason) => reason switch
|
||||
{
|
||||
MaintenanceSkipReason.Disabled => "disabled",
|
||||
MaintenanceSkipReason.UserActive => "user is active",
|
||||
MaintenanceSkipReason.BelowIdleThreshold => "idle threshold not reached",
|
||||
MaintenanceSkipReason.OnBattery => "on battery",
|
||||
MaintenanceSkipReason.ForegroundOperations => "foreground file operations",
|
||||
MaintenanceSkipReason.AlreadyRunning => "maintenance already running",
|
||||
_ => "none"
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public static class BrowseHydration
|
||||
public const int ConstrainedWorkers = 2;
|
||||
public const int LocalProviderBatch = 64;
|
||||
public const int ConstrainedProviderBatch = 16;
|
||||
public const int FirstPublish = 1;
|
||||
public const int PublishBatch = 48;
|
||||
public const int NearbyWindow = 32;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class BrowseService
|
||||
private readonly UiPreferencesStore _preferences;
|
||||
private readonly IElevatedScanService? _elevation;
|
||||
private readonly IRecycleBinCatalog? _recycle;
|
||||
private readonly IKnownUserFolderCatalog? _knownFolders;
|
||||
|
||||
public BrowseService(
|
||||
IFileSystemEnumerator enumerator,
|
||||
@@ -26,7 +27,8 @@ public sealed class BrowseService
|
||||
CloudPlaceStore cloudPlaces,
|
||||
UiPreferencesStore preferences,
|
||||
IElevatedScanService? elevation = null,
|
||||
IRecycleBinCatalog? recycle = null)
|
||||
IRecycleBinCatalog? recycle = null,
|
||||
IKnownUserFolderCatalog? knownFolders = null)
|
||||
{
|
||||
_enumerator = enumerator;
|
||||
_volumes = volumes;
|
||||
@@ -37,6 +39,7 @@ public sealed class BrowseService
|
||||
_preferences = preferences;
|
||||
_elevation = elevation;
|
||||
_recycle = recycle;
|
||||
_knownFolders = knownFolders;
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
||||
@@ -56,6 +59,41 @@ public sealed class BrowseService
|
||||
return new FolderListing { Path = listing.Path, Items = items };
|
||||
}
|
||||
|
||||
public FolderListing ListHome()
|
||||
{
|
||||
var items = (_knownFolders?.ListExisting() ?? [])
|
||||
.Select(folder => new FileSystemItem
|
||||
{
|
||||
FullPath = folder.Path,
|
||||
Name = folder.Name,
|
||||
DisplayName = folder.Name,
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory
|
||||
})
|
||||
.ToList();
|
||||
return new FolderListing { Path = LocationRoots.Home, Items = items };
|
||||
}
|
||||
|
||||
public FolderListing ListFavorites()
|
||||
{
|
||||
var items = new List<FileSystemItem>();
|
||||
foreach (var path in FavoriteFolders.Normalize(_preferences.Load().FavoriteFolders))
|
||||
{
|
||||
var exists = Directory.Exists(path);
|
||||
var name = FavoriteDisplayName(path);
|
||||
items.Add(new FileSystemItem
|
||||
{
|
||||
FullPath = path,
|
||||
Name = name,
|
||||
DisplayName = exists ? name : $"{name} (Offline)",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory
|
||||
});
|
||||
}
|
||||
|
||||
return new FolderListing { Path = LocationRoots.Favorites, Items = items };
|
||||
}
|
||||
|
||||
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
|
||||
@@ -172,17 +210,6 @@ public sealed class BrowseService
|
||||
BrowseViewport? viewport = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
|
||||
{
|
||||
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveListing is not null)
|
||||
{
|
||||
yield return CompleteDelta(archiveListing);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
|
||||
{
|
||||
yield return CompleteDelta(ListRecycleBin(path));
|
||||
@@ -190,13 +217,35 @@ public sealed class BrowseService
|
||||
}
|
||||
|
||||
var reachable = _volumes.IsPathReachable(path);
|
||||
if (MightBeArchiveListing(path, reachable))
|
||||
{
|
||||
var archiveSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveSource is { IsIndexed: true })
|
||||
{
|
||||
var archiveListing = await TryListArchiveAsync(archiveSource, path, cancellationToken).ConfigureAwait(false);
|
||||
if (archiveListing is not null)
|
||||
{
|
||||
yield return CompleteDelta(archiveListing);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!reachable)
|
||||
{
|
||||
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
|
||||
yield return CompleteDelta(await ListOfflineAsync(archiveSource, path, cancellationToken).ConfigureAwait(false));
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
else if (!reachable)
|
||||
{
|
||||
var offlineSource = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
yield return CompleteDelta(await ListOfflineAsync(offlineSource, path, cancellationToken).ConfigureAwait(false));
|
||||
yield break;
|
||||
}
|
||||
|
||||
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
|
||||
var sourceTask = _sources.FindByPathAsync(path, cancellationToken);
|
||||
_ = MarkReachableInBackground(path, cancellationToken);
|
||||
await foreach (var delta in ListLiveProgressiveAsync(path, sourceTask, viewport, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return delta;
|
||||
}
|
||||
@@ -205,6 +254,64 @@ public sealed class BrowseService
|
||||
public bool CanBrowseArchive(string name)
|
||||
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
|
||||
|
||||
private bool MightBeArchiveListing(string path, bool reachable)
|
||||
{
|
||||
if (!_preferences.Load().IndexArchiveContents)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!reachable)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = Path.GetFileName(path.TrimEnd('\\', '/'));
|
||||
return !string.IsNullOrEmpty(name) && ArchiveFormats.IsArchive(name);
|
||||
}
|
||||
|
||||
private async Task MarkReachableInBackground(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sources.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Listing already started from the live filesystem.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, IndexEntry>> LoadIndexAfterSourceAsync(
|
||||
Task<Source?> sourceTask,
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Source? source;
|
||||
try
|
||||
{
|
||||
source = await sourceTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (source is not { IsIndexed: true })
|
||||
{
|
||||
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return await LoadIndexChildrenAsync(source, path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ApplyDelta(
|
||||
List<FileSystemItem> items,
|
||||
Dictionary<string, int> byPath,
|
||||
@@ -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)
|
||||
@@ -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"];
|
||||
|
||||
193
src/Explorer.Application/DestinationPattern.cs
Normal file
193
src/Explorer.Application/DestinationPattern.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Explorer.Application/DestinationPatterns.cs
Normal file
88
src/Explorer.Application/DestinationPatterns.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
173
src/Explorer.Application/EntryClassificationService.cs
Normal file
173
src/Explorer.Application/EntryClassificationService.cs
Normal 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);
|
||||
}
|
||||
87
src/Explorer.Application/FavoriteFolders.cs
Normal file
87
src/Explorer.Application/FavoriteFolders.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FavoriteFolders
|
||||
{
|
||||
public const int MaxCount = 32;
|
||||
|
||||
public static IReadOnlyList<string> Normalize(IEnumerable<string>? paths)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in paths ?? [])
|
||||
{
|
||||
if (!TryNormalize(raw, out var path) || !seen.Add(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(path);
|
||||
if (result.Count >= MaxCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool TryNormalize(string? raw, out string path)
|
||||
{
|
||||
path = "";
|
||||
if (string.IsNullOrWhiteSpace(raw) || LocationRoots.IsVirtual(raw))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = PathRules.FromExtended(raw).TrimEnd('\\');
|
||||
if (normalized.Length == 2 && normalized[1] == ':')
|
||||
{
|
||||
normalized += "\\";
|
||||
}
|
||||
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
path = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool Contains(IEnumerable<string>? paths, string? candidate)
|
||||
=> TryNormalize(candidate, out var path)
|
||||
&& Normalize(paths).Any(p => p.Equals(path, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static IReadOnlyList<string> Add(IEnumerable<string>? current, IEnumerable<string> candidates)
|
||||
{
|
||||
var list = Normalize(current).ToList();
|
||||
var seen = new HashSet<string>(list, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (!TryNormalize(candidate, out var path) || !seen.Add(path) || list.Count >= MaxCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(path);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> Remove(IEnumerable<string>? current, IEnumerable<string> candidates)
|
||||
{
|
||||
var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (TryNormalize(candidate, out var path))
|
||||
{
|
||||
remove.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return Normalize(current).Where(p => !remove.Contains(p)).ToList();
|
||||
}
|
||||
}
|
||||
314
src/Explorer.Application/FilenamePattern.cs
Normal file
314
src/Explorer.Application/FilenamePattern.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
88
src/Explorer.Application/FilenamePatterns.cs
Normal file
88
src/Explorer.Application/FilenamePatterns.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
57
src/Explorer.Application/FolderDisplayRefresh.cs
Normal file
57
src/Explorer.Application/FolderDisplayRefresh.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FolderDisplayRefresh
|
||||
{
|
||||
public const int MaxProbes = 8;
|
||||
public const long MinProbeBytes = 1024 * 1024;
|
||||
|
||||
public static IReadOnlyList<FileSystemItem> Pick(
|
||||
IEnumerable<FileSystemItem> items,
|
||||
IReadOnlyCollection<string>? visiblePaths = null)
|
||||
{
|
||||
var dirs = items
|
||||
.Where(item => item.IsDirectory
|
||||
&& item.SizeBytes >= MinProbeBytes
|
||||
&& item.Cloud?.MayHydrateOnRead != true)
|
||||
.ToList();
|
||||
if (dirs.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var visible = visiblePaths is { Count: > 0 }
|
||||
? new HashSet<string>(visiblePaths, StringComparer.OrdinalIgnoreCase)
|
||||
: null;
|
||||
IEnumerable<FileSystemItem> ordered = visible is null
|
||||
? dirs.OrderByDescending(item => item.SizeBytes)
|
||||
: dirs.Where(item => visible.Contains(item.FullPath))
|
||||
.OrderByDescending(item => item.SizeBytes)
|
||||
.Concat(dirs.Where(item => !visible.Contains(item.FullPath)).OrderByDescending(item => item.SizeBytes));
|
||||
|
||||
var picked = new List<FileSystemItem>(Math.Min(MaxProbes, dirs.Count));
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in ordered)
|
||||
{
|
||||
if (!seen.Add(item.FullPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
picked.Add(item);
|
||||
if (picked.Count >= MaxProbes)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return picked;
|
||||
}
|
||||
|
||||
public static bool NeedsVerify(int liveChildCount, int indexedChildCount)
|
||||
=> liveChildCount >= 0 && liveChildCount != indexedChildCount;
|
||||
|
||||
public static bool HasIndexCounts(FileSystemItem item)
|
||||
=> (item.Hydration & ItemHydrationFlags.Index) != 0;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
73
src/Explorer.Application/FolderStatusText.cs
Normal file
73
src/Explorer.Application/FolderStatusText.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class FolderStatusText
|
||||
{
|
||||
public static string Format(IEnumerable<FileSystemItem> folderItems, IEnumerable<FileSystemItem> selectedItems)
|
||||
{
|
||||
var selected = selectedItems as IReadOnlyCollection<FileSystemItem> ?? selectedItems.ToList();
|
||||
var isSelection = selected.Count > 0;
|
||||
var totals = Measure(isSelection ? selected : folderItems);
|
||||
var noun = totals.Count == 1 ? "item" : "items";
|
||||
var prefix = isSelection
|
||||
? $"{totals.Count:N0} {noun} selected"
|
||||
: $"{totals.Count:N0} {noun}";
|
||||
if (!ShouldShowSize(totals))
|
||||
{
|
||||
return prefix;
|
||||
}
|
||||
|
||||
return $"{prefix} · {FormatSize(totals.KnownBytes)}";
|
||||
}
|
||||
|
||||
public static FolderStatusTotals Measure(IEnumerable<FileSystemItem> items)
|
||||
{
|
||||
var count = 0;
|
||||
var files = 0;
|
||||
var knownBytes = 0L;
|
||||
var hasKnownSize = false;
|
||||
foreach (var item in items)
|
||||
{
|
||||
count++;
|
||||
if (!item.IsDirectory)
|
||||
{
|
||||
files++;
|
||||
}
|
||||
|
||||
if (item.SizeKnowledge == SizeKnowledge.Unknown)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
knownBytes += Math.Max(0, item.SizeBytes);
|
||||
hasKnownSize = true;
|
||||
}
|
||||
|
||||
return new FolderStatusTotals(count, files, knownBytes, hasKnownSize);
|
||||
}
|
||||
|
||||
private static bool ShouldShowSize(FolderStatusTotals totals)
|
||||
=> totals.HasKnownSize && (totals.KnownBytes > 0 || totals.Files > 0);
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes < 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
double value = bytes;
|
||||
var unit = 0;
|
||||
while (value >= 1024 && unit < units.Length - 1)
|
||||
{
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return unit == 0 ? $"{bytes} B" : $"{value:0.##} {units[unit]}";
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct FolderStatusTotals(int Count, int Files, long KnownBytes, bool HasKnownSize);
|
||||
148
src/Explorer.Application/GitListingOverlay.cs
Normal file
148
src/Explorer.Application/GitListingOverlay.cs
Normal 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..];
|
||||
}
|
||||
87
src/Explorer.Application/HostActivityLog.cs
Normal file
87
src/Explorer.Application/HostActivityLog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,5 @@ public interface IWorkspaceLauncher
|
||||
{
|
||||
void OpenTerminal(string directory);
|
||||
bool TryOpenInCursor(string path);
|
||||
bool TryOpenInNotepadPlusPlus(IReadOnlyList<string> paths);
|
||||
}
|
||||
|
||||
13
src/Explorer.Application/IKnownUserFolderCatalog.cs
Normal file
13
src/Explorer.Application/IKnownUserFolderCatalog.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public sealed record KnownUserFolder(string Name, string Path, string Glyph);
|
||||
|
||||
public interface IKnownUserFolderCatalog
|
||||
{
|
||||
IReadOnlyList<KnownUserFolder> ListExisting();
|
||||
}
|
||||
|
||||
public sealed class KnownUserFolderCatalog : IKnownUserFolderCatalog
|
||||
{
|
||||
public IReadOnlyList<KnownUserFolder> ListExisting() => [];
|
||||
}
|
||||
9
src/Explorer.Application/IMediaTagService.cs
Normal file
9
src/Explorer.Application/IMediaTagService.cs
Normal 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);
|
||||
}
|
||||
39
src/Explorer.Application/ISqliteDatabaseSession.cs
Normal file
39
src/Explorer.Application/ISqliteDatabaseSession.cs
Normal 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; } = "";
|
||||
}
|
||||
116
src/Explorer.Application/IndexedPathPresence.cs
Normal file
116
src/Explorer.Application/IndexedPathPresence.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
80
src/Explorer.Application/MarqueeRange.cs
Normal file
80
src/Explorer.Application/MarqueeRange.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
namespace Explorer.Application;
|
||||
|
||||
public static class MarqueeRange
|
||||
{
|
||||
public static IReadOnlyList<int> Stack(double top, double bottom, int count, double itemHeight)
|
||||
{
|
||||
if (count <= 0 || itemHeight <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var min = Math.Min(top, bottom);
|
||||
var max = Math.Max(top, bottom);
|
||||
if (max - min < 0.5)
|
||||
{
|
||||
var index = (int)Math.Floor(min / itemHeight);
|
||||
return index >= 0 && index < count ? [index] : [];
|
||||
}
|
||||
|
||||
var first = (int)Math.Floor(min / itemHeight);
|
||||
var last = (int)Math.Ceiling(max / itemHeight) - 1;
|
||||
first = Math.Clamp(first, 0, count - 1);
|
||||
last = Math.Clamp(last, 0, count - 1);
|
||||
if (last < first)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var hits = new int[last - first + 1];
|
||||
for (var i = 0; i < hits.Length; i++)
|
||||
{
|
||||
hits[i] = first + i;
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<int> Wrap(double left, double top, double right, double bottom, int count, int columns, double itemWidth, double itemHeight)
|
||||
{
|
||||
if (count <= 0 || columns <= 0 || itemWidth <= 0 || itemHeight <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var minX = Math.Min(left, right);
|
||||
var maxX = Math.Max(left, right);
|
||||
var minY = Math.Min(top, bottom);
|
||||
var maxY = Math.Max(top, bottom);
|
||||
if (maxX - minX < 0.5 && maxY - minY < 0.5)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var firstCol = (int)Math.Floor(minX / itemWidth);
|
||||
var lastCol = (int)Math.Ceiling(maxX / itemWidth) - 1;
|
||||
var firstRow = (int)Math.Floor(minY / itemHeight);
|
||||
var lastRow = (int)Math.Ceiling(maxY / itemHeight) - 1;
|
||||
firstCol = Math.Clamp(firstCol, 0, columns - 1);
|
||||
lastCol = Math.Clamp(lastCol, 0, columns - 1);
|
||||
if (lastCol < firstCol || lastRow < firstRow)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var hits = new List<int>();
|
||||
for (var row = firstRow; row <= lastRow; row++)
|
||||
{
|
||||
for (var col = firstCol; col <= lastCol; col++)
|
||||
{
|
||||
var index = row * columns + col;
|
||||
if (index >= 0 && index < count)
|
||||
{
|
||||
hits.Add(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
116
src/Explorer.Application/MoveToPlanner.cs
Normal file
116
src/Explorer.Application/MoveToPlanner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
src/Explorer.Application/NotepadPlusPlusLocator.cs
Normal file
42
src/Explorer.Application/NotepadPlusPlusLocator.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
var classification = PreferIndexed(
|
||||
item,
|
||||
indexedByName,
|
||||
FileClassifier.Classify(
|
||||
item.Name,
|
||||
item.FullPath,
|
||||
item.IsDirectory,
|
||||
item.Attributes,
|
||||
item.IsDirectory && isRepoRoot(item.FullPath),
|
||||
childCategories);
|
||||
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,
|
||||
|
||||
203
src/Explorer.Application/ShellContextVerbFilter.cs
Normal file
203
src/Explorer.Application/ShellContextVerbFilter.cs
Normal 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;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ public sealed class SourceManager
|
||||
private long _refreshCacheTimestamp;
|
||||
private static readonly TimeSpan RefreshCacheTtl = TimeSpan.FromSeconds(2);
|
||||
|
||||
public event EventHandler<Source>? PresenceChanged;
|
||||
|
||||
public SourceManager(
|
||||
IIndexStore store,
|
||||
IVolumeService volumes,
|
||||
@@ -176,6 +178,7 @@ public sealed class SourceManager
|
||||
&& (source.Kind.IsNetwork() || PathRules.IsUnc(source.LastRootPath))
|
||||
&& _volumes.IsPathReachable(source.LastRootPath))
|
||||
{
|
||||
await BringOnlineAsync(source, cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -282,6 +285,77 @@ public sealed class SourceManager
|
||||
return source;
|
||||
}
|
||||
|
||||
public async Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_store.CanWrite)
|
||||
{
|
||||
if (_remote is null)
|
||||
{
|
||||
return await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var current = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (current is { Status: SourceStatus.Online or SourceStatus.Stale or SourceStatus.Scanning })
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
var updated = await _remote.MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
InvalidateRefreshCache();
|
||||
var source = updated ?? await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is not null)
|
||||
{
|
||||
RaisePresence(source);
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
var found = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (found is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await BringOnlineAsync(found, cancellationToken).ConfigureAwait(false);
|
||||
return found;
|
||||
}
|
||||
|
||||
public async Task ConfirmAccessedAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var source = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (source is null || source.Status != SourceStatus.Offline)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_volumes.IsPathReachable(path))
|
||||
{
|
||||
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 4; attempt++)
|
||||
{
|
||||
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
|
||||
if (_volumes.IsPathReachable(path))
|
||||
{
|
||||
await MarkReachableAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPresentInWindows(Source source)
|
||||
{
|
||||
var online = _volumes.EnumerateOnlineVolumes();
|
||||
@@ -528,6 +602,42 @@ public sealed class SourceManager
|
||||
public Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> _store.Sources.GetAsync(id, cancellationToken);
|
||||
|
||||
private async Task BringOnlineAsync(Source source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.Status == SourceStatus.Scanning
|
||||
&& await _store.ScanJobs.HasActiveAsync(source.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.Status is SourceStatus.Online or SourceStatus.Stale)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var wasOffline = source.Status == SourceStatus.Offline;
|
||||
source.Status = SourceStatus.Online;
|
||||
source.LastSeenUtc = _clock.UtcNow;
|
||||
source.LastError = null;
|
||||
await TryIndexWrite(
|
||||
() => _store.Sources.UpsertAsync(source, cancellationToken),
|
||||
"mark source online",
|
||||
source.Id).ConfigureAwait(false);
|
||||
if (source.IsIndexed && wasOffline)
|
||||
{
|
||||
await TryIndexWrite(
|
||||
() => _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken),
|
||||
"mark online",
|
||||
source.Id).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
InvalidateRefreshCache();
|
||||
RaisePresence(source);
|
||||
}
|
||||
|
||||
private void RaisePresence(Source source)
|
||||
=> PresenceChanged?.Invoke(this, source);
|
||||
|
||||
private async Task<SourceStatus> ResolveReachableStatusAsync(Source source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (source.Status == SourceStatus.Scanning
|
||||
|
||||
123
src/Explorer.Application/TagRenamePlanner.cs
Normal file
123
src/Explorer.Application/TagRenamePlanner.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
public sealed class TagRenamePlanner
|
||||
{
|
||||
public OperationPlan BuildWrite(
|
||||
IReadOnlyList<RenameSubject> subjects,
|
||||
string pattern,
|
||||
IReadOnlyDictionary<string, MediaTagFields> current,
|
||||
Func<string, bool>? wouldHydrate = null)
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
var preview = new List<TagPreviewRow>();
|
||||
var operations = new List<PlannedOperation>();
|
||||
foreach (var subject in subjects)
|
||||
{
|
||||
if (subject.IsDirectory)
|
||||
{
|
||||
preview.Add(Row(subject, current, null, "Folders do not have media tags.", true, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wouldHydrate?.Invoke(subject.FullPath) == true)
|
||||
{
|
||||
issues.Add(new PlanIssue(
|
||||
PlanIssueSeverity.Error,
|
||||
"This file is online-only. Writing tags would download it.",
|
||||
subject.FullPath));
|
||||
preview.Add(Row(subject, current, null, "Online-only", true, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
var (stem, _) = WindowsFileNames.Split(subject.Name);
|
||||
if (!FilenamePattern.TryParse(pattern, stem, out var parsed, out var error))
|
||||
{
|
||||
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error ?? "The name does not match the pattern.", subject.FullPath));
|
||||
preview.Add(Row(subject, current, parsed, error, true, true));
|
||||
continue;
|
||||
}
|
||||
|
||||
var existing = current.GetValueOrDefault(subject.FullPath) ?? new MediaTagFields();
|
||||
var unchanged = SameWritable(existing, parsed);
|
||||
preview.Add(Row(subject, current, parsed, unchanged ? "Unchanged" : null, unchanged, true));
|
||||
if (!unchanged)
|
||||
{
|
||||
operations.Add(new PlannedOperation(TransferOp.WriteTags, subject.FullPath, parsed.Payload()));
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
|
||||
{
|
||||
return new OperationPlan { Issues = issues, TagPreview = preview };
|
||||
}
|
||||
|
||||
return new OperationPlan { Operations = operations, Issues = issues, TagPreview = preview };
|
||||
}
|
||||
|
||||
public OperationPlan BuildRename(
|
||||
IReadOnlyList<RenameSubject> subjects,
|
||||
string pattern,
|
||||
IReadOnlyDictionary<string, MediaTagFields> current,
|
||||
RenamePlanner rename,
|
||||
Func<string, bool>? pathExists = null,
|
||||
Func<string, bool>? wouldHydrate = null)
|
||||
{
|
||||
var issues = new List<PlanIssue>();
|
||||
foreach (var subject in subjects)
|
||||
{
|
||||
if (wouldHydrate?.Invoke(subject.FullPath) == true
|
||||
&& FilenamePattern.UsesMediaContent(pattern))
|
||||
{
|
||||
issues.Add(new PlanIssue(
|
||||
PlanIssueSeverity.Error,
|
||||
"This file is online-only. Reading tags would download it.",
|
||||
subject.FullPath));
|
||||
}
|
||||
}
|
||||
|
||||
if (issues.Count > 0)
|
||||
{
|
||||
return new OperationPlan { Issues = issues };
|
||||
}
|
||||
|
||||
return rename.Build(
|
||||
subjects,
|
||||
new RenameRuleSet { NamePattern = pattern },
|
||||
pathExists,
|
||||
current);
|
||||
}
|
||||
|
||||
private static TagPreviewRow Row(
|
||||
RenameSubject subject,
|
||||
IReadOnlyDictionary<string, MediaTagFields> current,
|
||||
MediaTagFields? parsed,
|
||||
string? status,
|
||||
bool tagsUnchanged,
|
||||
bool nameUnchanged)
|
||||
{
|
||||
var fields = parsed ?? current.GetValueOrDefault(subject.FullPath) ?? new MediaTagFields();
|
||||
return new TagPreviewRow(
|
||||
subject.FullPath,
|
||||
subject.Name,
|
||||
fields.Artist ?? "",
|
||||
fields.Title ?? "",
|
||||
fields.Album ?? "",
|
||||
fields.Track?.ToString() ?? "",
|
||||
fields.Year?.ToString() ?? "",
|
||||
fields.Genre ?? "",
|
||||
null,
|
||||
status,
|
||||
tagsUnchanged,
|
||||
nameUnchanged);
|
||||
}
|
||||
|
||||
private static bool SameWritable(MediaTagFields left, MediaTagFields right)
|
||||
=> string.Equals(left.Artist ?? "", right.Artist ?? "", StringComparison.Ordinal)
|
||||
&& string.Equals(left.Title ?? "", right.Title ?? "", StringComparison.Ordinal)
|
||||
&& string.Equals(left.Album ?? "", right.Album ?? "", StringComparison.Ordinal)
|
||||
&& left.Track == right.Track
|
||||
&& left.Year == right.Year
|
||||
&& string.Equals(left.Genre ?? "", right.Genre ?? "", StringComparison.Ordinal);
|
||||
}
|
||||
102
src/Explorer.Application/TreeRevealSelector.cs
Normal file
102
src/Explorer.Application/TreeRevealSelector.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Explorer.Domain;
|
||||
|
||||
namespace Explorer.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Chooses which locations-tree root should be expanded for a filesystem path.
|
||||
/// Does not change the canonical path shown in the folder pane or breadcrumbs.
|
||||
/// </summary>
|
||||
public readonly record struct TreeRevealCandidate(string Path, bool IsFavorite);
|
||||
|
||||
public static class TreeRevealSelector
|
||||
{
|
||||
public static TreeRevealCandidate? Choose(
|
||||
IReadOnlyList<TreeRevealCandidate> candidates,
|
||||
string targetPath,
|
||||
bool preferFavorites,
|
||||
bool currentlyInFavorites)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetPath) || candidates.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matches = new List<TreeRevealCandidate>();
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (Covers(candidate.Path, targetPath))
|
||||
{
|
||||
matches.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IReadOnlyList<TreeRevealCandidate> pool;
|
||||
if (preferFavorites || currentlyInFavorites)
|
||||
{
|
||||
var favorites = Matches(matches, favorite: true);
|
||||
pool = favorites.Count > 0 ? favorites : Matches(matches, favorite: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
var others = Matches(matches, favorite: false);
|
||||
pool = others.Count > 0 ? others : matches;
|
||||
}
|
||||
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TreeRevealCandidate best = pool[0];
|
||||
var bestLength = NormalizedLength(best.Path);
|
||||
for (var i = 1; i < pool.Count; i++)
|
||||
{
|
||||
var length = NormalizedLength(pool[i].Path);
|
||||
if (length > bestLength)
|
||||
{
|
||||
best = pool[i];
|
||||
bestLength = length;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
public static bool Covers(string rootPath, string targetPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath) || LocationRoots.IsVirtual(rootPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = Normalize(rootPath);
|
||||
var target = Normalize(targetPath);
|
||||
return target.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| target.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<TreeRevealCandidate> Matches(List<TreeRevealCandidate> matches, bool favorite)
|
||||
{
|
||||
var result = new List<TreeRevealCandidate>();
|
||||
foreach (var candidate in matches)
|
||||
{
|
||||
if (candidate.IsFavorite == favorite)
|
||||
{
|
||||
result.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Normalize(string path)
|
||||
=> PathRules.FromExtended(path).TrimEnd('\\');
|
||||
|
||||
private static int NormalizedLength(string path)
|
||||
=> Normalize(path).Length;
|
||||
}
|
||||
@@ -36,8 +36,15 @@ public sealed record UiPreferences(
|
||||
string? OrganizeDevelopment = null,
|
||||
bool AutoIndexRemovable = false,
|
||||
bool BackgroundHostAtLogon = false,
|
||||
IReadOnlyList<string>? FavoriteFolders = null,
|
||||
IReadOnlyList<SessionTabState>? SessionTabs = null,
|
||||
int SessionActiveTab = 0)
|
||||
int SessionActiveTab = 0,
|
||||
bool PreferFavoritesInTree = false,
|
||||
bool BackgroundMaintenanceWhenIdle = true,
|
||||
int IdleMaintenanceMinutes = 10,
|
||||
bool IdleMaintenanceAcOnly = true,
|
||||
IReadOnlyList<string>? NamePatterns = null,
|
||||
IReadOnlyList<string>? MoveToPatterns = null)
|
||||
{
|
||||
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
|
||||
}
|
||||
@@ -84,11 +91,18 @@ public sealed class UiPreferencesStore
|
||||
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
|
||||
"auto-index-removable=" + (preferences.AutoIndexRemovable ? "true" : "false"),
|
||||
"background-host-at-logon=" + (preferences.BackgroundHostAtLogon ? "true" : "false"),
|
||||
"prefer-favorites-in-tree=" + (preferences.PreferFavoritesInTree ? "true" : "false"),
|
||||
"background-maintenance-when-idle=" + (preferences.BackgroundMaintenanceWhenIdle ? "true" : "false"),
|
||||
"idle-maintenance-minutes=" + NormalizeIdleMinutes(preferences.IdleMaintenanceMinutes),
|
||||
"idle-maintenance-ac-only=" + (preferences.IdleMaintenanceAcOnly ? "true" : "false"),
|
||||
.. SevenZipLines(preferences),
|
||||
.. GitLines(preferences),
|
||||
.. FfmpegLines(preferences),
|
||||
.. OrganizeLines(preferences),
|
||||
.. LayoutLines(preferences),
|
||||
.. FavoriteLines(preferences),
|
||||
.. NamePatternLines(preferences),
|
||||
.. MoveToPatternLines(preferences),
|
||||
.. SessionLines(preferences)
|
||||
]);
|
||||
}
|
||||
@@ -109,6 +123,10 @@ public sealed class UiPreferencesStore
|
||||
var autoClearQueue = false;
|
||||
var autoIndexRemovable = false;
|
||||
var backgroundHostAtLogon = false;
|
||||
var preferFavoritesInTree = false;
|
||||
var backgroundMaintenanceWhenIdle = true;
|
||||
var idleMaintenanceMinutes = 10;
|
||||
var idleMaintenanceAcOnly = true;
|
||||
string? sevenZipPath = null;
|
||||
string? gitPath = null;
|
||||
string? ffmpegPath = null;
|
||||
@@ -127,6 +145,9 @@ public sealed class UiPreferencesStore
|
||||
double? treeWidth = null;
|
||||
var sessionTabs = new List<SessionTabState>();
|
||||
var sessionActiveTab = 0;
|
||||
var favorites = new List<string>();
|
||||
var namePatterns = new List<string>();
|
||||
var moveToPatterns = new List<string>();
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var line = raw.Trim();
|
||||
@@ -179,6 +200,23 @@ public sealed class UiPreferencesStore
|
||||
{
|
||||
backgroundHostAtLogon = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("prefer-favorites-in-tree", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
preferFavoritesInTree = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("background-maintenance-when-idle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
backgroundMaintenanceWhenIdle = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("idle-maintenance-minutes", StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var minutes))
|
||||
{
|
||||
idleMaintenanceMinutes = NormalizeIdleMinutes(minutes);
|
||||
}
|
||||
else if (key.Equals("idle-maintenance-ac-only", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
idleMaintenanceAcOnly = IsTrue(value);
|
||||
}
|
||||
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
@@ -243,6 +281,21 @@ public sealed class UiPreferencesStore
|
||||
{
|
||||
treeWidth = ParseDouble(value);
|
||||
}
|
||||
else if (key.Equals("favorite", StringComparison.OrdinalIgnoreCase)
|
||||
&& favorites.Count < FavoriteFolders.MaxCount)
|
||||
{
|
||||
favorites.Add(value);
|
||||
}
|
||||
else if (key.Equals("name-pattern", StringComparison.OrdinalIgnoreCase)
|
||||
&& namePatterns.Count < FilenamePatterns.MaxCount)
|
||||
{
|
||||
namePatterns.Add(value);
|
||||
}
|
||||
else if (key.Equals("move-to", StringComparison.OrdinalIgnoreCase)
|
||||
&& moveToPatterns.Count < DestinationPatterns.MaxCount)
|
||||
{
|
||||
moveToPatterns.Add(value);
|
||||
}
|
||||
else if (key.Equals("session-active-tab", StringComparison.OrdinalIgnoreCase)
|
||||
&& int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var activeTab)
|
||||
&& activeTab >= 0)
|
||||
@@ -266,7 +319,11 @@ public sealed class UiPreferencesStore
|
||||
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
|
||||
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath, ffmpegPath,
|
||||
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
|
||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon, sessionTabs, sessionActiveTab);
|
||||
organizeDevelopment, autoIndexRemovable, backgroundHostAtLogon,
|
||||
FavoriteFolders.Normalize(favorites), sessionTabs, sessionActiveTab, preferFavoritesInTree,
|
||||
backgroundMaintenanceWhenIdle, idleMaintenanceMinutes, idleMaintenanceAcOnly,
|
||||
FilenamePatterns.Normalize(namePatterns),
|
||||
DestinationPatterns.Normalize(moveToPatterns));
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
|
||||
@@ -364,6 +421,30 @@ public sealed class UiPreferencesStore
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> NamePatternLines(UiPreferences preferences)
|
||||
{
|
||||
foreach (var pattern in FilenamePatterns.Normalize(preferences.NamePatterns))
|
||||
{
|
||||
yield return "name-pattern=" + pattern;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> MoveToPatternLines(UiPreferences preferences)
|
||||
{
|
||||
foreach (var pattern in DestinationPatterns.Normalize(preferences.MoveToPatterns))
|
||||
{
|
||||
yield return "move-to=" + pattern;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FavoriteLines(UiPreferences preferences)
|
||||
{
|
||||
foreach (var path in FavoriteFolders.Normalize(preferences.FavoriteFolders))
|
||||
{
|
||||
yield return "favorite=" + path;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SessionLines(UiPreferences preferences)
|
||||
{
|
||||
var tabs = preferences.SessionTabs;
|
||||
@@ -443,6 +524,9 @@ public sealed class UiPreferencesStore
|
||||
public static string NormalizeTheme(string? theme)
|
||||
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
|
||||
|
||||
public static int NormalizeIdleMinutes(int minutes)
|
||||
=> minutes <= 5 ? 5 : minutes >= 30 ? 30 : 10;
|
||||
|
||||
private static string? EmptyToNull(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ public sealed class LocalSourceHost : ISourceHost
|
||||
=> _sources.EnsureForPathAsync(path, cancellationToken);
|
||||
public Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> _sources.ForgetDisconnectedAsync(path, cancellationToken);
|
||||
public Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> _sources.MarkReachableAsync(path, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class LocalIndexMutations : IIndexMutations
|
||||
@@ -55,4 +57,53 @@ public sealed class LocalIndexMutations : IIndexMutations
|
||||
=> _store.Hashes.EnqueueSizeCollisionsAsync(sourceId, cancellationToken);
|
||||
public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default)
|
||||
=> _store.Relations.UpsertAsync(relation, cancellationToken);
|
||||
|
||||
public async Task SetUserCategoryByPathAsync(
|
||||
string fullPath,
|
||||
FileCategory category,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||
Source? best = null;
|
||||
var bestLen = -1;
|
||||
var path = PathRules.FromExtended(fullPath).TrimEnd('\\');
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source.LastRootPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var root = PathRules.FromExtended(source.LastRootPath).TrimEnd('\\');
|
||||
if (path.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (root.Length > bestLen)
|
||||
{
|
||||
best = source;
|
||||
bestLen = root.Length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (best?.LastRootPath is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var rel = PathRules.MakeRelative(best.LastRootPath, fullPath) ?? "";
|
||||
var entry = await _store.Entries.GetByPathAsync(best.Id, rel, cancellationToken).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _store.Entries.SetUserCategoryAsync(entry.Id, category, reason, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
52
src/Explorer.Contracts/HostActivity.cs
Normal file
52
src/Explorer.Contracts/HostActivity.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace Explorer.Contracts;
|
||||
|
||||
public sealed class HostActivitySnapshot
|
||||
{
|
||||
public DateTimeOffset Utc { get; init; } = DateTimeOffset.UtcNow;
|
||||
public MaintenanceSnapshot Maintenance { get; init; } = MaintenanceSnapshot.Empty;
|
||||
public bool IdleAllowed { get; init; }
|
||||
public int IdleQueued { get; init; }
|
||||
public bool HashPaused { get; init; }
|
||||
public long HashPending { get; init; }
|
||||
public string? HashCurrentPath { get; init; }
|
||||
public bool TransfersPaused { get; init; }
|
||||
public int TransfersActive { get; init; }
|
||||
public int TransfersQueued { get; init; }
|
||||
public string? TransferCurrentPath { get; init; }
|
||||
public string? LastIndexPath { get; init; }
|
||||
public long LastIndexFilesDone { get; init; }
|
||||
public long LastIndexDirsDone { get; init; }
|
||||
public string? LastIndexStatus { get; init; }
|
||||
public IReadOnlyList<HostActivityJob> IndexingJobs { get; init; } = [];
|
||||
public IReadOnlyList<HostActivityEvent> RecentEvents { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class HostActivityJob
|
||||
{
|
||||
public long SourceId { get; init; }
|
||||
public required string SourceName { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Origin { get; init; }
|
||||
public string? PathRel { get; init; }
|
||||
public bool VerifyChildren { get; init; }
|
||||
}
|
||||
|
||||
public sealed class HostActivityEvent
|
||||
{
|
||||
public DateTimeOffset Utc { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
public interface IHostActivity
|
||||
{
|
||||
Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class NullHostActivity : IHostActivity
|
||||
{
|
||||
public static NullHostActivity Instance { get; } = new();
|
||||
|
||||
public Task<HostActivitySnapshot> GetSnapshotAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new HostActivitySnapshot());
|
||||
}
|
||||
34
src/Explorer.Contracts/IBackgroundMaintenance.cs
Normal file
34
src/Explorer.Contracts/IBackgroundMaintenance.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Explorer.Contracts;
|
||||
|
||||
public sealed record MaintenanceSnapshot(
|
||||
string Activity,
|
||||
bool Allowed,
|
||||
string Message,
|
||||
string? SkipReason = null)
|
||||
{
|
||||
public static MaintenanceSnapshot Empty { get; } = new("Active", false, "");
|
||||
}
|
||||
|
||||
public interface IBackgroundMaintenance
|
||||
{
|
||||
event EventHandler<MaintenanceSnapshot>? Changed;
|
||||
MaintenanceSnapshot Snapshot { get; }
|
||||
void RunNow();
|
||||
}
|
||||
|
||||
public sealed class NullBackgroundMaintenance : IBackgroundMaintenance
|
||||
{
|
||||
public static NullBackgroundMaintenance Instance { get; } = new();
|
||||
|
||||
public event EventHandler<MaintenanceSnapshot>? Changed
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public MaintenanceSnapshot Snapshot => MaintenanceSnapshot.Empty;
|
||||
|
||||
public void RunNow()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public interface IIndexingHost
|
||||
void EnqueueFullScan(long sourceId);
|
||||
void EnqueueFolderScan(long sourceId, string pathRel);
|
||||
void EnqueueReconcile(long sourceId, string pathRel);
|
||||
/// <summary>Shallow reconcile, then descend into child folders whose index no longer matches disk.</summary>
|
||||
void EnqueueVerify(long sourceId, string pathRel) => EnqueueReconcile(sourceId, pathRel);
|
||||
void Cancel(long sourceId);
|
||||
}
|
||||
|
||||
@@ -55,6 +57,10 @@ public interface ITransferHost
|
||||
=> Task.CompletedTask;
|
||||
Task EnqueueConvertAsync(string sourcePath, string destinationPath, ConversionKind kind, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
Task EnqueueWriteTagsAsync(string path, string payload, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
Task EnqueueMoveToAsync(string source, string destinationPath, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
public interface ISourceHost
|
||||
@@ -63,6 +69,8 @@ public interface ISourceHost
|
||||
Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<bool> ForgetAsync(string path, CancellationToken cancellationToken = default);
|
||||
Task<Source?> MarkReachableAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<Source?>(null);
|
||||
}
|
||||
|
||||
public interface IIndexMutations
|
||||
@@ -75,4 +83,6 @@ public interface IIndexMutations
|
||||
Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default);
|
||||
Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
||||
Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default);
|
||||
Task SetUserCategoryByPathAsync(string fullPath, FileCategory category, string? reason = null, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ public interface IEntryStore
|
||||
Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default);
|
||||
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
|
||||
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||
Task RefreshCategoryFromChildrenAsync(long folderOrArchiveId, CancellationToken cancellationToken = default);
|
||||
Task SetUserCategoryAsync(long entryId, FileCategory category, string? reason = null, CancellationToken cancellationToken = default);
|
||||
Task<int> BackfillCheapCategoriesAsync(int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<long>> GetArchiveIdsNeedingContentClassifyAsync(int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<IndexEntry>> GetUnknownFilesForSignatureAsync(int take, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IExcludeStore
|
||||
@@ -109,6 +114,7 @@ public sealed class SearchRequest
|
||||
public bool DirectChildrenOnly { get; init; }
|
||||
public bool IncludeOffline { get; init; } = true;
|
||||
public bool IncludeDeleted { get; init; }
|
||||
public FileCategory? Category { get; init; }
|
||||
public int Skip { get; init; }
|
||||
public int Take { get; init; } = 500;
|
||||
}
|
||||
@@ -121,6 +127,7 @@ public interface IAnalysisStore
|
||||
Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(long? sourceId, long? parentId, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<CategoryUsage>> UsageByCategoryAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<IndexEntry>> ChildrenBySizeAsync(long parentId, int take, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -132,6 +139,13 @@ public sealed class ExtensionUsage
|
||||
public long FileCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CategoryUsage
|
||||
{
|
||||
public required string Category { get; init; }
|
||||
public long TotalSize { get; init; }
|
||||
public long FileCount { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SourceUsage
|
||||
{
|
||||
public long SourceId { get; init; }
|
||||
@@ -159,6 +173,8 @@ public sealed class SourceSnapshot
|
||||
public interface IHashStore
|
||||
{
|
||||
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
||||
Task<bool> HasPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<long> CountPendingAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
|
||||
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||
@@ -167,6 +183,10 @@ public interface IHashStore
|
||||
Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
|
||||
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<byte[]>> GetDuplicateHashesAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsByHashesAsync(
|
||||
IReadOnlyList<byte[]> hashes,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IFileRelationStore
|
||||
|
||||
@@ -23,6 +23,7 @@ public interface IVolumeService
|
||||
VolumeFingerprint? Probe(string path);
|
||||
VolumeSpace GetSpace(string path);
|
||||
bool IsPathReachable(string path);
|
||||
bool TryEnsureReachable(string path) => IsPathReachable(path);
|
||||
}
|
||||
|
||||
public interface IFileSystemEnumerator
|
||||
@@ -94,6 +95,18 @@ public static class UsnReasons
|
||||
public const int Close = unchecked((int)0x80000000);
|
||||
}
|
||||
|
||||
public sealed record ShellContextVerb(
|
||||
string Id,
|
||||
string Label,
|
||||
IReadOnlyList<ShellContextVerb>? Children = null);
|
||||
|
||||
public interface IShellContextMenu
|
||||
{
|
||||
IReadOnlyList<ShellContextVerb> Query(IReadOnlyList<string> paths);
|
||||
bool TryInvoke(string id, out string? error);
|
||||
bool TryShowFullMenu(IReadOnlyList<string> paths, int screenX, int screenY, nint ownerHwnd, out string? error);
|
||||
}
|
||||
|
||||
public interface IShellFileOperations
|
||||
{
|
||||
void Open(string path);
|
||||
|
||||
@@ -19,5 +19,6 @@ public static class AppConstants
|
||||
public const int NetworkScanParallelism = 1;
|
||||
public const int ProgressHzMilliseconds = 100;
|
||||
public const int MaxArchiveEntries = 8000;
|
||||
public const int IdleRescanAfterDays = 7;
|
||||
public static readonly TimeSpan SyncTimestampSkew = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
@@ -54,4 +54,46 @@ public static class DragDropPolicy
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows SM_CXDRAG is typically 4 DIP — enough to turn a click into a drag.
|
||||
/// A real drag is still immediate; this only ignores click jitter.
|
||||
/// </summary>
|
||||
public const double DragStartDistance = 16;
|
||||
|
||||
/// <summary>Dropping into a neighboring folder row should need more than a row-edge slip.</summary>
|
||||
public const double DropIntoItemDistance = 28;
|
||||
|
||||
/// <summary>Crossing into the other pane or the tree should not happen at the splitter.</summary>
|
||||
public const double DropCrossViewDistance = 36;
|
||||
|
||||
public static bool ExceedsDistance(double deltaX, double deltaY, double minimum)
|
||||
{
|
||||
var threshold = Math.Max(minimum, 0);
|
||||
return (deltaX * deltaX) + (deltaY * deltaY) >= threshold * threshold;
|
||||
}
|
||||
|
||||
public static bool AllAlreadyInDirectory(IReadOnlyList<string> sources, string destinationDirectory)
|
||||
{
|
||||
if (sources.Count == 0 || string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var dest = NormalizeDirectory(destinationDirectory);
|
||||
foreach (var source in sources)
|
||||
{
|
||||
var src = PathRules.FromExtended(source).TrimEnd('\\');
|
||||
var parent = Path.GetDirectoryName(src);
|
||||
if (parent is null || !NormalizeDirectory(parent).Equals(dest, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeDirectory(string path)
|
||||
=> PathRules.FromExtended(path).TrimEnd('\\');
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ public sealed class IndexEntry
|
||||
public long ScanGeneration { get; set; }
|
||||
public long? AllocatedSizeBytes { get; set; }
|
||||
public CloudAvailability? CloudAvailability { get; set; }
|
||||
public FileCategory Category { get; set; } = FileCategory.Unknown;
|
||||
public string? CategoryReason { get; set; }
|
||||
public int CategoryConfidence { get; set; }
|
||||
public string CategorySource { get; set; } = CategorySources.None;
|
||||
public DateTimeOffset? CategoryUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ExcludeRule
|
||||
@@ -149,6 +154,11 @@ public sealed class FileSystemItem
|
||||
public long? CapacityBytes { get; init; }
|
||||
public bool AvailableToImport { get; init; }
|
||||
public ItemHydrationFlags Hydration { get; init; } = ItemHydrationFlags.All;
|
||||
public int IndexedChildCount { get; init; }
|
||||
public long? IndexEntryId { get; init; }
|
||||
public FileCategory Category { get; init; }
|
||||
public string? CategoryReason { get; init; }
|
||||
public string? CategorySource { get; init; }
|
||||
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
||||
|
||||
public FileSystemItem Overlay(
|
||||
@@ -166,7 +176,12 @@ public sealed class FileSystemItem
|
||||
long? freeSpaceBytes = null,
|
||||
long? capacityBytes = null,
|
||||
bool? availableToImport = null,
|
||||
ItemHydrationFlags? hydration = null)
|
||||
ItemHydrationFlags? hydration = null,
|
||||
int? indexedChildCount = null,
|
||||
long? indexEntryId = null,
|
||||
FileCategory? category = null,
|
||||
string? categoryReason = null,
|
||||
string? categorySource = null)
|
||||
=> new()
|
||||
{
|
||||
FullPath = FullPath,
|
||||
@@ -186,7 +201,12 @@ public sealed class FileSystemItem
|
||||
FreeSpaceBytes = freeSpaceBytes ?? FreeSpaceBytes,
|
||||
CapacityBytes = capacityBytes ?? CapacityBytes,
|
||||
AvailableToImport = availableToImport ?? AvailableToImport,
|
||||
Hydration = hydration ?? Hydration
|
||||
Hydration = hydration ?? Hydration,
|
||||
IndexedChildCount = indexedChildCount ?? IndexedChildCount,
|
||||
IndexEntryId = indexEntryId ?? IndexEntryId,
|
||||
Category = category ?? Category,
|
||||
CategoryReason = categoryReason ?? CategoryReason,
|
||||
CategorySource = categorySource ?? CategorySource
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +228,8 @@ public sealed record ScanProgress
|
||||
public long BytesSeen { get; init; }
|
||||
public int ErrorCount { get; init; }
|
||||
public ScanJobStatus Status { get; init; }
|
||||
/// <summary>Index overlay should be reapplied; do not show a full-scan footer.</summary>
|
||||
public bool IndexRefresh { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FolderListing
|
||||
|
||||
129
src/Explorer.Domain/EntryCategoryAssigner.cs
Normal file
129
src/Explorer.Domain/EntryCategoryAssigner.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
namespace Explorer.Domain;
|
||||
|
||||
/// <summary>Where an <see cref="IndexEntry"/> category came from.</summary>
|
||||
public static class CategorySources
|
||||
{
|
||||
public const string None = "none";
|
||||
public const string Extension = "extension";
|
||||
public const string Folder = "folder";
|
||||
public const string Children = "children";
|
||||
public const string ArchiveContents = "archive_contents";
|
||||
public const string Mime = "mime";
|
||||
public const string User = "user";
|
||||
|
||||
public static bool IsUser(string? source)
|
||||
=> string.Equals(source, User, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public static bool IsAutomatic(string? source)
|
||||
=> !IsUser(source);
|
||||
}
|
||||
|
||||
public static class EntryCategoryAssigner
|
||||
{
|
||||
public static void ApplyAutomatic(
|
||||
IndexEntry entry,
|
||||
string? rootPath = null,
|
||||
bool isRepoRoot = false,
|
||||
IReadOnlyList<FileCategory>? childCategories = null)
|
||||
{
|
||||
if (CategorySources.IsUser(entry.CategorySource))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var full = string.IsNullOrWhiteSpace(rootPath)
|
||||
? (string.IsNullOrEmpty(entry.PathRel) ? entry.Name : entry.PathRel)
|
||||
: PathRules.Combine(rootPath, entry.PathRel);
|
||||
var result = FileClassifier.Classify(
|
||||
entry.Name,
|
||||
full,
|
||||
entry.IsDirectory,
|
||||
entry.Attributes,
|
||||
isRepoRoot,
|
||||
childCategories);
|
||||
Apply(entry, result, InferSource(entry, result, childCategories), Confidence(result, childCategories));
|
||||
}
|
||||
|
||||
public static void ApplyUser(IndexEntry entry, FileCategory category, string? reason = null)
|
||||
=> Apply(
|
||||
entry,
|
||||
new FileClassification(category, reason ?? "Set by user."),
|
||||
CategorySources.User,
|
||||
100);
|
||||
|
||||
public static void Apply(
|
||||
IndexEntry entry,
|
||||
FileClassification classification,
|
||||
string source,
|
||||
int confidence)
|
||||
{
|
||||
entry.Category = classification.Category;
|
||||
entry.CategoryReason = classification.Reason;
|
||||
entry.CategorySource = source;
|
||||
entry.CategoryConfidence = Math.Clamp(confidence, 0, 100);
|
||||
entry.CategoryUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public static FileCategory ParseCategory(string? value)
|
||||
=> Enum.TryParse<FileCategory>(value, ignoreCase: true, out var cat) ? cat : FileCategory.Unknown;
|
||||
|
||||
public static string InferSource(
|
||||
IndexEntry entry,
|
||||
FileClassification classification,
|
||||
IReadOnlyList<FileCategory>? childCategories)
|
||||
{
|
||||
if (childCategories is { Count: > 0 } && classification.Category != FileCategory.Unknown)
|
||||
{
|
||||
return entry.IsDirectory
|
||||
? CategorySources.Children
|
||||
: CategorySources.ArchiveContents;
|
||||
}
|
||||
|
||||
if (entry.IsDirectory)
|
||||
{
|
||||
return classification.Category == FileCategory.Unknown
|
||||
? CategorySources.None
|
||||
: CategorySources.Folder;
|
||||
}
|
||||
|
||||
if (ArchiveFormats.IsArchive(entry.Name))
|
||||
{
|
||||
return CategorySources.Extension;
|
||||
}
|
||||
|
||||
return classification.Category == FileCategory.Unknown
|
||||
? CategorySources.None
|
||||
: CategorySources.Extension;
|
||||
}
|
||||
|
||||
public static int Confidence(FileClassification classification, IReadOnlyList<FileCategory>? childCategories)
|
||||
{
|
||||
if (classification.Category == FileCategory.Unknown)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (childCategories is { Count: > 0 })
|
||||
{
|
||||
var known = childCategories.Count(c => c is not FileCategory.Unknown);
|
||||
if (known == 0)
|
||||
{
|
||||
return 40;
|
||||
}
|
||||
|
||||
var majority = childCategories
|
||||
.Where(c => c == classification.Category)
|
||||
.Count();
|
||||
return Math.Clamp(40 + majority * 60 / known, 40, 95);
|
||||
}
|
||||
|
||||
return classification.Category switch
|
||||
{
|
||||
FileCategory.SystemData or FileCategory.BuildOutput => 95,
|
||||
FileCategory.Archive or FileCategory.Photos or FileCategory.Video
|
||||
or FileCategory.Audio or FileCategory.Documents => 85,
|
||||
FileCategory.Installer or FileCategory.Backup or FileCategory.CodeRepository => 80,
|
||||
_ => 50
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,8 @@ public enum TransferOp
|
||||
Compress,
|
||||
AddToArchive,
|
||||
VerifyArchive,
|
||||
Convert
|
||||
Convert,
|
||||
WriteTags
|
||||
}
|
||||
|
||||
public enum ArchiveFormat
|
||||
|
||||
@@ -87,6 +87,13 @@ public static class FileClassifier
|
||||
{
|
||||
if (ArchiveFormats.IsArchive(name))
|
||||
{
|
||||
if (TryMajority(childCategories, out var archiveMajority))
|
||||
{
|
||||
return new FileClassification(
|
||||
archiveMajority,
|
||||
"Archive contents are mostly " + OrganizeDestinations.Label(archiveMajority).ToLowerInvariant() + ".");
|
||||
}
|
||||
|
||||
return new FileClassification(FileCategory.Archive, "Archive file.");
|
||||
}
|
||||
|
||||
@@ -147,24 +154,62 @@ public static class FileClassifier
|
||||
return new FileClassification(FileCategory.Backup, "Backup folder name.");
|
||||
}
|
||||
|
||||
if (childCategories is { Count: > 0 })
|
||||
if (TryMajority(childCategories, out var folderMajority))
|
||||
{
|
||||
var known = childCategories.Where(c => c is not FileCategory.Unknown and not FileCategory.BuildOutput and not FileCategory.SystemData).ToList();
|
||||
if (known.Count > 0)
|
||||
{
|
||||
var majority = known.GroupBy(c => c).OrderByDescending(g => g.Count()).First();
|
||||
if (majority.Count() * 10 >= known.Count * 7)
|
||||
{
|
||||
return new FileClassification(majority.Key, "Folder contents are mostly " + OrganizeDestinations.Label(majority.Key).ToLowerInvariant() + ".");
|
||||
}
|
||||
}
|
||||
return new FileClassification(
|
||||
folderMajority,
|
||||
"Folder contents are mostly " + OrganizeDestinations.Label(folderMajority).ToLowerInvariant() + ".");
|
||||
}
|
||||
|
||||
return new FileClassification(FileCategory.Unknown, "No matching signal.");
|
||||
}
|
||||
|
||||
private static bool TryMajority(IReadOnlyList<FileCategory>? childCategories, out FileCategory majority)
|
||||
{
|
||||
majority = FileCategory.Unknown;
|
||||
if (childCategories is not { Count: > 0 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var known = childCategories
|
||||
.Where(c => c is not FileCategory.Unknown and not FileCategory.BuildOutput and not FileCategory.SystemData)
|
||||
.ToList();
|
||||
if (known.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var top = known.GroupBy(c => c).OrderByDescending(g => g.Count()).First();
|
||||
if (top.Count() * 10 < known.Count * 7)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
majority = top.Key;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool ShouldLeave(FileCategory category)
|
||||
=> category is FileCategory.Unknown or FileCategory.SystemData or FileCategory.BuildOutput or FileCategory.Backup;
|
||||
|
||||
public static bool SkipDescent(string name) => BuildNames.Contains(name);
|
||||
|
||||
public static bool IsPhotoFile(string name)
|
||||
{
|
||||
var ext = NameNormalizer.Extension(name);
|
||||
return ext is not null && Photos.Contains(ext);
|
||||
}
|
||||
|
||||
public static bool IsVideoFile(string name)
|
||||
{
|
||||
var ext = NameNormalizer.Extension(name);
|
||||
return ext is not null && Videos.Contains(ext);
|
||||
}
|
||||
|
||||
public static bool IsAudioFile(string name)
|
||||
{
|
||||
var ext = NameNormalizer.Extension(name);
|
||||
return ext is not null && Audio.Contains(ext);
|
||||
}
|
||||
}
|
||||
|
||||
84
src/Explorer.Domain/FileSignatureClassifier.cs
Normal file
84
src/Explorer.Domain/FileSignatureClassifier.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace Explorer.Domain;
|
||||
|
||||
/// <summary>Light magic-byte hints for files with no useful extension category.</summary>
|
||||
public static class FileSignatureClassifier
|
||||
{
|
||||
public static FileClassification? TryClassify(ReadOnlySpan<byte> header)
|
||||
{
|
||||
if (header.Length < 4)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF)
|
||||
{
|
||||
return new FileClassification(FileCategory.Photos, "JPEG signature.");
|
||||
}
|
||||
|
||||
if (header.Length >= 8
|
||||
&& header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47)
|
||||
{
|
||||
return new FileClassification(FileCategory.Photos, "PNG signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x38)
|
||||
{
|
||||
return new FileClassification(FileCategory.Photos, "GIF signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 && header[3] == 0x46)
|
||||
{
|
||||
return new FileClassification(FileCategory.Documents, "PDF signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x50 && header[1] == 0x4B && (header[2] == 0x03 || header[2] == 0x05 || header[2] == 0x07))
|
||||
{
|
||||
return new FileClassification(FileCategory.Archive, "ZIP-family signature.");
|
||||
}
|
||||
|
||||
if (header.Length >= 12
|
||||
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46)
|
||||
{
|
||||
var form = System.Text.Encoding.ASCII.GetString(header.Slice(8, 4));
|
||||
if (form is "WAVE" or "AVI ")
|
||||
{
|
||||
return new FileClassification(
|
||||
form == "WAVE" ? FileCategory.Audio : FileCategory.Video,
|
||||
"RIFF " + form.Trim() + " signature.");
|
||||
}
|
||||
}
|
||||
|
||||
if (header.Length >= 12
|
||||
&& header[4] == 0x66 && header[5] == 0x74 && header[6] == 0x79 && header[7] == 0x70)
|
||||
{
|
||||
return new FileClassification(FileCategory.Video, "ISO BMFF (ftyp) signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x49 && header[1] == 0x44 && header[2] == 0x33)
|
||||
{
|
||||
return new FileClassification(FileCategory.Audio, "ID3 signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x7F && header[1] == 0x45 && header[2] == 0x4C && header[3] == 0x46)
|
||||
{
|
||||
return new FileClassification(FileCategory.Installer, "ELF binary signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x4D && header[1] == 0x5A)
|
||||
{
|
||||
return new FileClassification(FileCategory.Installer, "PE/MZ signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x37 && header[1] == 0x7A && header[2] == 0xBC && header[3] == 0xAF)
|
||||
{
|
||||
return new FileClassification(FileCategory.Archive, "7z signature.");
|
||||
}
|
||||
|
||||
if (header[0] == 0x52 && header[1] == 0x61 && header[2] == 0x72 && header[3] == 0x21)
|
||||
{
|
||||
return new FileClassification(FileCategory.Archive, "RAR signature.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ namespace Explorer.Domain;
|
||||
public static class LocationRoots
|
||||
{
|
||||
public const string ThisPc = "This PC";
|
||||
public const string Home = "Home";
|
||||
public const string Favorites = "Favorites";
|
||||
public const string Network = "Network";
|
||||
public const string Cloud = "Cloud";
|
||||
public const string RecycleBin = "Recycle Bin";
|
||||
|
||||
public static bool IsVirtual(string? path)
|
||||
=> path is ThisPc or Network or Cloud or RecycleBin;
|
||||
=> path is ThisPc or Home or Favorites or Network or Cloud or RecycleBin;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user