Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

823
Backlog.md Normal file
View File

@@ -0,0 +1,823 @@
# Explorer Workbench - Backlog
> Personal file management workbench.
>
> Goal: Make working with files faster - not necessarily the filesystem itself.
>
> **Primary success metric:** One happy customer. 😎
---
## Core Principles
### Windows remains the source of truth
Explorer Workbench may discover, index, display and organize Windows resources, but should not silently modify Windows configuration.
Example:
- Removing `Z:` from Workbench removes the Workbench location and its index data.
- It does **not** disconnect the Windows network drive.
- Existing Windows resources should be discoverable/importable again.
### Workbench owns the workflow
Specialized tools may provide execution capabilities.
Possible providers:
- Robocopy - robust file transfers
- 7-Zip - archive operations
- FFmpeg - media conversion
- Git - repository operations
Explorer Workbench provides:
- UI
- orchestration
- metadata
- index
- profiles
- queue
- status
- history
### File Operations Queue is the execution engine
Whenever possible, features that modify files should create operations instead of modifying the filesystem directly.
Examples:
- Copy
- Move
- Recycle
- Delete
- Rename
- Extract
- Compress
- Convert
- Sync
- Reorganize
### Analyze aggressively, modify conservatively
Analysis may be ambitious.
Filesystem modifications should be predictable, reviewable and safe.
Complex operations should preferably follow:
`Analyze -> Preview -> Operation Plan -> Queue -> Execute`
### Configuration rule
If both behaviors are useful depending on the situation:
**Make it an option.**
---
# Current Features
## Navigation
- [x] Group locations into `This PC`, `Network` and `Cloud`
- [x] Optional grouping via Settings
- [x] Remove locations from Workbench
- [x] Remove associated index/database data
- [x] Do not modify Windows mappings when removing Workbench locations
- [x] Add network/cloud locations
- [x] Discover/import existing Windows network drives
- [x] Restore a previously removed Workbench location from Windows discovery
## UI
- [x] Classic application menu
- [x] Icon-based toolbar
- [x] Settings dialog
- [x] Help menu with About and in-app Documentation
- [x] Expandable Settings architecture
- [x] Show `Free Space` next to `Size` where meaningful
- [x] Consistent capacity information for local/network/cloud providers
- [x] Organize folder (classify → preview → queue moves)
## Search / Index
- [x] Central filesystem index
- [x] Fast indexed search
- [ ] Search offline locations
- [x] Search archive contents
- [ ] Store originating machine/location
- [ ] Multi-PC search
- [ ] Richer metadata indexing
## Storage Analysis
- [x] Storage usage analysis
- [x] WinDirStat-style functionality
- [x] Duplicate detection
- [x] Duplicate classification
- [x] Distinguish intentional duplicates
- [x] Recognize Sync copies
- [ ] Recognize Backup copies
- [x] Detect hardlinks
---
# File Operations Queue
## Implemented
- [x] Central File Operations Queue
- [x] Copy
- [x] Move
- [x] Move to Recycle Bin
- [x] Queue Rename
- [x] Queue Extract
- [x] Queue Compress
- [x] Sequential execution
- [x] Pause / Resume
- [x] Pause all
- [x] Progress display
- [x] Queue status
- [x] Reorder operations
- [x] Remove queued operations
- [x] Clear finished operations
- [x] Auto-clear when done
- [x] Compact queue status in main UI
- [x] Persist queue across Workbench restarts
- [x] Retry failed operations
- [x] `Waiting for destination` state
- [x] Resume automatically when destination becomes available
- [x] Handle temporarily unavailable network shares
- [x] Handle removable media (wait until reachable)
- [x] Detect file locks (Failed + Retry)
- [x] Operation history (append-only)
## Planned
- [ ] Conflict resolution
- [x] Undo last rename batch
- [ ] Undo copy / move
- [ ] Resource-aware scheduling
### Resource-aware scheduling
Potential future behavior:
Instead of always running exactly one operation, allow concurrent operations when they do not compete for the same I/O resources.
Example:
`C: -> D:` and `NAS -> USB` may run simultaneously.
Two operations using the same physical source/target should normally remain serialized.
---
# Protected Windows Filesystem Locations
## Visibility
- [x] Hidden file support
- [x] Setting: `Show protected system locations`
- [x] Detect `FileAttributes.Hidden`
- [x] Detect `FileAttributes.System`
- [x] Classify known protected Windows locations
- [x] Distinguish `Access denied` from empty folders
Known examples:
- `System Volume Information`
- `$RECYCLE.BIN`
- `Recovery`
- `pagefile.sys`
- `hiberfil.sys`
## Size calculation
Never display:
`0 B`
when the actual state is:
`Unknown because access was denied`
Possible states:
- Calculated
- Partial
- Unknown
- Access denied
## Elevated scan
- [ ] Detect when elevation would provide additional information
- [ ] Optional elevated/deep scan
- [ ] Never silently elevate
- [ ] Keep normal Workbench operation non-elevated
## Recycle Bin
- [x] Classify `$RECYCLE.BIN` specially
- [x] Do not expose it merely as a normal filesystem folder
- [x] Show logical Recycle Bin information
- [x] Number of deleted items
- [x] Used space
- [x] Open Recycle Bin
- [x] Empty Recycle Bin
---
# Folder Sync
Goal: Replace the relevant AllSync workflows.
## Sync modes
- [x] Copy / Update
- [x] Mirror
- [ ] Two-way sync
- [ ] Versioned backup
Two-way sync should be implemented later because it requires proper conflict and deletion tracking.
## Integration
- [x] Analyze differences
- [x] Generate operation plan
- [x] Submit operations to File Operations Queue
- [x] Preview before execution
- [x] Conflict detection
- [ ] Sync history
## Offline / removable locations
- [x] Detect target availability
- [x] Wait when target is offline
- [x] Automatically continue when target returns
- [x] Identify removable media using stable volume identity
- [x] Do not depend only on drive letters
- [x] Trigger sync when known media is connected
Example:
`When PhotosBackup becomes available -> run Photos Sync`
## Duplicate awareness
Files intentionally created by Folder Sync should be marked as intentional duplicates.
Possible classification:
- `Accidental`
- `Synchronized`
- `Backup`
- `Unknown`
The Duplicate Finder hides intentional duplicates by default. Sync and backup tags are written when those operations exist.
---
# Batch Rename
Goal: Replace FileRenamer workflows.
## Rename rules
- [x] Search / Replace
- [x] Regex
- [x] Prefix
- [x] Suffix
- [x] Counter
- [x] Counter padding
- [x] Case conversion
- [x] Extension handling
- [ ] Metadata placeholders
Potential placeholders:
- `{CreatedDate}`
- `{ModifiedDate}`
- `{Counter}`
- `{Width}`
- `{Height}`
- `{Artist}`
- `{Title}`
- `{Project}`
- `{Extension}`
## Workflow
`Select -> Configure -> Preview -> Validate -> Queue`
- [x] Preview all resulting names
- [x] Detect collisions
- [x] Detect invalid filenames
- [x] Add Rename operations to File Operations Queue
- [x] Store `OldPath -> NewPath`
- [x] Support Undo for completed rename batches
---
# File Operation Profiles
Reusable workflows built from Workbench operations.
## Core
- [x] Create profile
- [x] Edit profile
- [x] Delete profile
- [x] Duplicate profile
- [x] Run profile
- [x] Preview operation plan
- [x] Add resulting operations to queue
## Example: Archive Project
1. Verify Git status
2. Require clean working tree
3. Exclude `.git`, `bin`, `obj`, `.vs`
4. Compress using 7-Zip
5. Generate SHA-256
6. Copy archive to NAS
7. Verify destination
8. Recycle source only if all previous steps succeeded
## Example: Phone Videos
1. Find matching videos
2. Convert to configured format
3. Preserve timestamps
4. Copy converted files to Media
5. Archive originals
6. Queue cleanup
## Example: Clean Downloads
Classify files and propose:
- Installers -> Software
- Images -> Pictures
- Video -> Media
- Archives -> Archive
- Git repositories -> Development
- Unknown -> Leave untouched
Available as **Tools → Organize folder…** and **Organize this folder…** on a directory. Suggestions only; nothing moves until Queue.
## Profile triggers
- [x] Manual
- [x] Context menu: `Run Profile`
- [x] Drag files onto profile
- [x] When location becomes available
- [x] When removable media is connected
- [ ] Scheduled
- [ ] Folder watcher
---
# Archives
## Supported concepts
- [x] ZIP
- [x] 7z
- [x] RAR
- [x] Additional archive formats through providers
## Indexing
- [x] Scan archive contents without extraction
- [x] Add archive entries to search index
- [x] Search inside archives
- [x] Display archive path as virtual hierarchy
Example search result:
`\\NAS\Archive\2019-backup.7z\Projects\Explorer\README.md`
even when the NAS is currently offline.
## Operations
- [x] Extract
- [x] Compress
- [x] Add to archive
- [x] Verify archive
- [x] Queue archive operations
Potential provider:
`7-Zip`
---
# File Classification
Goal: Understand what files and folders represent rather than relying only on extensions.
## Initial categories
- [x] Media (Photos / Video / Audio)
- [x] Photos
- [x] Video
- [x] Audio
- [x] Documents
- [x] Code repositories
- [x] Installers
- [x] Backups (classified; left in place)
- [x] Archives
- [x] System data
- [x] Unknown
## Detection signals
- [x] File extension
- [ ] MIME/content signature
- [x] Folder structure
- [x] Git metadata
- [ ] Media metadata
- [x] Known application structures (`node_modules`, `bin`, `obj`, `.vs`)
- [x] File age (old installers flagged in preview)
- [ ] File relationships
- [ ] Index metadata
---
# Reorganization / Consolidation
Use file classification and metadata to propose better organization.
## Concepts
- [x] Analyze directory
- [x] Classify contents
- [x] Detect misplaced files
- [ ] Detect redundant files
- [x] Detect old installers
- [x] Detect old build output
- [ ] Detect archived projects
- [x] Generate reorganization proposal
- [x] Preview proposed operations
- [x] Submit approved operations to queue
Important:
**Never automatically reorganize based only on classification.**
Always prefer:
`Analyze -> Suggest -> Review -> Queue`
---
# Multi-PC
Goal: Search and manage the user's complete file environment rather than only the current PC.
## Machines
- [ ] Register Workbench machine
- [ ] Assign machine identity
- [ ] Store machine name with indexed files
- [ ] Track last-seen timestamp
- [ ] Track online/offline state
## Search
Example:
Search: `wallet.dat`
Results:
- `Desktop-PC -> D:\Backup\wallet.dat`
- `Laptop -> C:\Archive\wallet.dat`
- `NAS -> backup-2019.7z\Crypto\wallet.dat`
## Offline behavior
- [ ] Keep index searchable while machine is offline
- [ ] Show last known location
- [ ] Show last indexed timestamp
- [ ] Queue operations requiring offline machine
- [ ] Resume when machine becomes available
## Future
- [ ] Transfer files between Workbench machines
- [ ] Remote filesystem browsing
- [ ] Remote indexing
- [ ] Shared operation queue / agent model
---
# Secure Storage
## Concepts
- [ ] CryptDrive/Vault integration
- [ ] Secure Storage location category
- [ ] Detect mounted/unmounted state
- [ ] Index policy for encrypted locations
- [ ] Optional metadata-only index
- [ ] Security-sensitive search behavior
Workbench should preferably integrate existing encryption technology rather than implement cryptography itself.
---
# Conversion
Conversions should be represented as File Operations.
## Media
Potential provider:
`FFmpeg`
Possible operations:
- [ ] Video conversion
- [ ] Audio conversion
- [ ] Codec conversion
- [ ] Resolution conversion
- [ ] Extract audio
- [ ] Generate thumbnails
## Images
- [ ] HEIC -> JPEG
- [ ] PNG -> JPEG
- [ ] Resize
- [ ] Rotate
- [ ] Metadata preservation/removal
## Workflow
Conversions should support:
`Input -> Conversion Profile -> Preview -> Queue`
and be usable inside File Operation Profiles.
---
# Git Integration
Workbench should recognize repositories but not reimplement Git.
Potential provider:
`git`
## Repository detection
- [x] Detect Git repositories
- [x] Show current branch
- [x] Show modified count
- [x] Show untracked count
- [x] Show ahead/behind state
Example:
`Explorer-Workbench main 3 modified 1 untracked`
## Actions
- [ ] Status
- [ ] View Changes
- [ ] Commit
- [ ] Pull
- [ ] Push
- [ ] Fetch
- [x] Open terminal here
- [x] Open in Cursor
## Profiles
Git state may be used as a profile condition.
Example:
`Archive Project`
requires:
`Working tree clean`
before archive operations continue.
---
# Sharing
## Basic sharing
- [ ] Share file
- [ ] Share folder
- [ ] Copy share link
- [ ] Temporary shares
- [ ] Expiration
- [ ] Password protection
- [ ] Read-only permissions
## Providers
Potential integrations:
- [ ] Nextcloud
- [ ] Network shares
- [ ] Other cloud providers
## Package for Sharing
Example profile:
1. Exclude unnecessary files
2. Clean temporary data
3. Compress
4. Encrypt
5. Upload
6. Create expiring share link
7. Copy link to clipboard
8. Queue temporary archive cleanup
---
# External Tool / Provider Integration
## Robocopy
Status:
**Documentation review / knowledge transfer pending. 😏**
Potential uses:
- [ ] Restartable copies
- [ ] Network transfers
- [ ] Retry handling
- [ ] Backup-mode access
- [ ] Mirror operations
- [ ] Metadata/ACL preservation
Possible architecture:
`RobocopyTransferProvider`
Workbench retains ownership of:
- Queue
- Progress
- State
- UI
- Retry policy
- History
Robocopy performs selected low-level transfers.
## 7-Zip
`SevenZipArchiveExecutor` (`IArchiveExecutor`)
Discovery: Settings path, then Program Files, then PATH. Missing 7-Zip fails the queued job with an install hint. 7-Zip is not bundled.
## FFmpeg
Potential:
`MediaConversionProvider`
## Git
`WindowsGitStatusProvider` (`IGitStatusProvider`)
Discovery: Settings path, then Program Files, then PATH. Missing git.exe means no badge. Git is not bundled. Workbench does not commit, push, or pull.
Potential later:
`IGitCommandProvider`
---
# Future Intelligence
## File relationships
Workbench should eventually understand relationships between files.
Examples:
- Duplicate
- Sync copy
- Backup copy
- Converted version
- Archived version
- Renamed version
- Source/output relationship
This allows questions such as:
> Which duplicates are accidental?
instead of merely:
> Which files have the same hash?
## Forgotten Files
Potential analysis:
- Files not modified for many years
- Files not accessed for many years
- Old backups
- Forgotten projects
- Large unused files
- Old installers
- Historical archives
Possible feature name:
`Forgotten Files`
---
# Architectural Direction
Long-term conceptual model:
Filesystems / Providers
|
v
Scanner
|
v
Metadata Index
|
v
Intelligence Layer
|
+-----+-----+
| |
v v
Workbench Profiles
| |
+-----+-----+
|
v
File Operations Queue
|
v
Execution Providers
The Workbench should increasingly understand:
- What is this?
- Where is it?
- Which machine has it?
- Is it currently available?
- Is there another copy?
- Why does another copy exist?
- Is it part of a sync or backup?
- How much space does it consume?
- What operations are safe?
- What does the user want to do with it?
---
# Replaced / Potentially Replaced Tools
| Tool | Workbench capability |
| --- | --- |
| Windows Explorer | Navigation / file management |
| WinDirStat | Storage analysis |
| WhereIsIt? | Indexed catalog / offline search |
| AllSync | Folder synchronization |
| FileRenamer | Batch rename |
| Archive tools | Archive workflows |
| Conversion tools | Profile-driven conversion |
| Various sharing workflows | Integrated sharing |
The goal is not necessarily to completely replace every specialized tool.
The goal is to make Explorer Workbench the **central working environment** from which those capabilities are orchestrated.
---
# Definition of Done
Explorer Workbench is never truly finished.
A feature is useful when it improves the actual workflow of the primary user.
A release is successful when:
> Domi realizes he has not voluntarily opened Windows Explorer all week.
😎

343
docs/Documentation.md Normal file
View File

@@ -0,0 +1,343 @@
# Explorer Workbench
Personal file management workbench for Windows. The goal is to make **working with files** faster — not to replace the filesystem, and not to silently change Windows configuration.
Mental model:
> Open Explorer Workbench → see files → manage files → search immediately → analyze storage.
Windows remains the source of truth. Removing a location from Workbench removes Workbenchs index data for it. It does **not** disconnect a network drive, unlink OneDrive, or change Explorer settings.
Version documented here: **0.1** (schema 8). This file is the user guide. Edit it in any text editor; Explorer Workbench reloads it when you open **Help → Documentation**.
---
## What it is (and is not)
Workbench **does**:
- Browse live folders (local, removable, network, cloud mounts)
- Index locations you choose, then search and analyze them
- Queue copy, move, recycle, rename, archive, sync, and organize work
- Overlay Git and cloud status without becoming a Git client or a sync engine
Workbench **does not**:
- Two-way sync
- Commit, push, or pull
- Convert media (no FFmpeg in this build)
- Hydrate online-only cloud files just to look at them
- Change Windows drive mappings or cloud client folders
Specialized tools still do specialized jobs. 7-Zip compresses. Git reports status. Workbench orchestrates.
---
## First launch
1. This PC lists local and removable volumes Windows already knows.
2. Browsing works with an empty index.
3. Folder sizes, search, duplicates, and storage analysis need an index. Use the banner **Build index**, **Tools → Locations → Index this location**, or the toolbar **Index** control.
4. Data lives under `%LocalAppData%\ExplorerWorkbench\` — never beside the executable.
| Path | Contents |
| --- | --- |
| `%LocalAppData%\ExplorerWorkbench\index.db` | Index, queue, profiles |
| `%LocalAppData%\ExplorerWorkbench\logs\` | Rolling logs |
| `%LocalAppData%\ExplorerWorkbench\ui-preferences.txt` | Theme, layout, tool paths, organize destinations |
---
## Window layout
- **Title bar** — Explorer Workbench; minimize / maximize / close.
- **Menu** — File, View, Tools, Settings, Help. Tools is grouped: Storage, Locations, File Operations (including Archives), Automation, Development, Recycle Bin.
- **Toolbar** — path, navigation, view mode, search, storage, queue summary.
- **Tree** — This PC, Network, Cloud (grouping is optional in Settings).
- **Folder pane** — details, list, or preview. Split pane is optional.
- **Queue** — compact status; expand for the full File Operations Queue.
### Tabs and panes
| Action | How |
| --- | --- |
| New tab | File → New tab, or `Ctrl+T` |
| Close tab | File → Close tab, or `Ctrl+W` |
| Split pane | File → Split pane |
| Details / List / Preview | View menu or toolbar |
| Refresh | View → Refresh, or `F5` |
---
## Locations
### This PC
Local NTFS volumes and removable disks. Capacity and free space show next to size where Windows reports them.
### Network
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.
### 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.”
Online-only placeholders are **not** hydrated to compute size, search content, hash duplicates, or pack archives. Status text on the item tells you when a file is online-only.
### Recycle Bin
Open it from **Tools → Recycle Bin**. Workbench talks to the real Windows Recycle Bin. Emptying it is a queued operation. `$RECYCLE.BIN` folders on volumes are hidden from normal browsing.
### Remove vs restore
- **Remove from Explorer** forgets the Workbench location and its index rows.
- Windows resources stay. You can import them again from discovery.
---
## Browsing and file management
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.
**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.
| Shortcut | Action |
| --- | --- |
| `Alt+Left` / Back | Back |
| `Alt+Right` | Forward |
| `Alt+Up` | Up |
| `F5` | Refresh |
| `Enter` | Open |
| `F2` | Rename (single item, immediate) |
| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | Copy / Cut / Paste |
| `Delete` | Recycle (queued) |
| `Ctrl+T` / `Ctrl+W` | New tab / Close tab |
Drag and drop copies by default. Hold `Shift` to move. Right-drag offers a 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.
---
## Index and search
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)
Default excludes include Windows, recycle bins, `node_modules`, `.git`, and similar. Inaccessible paths are skipped.
Search uses the index: name/glob plus size, type, and date filters. Results are paged (500, hard cap 10000). Offline volumes stay searchable from the last index; the live folder listing still needs the volume.
Archive hits can look like:
`\\NAS\Archive\2019-backup.7z\Projects\README.md`
even when that NAS is currently offline — if the archive was indexed earlier.
---
## Storage analysis
**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.
---
## Duplicates
**Tools → Storage → Duplicates**. Groups are hashed in the background (size → partial hash → full hash only when needed). Workbench distinguishes:
| Class | Meaning |
| --- | --- |
| Accidental | Same content, no known reason |
| Synchronized | Created by Folder Sync |
| Intentional | You marked them |
| Hardlink | Same file id on the same volume |
| Backup | Reserved; not auto-tagged yet |
Intentional and sync copies are hidden by default. Hardlinks are not wasted space.
---
## File Operations Queue
Almost every change goes through the queue instead of happening silently.
Supported operations today: copy, move, recycle, permanent delete, rename, empty Recycle Bin, extract, compress, add to archive, verify archive.
The queue:
- Runs **one operation at a time**
- Can pause / resume / pause all
- Lets you reorder, remove, retry, and clear finished items
- Survives Workbench restarts
- Waits when the destination is unreachable (network share, USB) and continues when it returns
- Records append-only history
- Fails locked files so you can retry
Auto-clear when done is a setting. Failed items stay until you dismiss or retry them.
**Undo** exists for completed **rename batches**. Copy and move are not undone from the queue in this build.
---
## Batch rename
**Tools → File Operations → Batch rename…** (select items first). F2 stays a single immediate rename.
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**).
Metadata placeholders such as `{CreatedDate}` or `{Width}` are not implemented yet. `{Counter}` and `{Extension}` work.
---
## Archives
Needs **7-Zip** on the machine (Settings can point at `7z.exe`; otherwise Program Files and PATH). 7-Zip is not bundled.
| Action | Menu |
| --- | --- |
| Extract here / Extract to… | Tools → File Operations → Archives, or context menu |
| Compress to ZIP / 7z | Tools → File Operations → Archives, or context menu |
| Add to archive… | Tools → File Operations → Archives |
| Verify archive | Tools → File Operations → Archives |
Jobs go through the queue. Online-only cloud archives are refused so Workbench will not hydrate them. Formats include ZIP, 7z, RAR, TAR and related containers through 7-Zip.
---
## Folder sync
**Tools → Automation → Folder sync…****one-way** only.
| Mode | Behavior |
| --- | --- |
| Copy / Update | Add and overwrite from source. Never deletes. Destination-newer files are skipped with a warning. |
| Mirror | Also deletes destination items that are not in the source. Deletes stay in the preview until you Queue. Mirror AutoRun is disabled. |
Copy / Update can AutoRun when the destination volume becomes available (USB, share). Drive letters may change; Workbench stores volume identity. Excludes are one glob per line.
Files copied by sync are tagged as synchronized duplicates.
---
## Operation profiles
**Tools → Automation → Operation profiles…** — named recipes that **plan** work, then enqueue it. Steps never touch the filesystem themselves.
Toggles (not a free-form graph): Copy, Rename, Compress, require a clean Git working tree. Excludes are globs, one per line.
Built-in recipes (seeded when the list is empty):
1. **Archive folder** — require clean Git, compress 7z, exclude `.git` / `bin` / `obj` / `.vs`
2. **Copy to destination** — copy; AutoRun when the destination volume connects
Run from the window, from **Run profile** on the context menu (always previews first), or by dropping files onto a profile. AutoRun is Copy-only (no rename, no compress) and fires on unreachable → reachable, not on a timer while already online.
Dirty or missing Git, missing 7-Zip, or an unreachable destination stops the plan. Nothing is queued.
Not in this build: SHA-256, recycle source after success, FFmpeg, scheduled or folder-watcher triggers.
---
## Organize folder
**Tools → File Operations → Organize folder…** or **Organize this folder…** on a directory.
Classification **suggests** moves. Nothing moves until you Preview and Queue.
| Category | Typical destination |
| --- | --- |
| Photos / Video / Audio / Documents | Windows Pictures, Videos, Music, Documents |
| Installers | Folder you choose (for example Software) |
| Archives | Folder you choose |
| Git repositories | Folder you choose (Development) |
| Unknown, system data, backups, build output | Left in place |
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).
---
## Git
Workbench detects repositories and shows a badge (branch, modified, untracked, ahead/behind). **Tools → Development → Open terminal here** and **Open in Cursor** are available when a folder is in context.
Missing `git.exe` means no badge. Path can be set in Settings. Git is not bundled. There is no commit, push, pull, or diff viewer. Profiles can require a clean working tree.
---
## Cloud overlays
When a cloud folder is added:
- Status text on items (available / online-only / syncing)
- **Always keep on this device** / **Free up space** when the provider supports pin/dehydrate
- Quota in capacity/free space where the provider reports it
Workbench never starts a cloud vendors own two-way sync. It never hydrates a file as a side effect of browse, size, search, hash, or archive.
---
## Settings
**Settings** in the menu:
- Dark / Light theme
- Group network under Network; group cloud under Cloud (independent)
- Show hidden files
- Show protected system locations
- Auto-clear queue when done
- Include archive contents in the index
- Path to 7-Zip
- Path to git.exe
These options change what Workbench shows and indexes. They do not change Windows Explorer settings.
---
## Help
| Item | What you get |
| --- | --- |
| Documentation | This guide, shown inside Workbench |
| About | Version, data folder, log folder |
The source of this guide is `docs/Documentation.md` in the project (or `Documentation.md` next to the executable). Edit the Markdown in any editor, then reopen this window. A copy is also embedded in the app if no file is found.
Press `F1` to open Documentation.
---
## What is not in this build
Left open on purpose:
- Two-way sync and conflict resolution UI
- Undo for copy/move
- Concurrent copies across different disks
- FFmpeg / media conversion
- Multi-PC search, sharing, encrypted vaults
- Robocopy as a second transfer engine
- Scheduled profiles and folder-watcher triggers
- MIME/EXIF classification
- Duplicate “backup copy” auto-tagging
- Rename placeholders from EXIF or dates
---
## Definition of done
Explorer Workbench is never finished. A feature is useful when it improves the actual workflow of the person using it.

View File

@@ -88,6 +88,48 @@ public sealed class AnalysisService
public Task<IReadOnlyList<Source>> GetKnownSourcesAsync(CancellationToken cancellationToken = default)
=> RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken);
public Task<IReadOnlyList<ClassifiedDuplicateGroup>> GetClassifiedDuplicatesAsync(
int take = 200,
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();
}, cancellationToken);
public Task MarkDuplicateGroupAsync(
IReadOnlyList<IndexEntry> entries,
FileRelationKind kind,
CancellationToken cancellationToken = default)
=> RunOffUiAsync(async ct =>
{
var ids = entries.Select(e => e.Id).Distinct().ToList();
await _store.Relations.DeleteAmongAsync(
ids,
[FileRelationKind.AccidentalDuplicate, FileRelationKind.IntentionalDuplicate],
ct)
.ConfigureAwait(false);
var utc = DateTimeOffset.UtcNow;
foreach (var (left, right) in DuplicateClassifier.Pairs(entries))
{
await _store.Relations.UpsertAsync(new FileRelation
{
LeftEntryId = left,
RightEntryId = right,
Kind = kind,
Origin = FileRelationOrigin.User,
CreatedUtc = utc
}, ct).ConfigureAwait(false);
}
return 0;
}, cancellationToken);
private async Task<T> CachedAsync<T>(string key, Func<CancellationToken, Task<T>> query, CancellationToken cancellationToken)
{
await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);

View File

@@ -0,0 +1,32 @@
<Window x:Class="Explorer.App.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="About Explorer Workbench"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="420" Width="520"
MinHeight="360" MinWidth="440"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
ResizeMode="NoResize">
<DockPanel Margin="24">
<Button DockPanel.Dock="Bottom" Content="Close" MinWidth="88" Height="32" HorizontalAlignment="Right"
IsDefault="True" IsCancel="True" Margin="0,20,0,0"/>
<StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Image Width="48" Height="48" Margin="0,0,14,0"
RenderOptions.BitmapScalingMode="HighQuality"
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Explorer Workbench" FontSize="22" FontWeight="SemiBold"/>
<TextBlock x:Name="VersionText" FontSize="13" Foreground="{DynamicResource FgMuted}" Margin="0,4,0,0"/>
</StackPanel>
</StackPanel>
<TextBlock TextWrapping="Wrap" Margin="0,0,0,16"
Text="A personal file management workbench: live browsing, a local index for search and analysis, and a queue for copy, rename, archive, sync, and organize work. Windows stays the source of truth."/>
<TextBlock Text="Data folder" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox x:Name="DataPath" IsReadOnly="True" Margin="0,0,0,12"/>
<TextBlock Text="Logs" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox x:Name="LogPath" IsReadOnly="True"/>
</StackPanel>
</DockPanel>
</Window>

View File

@@ -0,0 +1,24 @@
using System.IO;
using System.Reflection;
using System.Windows;
using Explorer.Domain;
namespace Explorer.App;
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
var version = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion
?? "0.1";
VersionText.Text = "Version " + version;
var data = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
AppConstants.ProductFolderName);
DataPath.Text = data;
LogPath.Text = Path.Combine(data, AppConstants.LogFolderName);
}
}

View File

@@ -17,7 +17,6 @@
<local:ViewModeToVisibilityConverter x:Key="ViewList" Match="List"/>
<local:ViewModeToVisibilityConverter x:Key="ViewPreview" Match="Preview"/>
<local:ShellIconConverter x:Key="ShellIcon"/>
<local:ThumbnailConverter x:Key="Thumbnail"/>
<local:TransferActionTextConverter x:Key="TransferAction"/>
<Style x:Key="InlineRenameBox" TargetType="TextBox">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
@@ -59,25 +58,14 @@
<TextBlock Text="{Binding CloudStatus}" 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"
Foreground="{DynamicResource FgMuted}"
Visibility="{Binding HasGitLabel, Converter={StaticResource BoolVis}}"/>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="PreviewTile">
<StackPanel Width="96" Margin="2">
<Grid Width="96" Height="96">
<Image Width="96" Height="96" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Converter={StaticResource Thumbnail}}">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImage}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Image Width="64" Height="64" Stretch="Uniform"
HorizontalAlignment="Center" VerticalAlignment="Center"
RenderOptions.BitmapScalingMode="HighQuality"
@@ -86,13 +74,27 @@
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsImage}" Value="True">
<DataTrigger Binding="{Binding HasThumbnail}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Image Width="96" Height="96" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding Thumbnail}">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasThumbnail}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
</Grid>
<TextBlock Tag="ItemName" Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}">
@@ -490,6 +492,49 @@
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Role" Value="SubmenuHeader">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
<Grid>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="&#xE76C;" FontFamily="{StaticResource Symbol}"
FontSize="10" Margin="16,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource FgMuted}"/>
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" VerticalAlignment="Center"/>
</DockPanel>
<Popup x:Name="PART_Popup"
IsOpen="{Binding IsSubmenuOpen, RelativeSource={RelativeSource TemplatedParent}}"
Placement="Right"
PlacementTarget="{Binding ElementName=Bd}"
HorizontalOffset="-2"
VerticalOffset="-4"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade">
<Border MinWidth="180" Background="{DynamicResource Panel}"
BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Padding="4">
<StackPanel IsItemsHost="True"/>
</Border>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsSubmenuOpen" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="Role" Value="TopLevelHeader">
<Setter Property="Padding" Value="10,6"/>
<Setter Property="Template">

View File

@@ -8,6 +8,7 @@ using Explorer.Plugin.Abstractions;
using Explorer.Plugin.GoogleDrive;
using Explorer.Plugin.Nextcloud;
using Explorer.Plugin.OneDrive;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Explorer.Search;
using Explorer.Storage.Sqlite;
@@ -40,8 +41,11 @@ public static class AppServices
services.AddSingleton<IStorageProvider, NextcloudStorageProvider>();
services.AddSingleton<StorageProviderRegistry>();
services.AddSingleton<IHydrationGuard, HydrationGuard>();
services.AddSingleton<IArchiveExecutor, SevenZipArchiveExecutor>();
services.AddSingleton<IGitStatusProvider, WindowsGitStatusProvider>();
services.AddSingleton<IWorkspaceLauncher, WindowsWorkspaceLauncher>();
services.AddSingleton<IElevatedScanService, WindowsElevatedScanService>();
services.AddSingleton<IRecycleBinCatalog, RecycleBinCatalog>();
services.AddSingleton<IRecycleBinCatalog, WindowsRecycleBinCatalog>();
services.AddSingleton<SourceManager>();
services.AddSingleton<PathHistoryStore>();
services.AddSingleton<CloudPlaceStore>();
@@ -49,6 +53,8 @@ public static class AppServices
services.AddSingleton<IArchiveCatalog, ArchiveCatalog>();
services.AddSingleton<ArchiveContentsIndexer>();
services.AddSingleton<BrowseService>();
services.AddSingleton<ThumbnailService>();
services.AddSingleton<IThumbnailService>(sp => sp.GetRequiredService<ThumbnailService>());
services.AddSingleton<FilesystemScanner>();
services.AddSingleton<FolderReconciler>();
services.AddSingleton<UsnChangeApplier>();
@@ -56,8 +62,17 @@ public static class AppServices
services.AddSingleton<DirectoryWatcherHub>();
services.AddSingleton<SearchService>();
services.AddSingleton<AnalysisService>();
services.AddSingleton<IOperationExecutor, NativeFileOperationExecutor>();
services.AddSingleton<TransferQueue>();
services.AddSingleton<FileOperationService>();
services.AddSingleton<RenamePlanner>();
services.AddSingleton<RenameBatchService>();
services.AddSingleton<FolderSyncPlanner>();
services.AddSingleton<FolderSyncService>();
services.AddSingleton<FileOperationProfilePlanner>();
services.AddSingleton<OperationProfileService>();
services.AddSingleton<ReorganizePlanner>();
services.AddSingleton<ReorganizeService>();
services.AddSingleton<DuplicateHashWorker>();
services.AddSingleton<HistoryRollupService>();
services.AddSingleton<MainViewModel>();
@@ -66,6 +81,7 @@ public static class AppServices
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
services.AddHostedService(sp => sp.GetRequiredService<ThumbnailService>());
services.AddHostedService<WatcherHostedService>();
return services;
}
@@ -75,11 +91,22 @@ public sealed class WatcherHostedService : BackgroundService
{
private readonly DirectoryWatcherHub _hub;
private readonly SourceManager _sources;
private readonly TransferQueue _transfers;
private readonly FolderSyncService _sync;
private readonly OperationProfileService _profiles;
public WatcherHostedService(DirectoryWatcherHub hub, SourceManager sources)
public WatcherHostedService(
DirectoryWatcherHub hub,
SourceManager sources,
TransferQueue transfers,
FolderSyncService sync,
OperationProfileService profiles)
{
_hub = hub;
_sources = sources;
_transfers = transfers;
_sync = sync;
_profiles = profiles;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -89,6 +116,9 @@ public sealed class WatcherHostedService : BackgroundService
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
await _sources.RefreshOnlineStateAsync(stoppingToken).ConfigureAwait(false);
_transfers.NotifyAvailability();
await _sync.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _profiles.TryAutoRunAsync(stoppingToken).ConfigureAwait(false);
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,86 @@
<Window x:Class="Explorer.App.BatchRenameWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Batch rename"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="640" Width="820"
MinHeight="480" MinWidth="640"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}">
<DockPanel Margin="16">
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0">
<Button DockPanel.Dock="Right" Content="Cancel" MinWidth="88" Height="32" IsCancel="True" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Queue" MinWidth="88" Height="32" IsDefault="True"
Command="{Binding QueueCommand}" IsEnabled="{Binding CanQueue}"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="280"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Search / replace" FontWeight="SemiBold" Margin="0,0,0,8"/>
<TextBlock Text="Search" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Search, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Replace" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Replace, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<CheckBox Content="Regular expression" IsChecked="{Binding UseRegex}" Margin="0,0,0,6"/>
<CheckBox Content="Match case" IsChecked="{Binding MatchCase}" Margin="0,0,0,6"/>
<CheckBox Content="Include extension in search" IsChecked="{Binding IncludeExtensionInSearch}" Margin="0,0,0,16"/>
<TextBlock Text="Prefix / suffix" FontWeight="SemiBold" Margin="0,0,0,8"/>
<TextBlock Text="Prefix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Prefix, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Suffix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Suffix, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,16"/>
<TextBlock Text="Counter" FontWeight="SemiBold" Margin="0,0,0,8"/>
<CheckBox Content="Add counter" IsChecked="{Binding UseCounter}" Margin="0,0,0,8"/>
<Grid Margin="0,0,0,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Start" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterStart, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Step" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterStep, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Digits" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding CounterPadding, UpdateSourceTrigger=PropertyChanged}"/>
</StackPanel>
</Grid>
<TextBlock Text="Case" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding CaseOptions}" DisplayMemberPath="Label" SelectedValuePath="Mode"
SelectedValue="{Binding CaseMode}" Margin="0,0,0,16"/>
<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}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Current" Width="220" DisplayMemberBinding="{Binding OldName}"/>
<GridViewColumn Header="New name" Width="220" DisplayMemberBinding="{Binding NewName}"/>
<GridViewColumn Header="Status" Width="220" DisplayMemberBinding="{Binding Status}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

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

View File

@@ -1,11 +1,7 @@
using System.Globalization;
using System.IO;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Explorer.Domain;
using Explorer.Presentation;
namespace Explorer.App;
@@ -53,22 +49,6 @@ public sealed class ViewModeToVisibilityConverter : IValueConverter
=> throw new NotSupportedException();
}
public sealed class ThumbnailConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is FolderItemViewModel { IsImage: true, MayHydrateOnRead: false, FullPath: var path })
{
return ThumbnailCache.Load(path, decodeWidth: 240);
}
return null;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
public sealed class TransferActionTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
@@ -77,31 +57,3 @@ public sealed class TransferActionTextConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
internal static class ThumbnailCache
{
public static ImageSource? Load(string path, int decodeWidth)
{
try
{
if (!File.Exists(path))
{
return null;
}
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(path);
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.CreateOptions = BitmapCreateOptions.IgnoreImageCache | BitmapCreateOptions.IgnoreColorProfile;
bmp.DecodePixelWidth = decodeWidth;
bmp.EndInit();
bmp.Freeze();
return bmp;
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,55 @@
using System.IO;
using System.Reflection;
namespace Explorer.App;
public static class DocumentationLoader
{
public const string EmbeddedName = "Explorer.Documentation.md";
public const string FileName = "Documentation.md";
public static LoadedDocumentation Load()
{
foreach (var path in CandidatePaths())
{
try
{
if (File.Exists(path))
{
return new LoadedDocumentation(File.ReadAllText(path), path);
}
}
catch (IOException)
{
// try the next candidate
}
}
var assembly = typeof(DocumentationLoader).Assembly;
using var stream = assembly.GetManifestResourceStream(EmbeddedName);
if (stream is null)
{
return new LoadedDocumentation(
"# Documentation\n\nThe user guide file was not found. Add `docs/Documentation.md` to the project.",
null);
}
using var reader = new StreamReader(stream);
return new LoadedDocumentation(reader.ReadToEnd(), null);
}
public static IEnumerable<string> CandidatePaths()
{
var baseDir = AppContext.BaseDirectory;
yield return Path.Combine(baseDir, FileName);
yield return Path.Combine(baseDir, "docs", FileName);
var dir = new DirectoryInfo(baseDir);
for (var n = 0; n < 8 && dir is not null; n++, dir = dir.Parent)
{
yield return Path.Combine(dir.FullName, "docs", FileName);
yield return Path.Combine(dir.FullName, FileName);
}
}
}
public sealed record LoadedDocumentation(string Markdown, string? SourcePath);

View File

@@ -0,0 +1,38 @@
<Window x:Class="Explorer.App.DocumentationWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Documentation"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="760" Width="1020"
MinHeight="480" MinWidth="720"
WindowStartupLocation="CenterOwner"
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="Open file" MinWidth="88" Height="32" Click="OnOpenFile"
x:Name="OpenFileButton" 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}"/>
</DockPanel>
<Grid Margin="16,16,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="240"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="Contents" FontWeight="SemiBold" Margin="0,0,0,8"/>
<ListBox x:Name="TocList" SelectionChanged="OnTocSelected"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}"/>
</DockPanel>
<FlowDocumentScrollViewer x:Name="Viewer" Grid.Column="2"
IsToolBarVisible="False"
Zoom="100"
Background="{DynamicResource Bg}"
VerticalScrollBarVisibility="Auto"/>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,108 @@
using System.Diagnostics;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using Explorer.Application;
namespace Explorer.App;
public partial class DocumentationWindow : Window
{
private FlowDocument? _document;
private bool _suppressToc;
public DocumentationWindow()
{
InitializeComponent();
Loaded += (_, _) => Render();
}
private void OnReload(object sender, RoutedEventArgs e) => Render();
private void OnOpenFile(object sender, RoutedEventArgs e)
{
var path = OpenFileButton.Tag as string;
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
{
return;
}
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
private void OnTocSelected(object sender, SelectionChangedEventArgs e)
{
if (_suppressToc || TocList.SelectedItem is not TocItem item || _document is null)
{
return;
}
if (FindHeading(_document.Blocks, item.Id) is { } paragraph)
{
paragraph.BringIntoView();
}
}
private void Render()
{
var loaded = DocumentationLoader.Load();
var parsed = MarkdownParser.Parse(loaded.Markdown);
var brushes = new DocumentationBrushes(
Brush("Fg"),
Brush("FgMuted"),
Brush("Accent"),
Brush("Panel"),
Brush("Stroke"),
Brush("InputBg"));
_document = MarkdownFlowConverter.ToFlowDocument(parsed, brushes);
Viewer.Document = _document;
_suppressToc = true;
TocList.Items.Clear();
foreach (var heading in parsed.Headings)
{
TocList.Items.Add(new TocItem(heading.Text, heading.Id, heading.Level));
}
_suppressToc = false;
var fromFile = !string.IsNullOrWhiteSpace(loaded.SourcePath);
OpenFileButton.IsEnabled = fromFile;
OpenFileButton.Tag = loaded.SourcePath;
SourceLabel.Text = fromFile
? "Editing: " + loaded.SourcePath
: "Built-in copy. Place Documentation.md next to the executable to override it.";
}
private Brush Brush(string key)
=> TryFindResource(key) as Brush ?? Brushes.White;
private static Paragraph? FindHeading(BlockCollection blocks, string id)
{
foreach (var block in blocks)
{
if (block is Paragraph paragraph && paragraph.Tag as string == id)
{
return paragraph;
}
if (block is List list)
{
foreach (var item in list.ListItems)
{
var nested = FindHeading(item.Blocks, id);
if (nested is not null)
{
return nested;
}
}
}
}
return null;
}
private sealed record TocItem(string Title, string Id, int Level)
{
public override string ToString() => Level >= 3 ? " " + Title : Title;
}
}

View File

@@ -14,6 +14,8 @@
<ItemGroup>
<Resource Include="..\..\explorer-workbench-icons\explorer-workbench.ico" Link="Assets\explorer-workbench.ico" />
<Resource Include="..\..\explorer-workbench-icons\explorer-workbench-20.png" Link="Assets\explorer-workbench-20.png" />
<EmbeddedResource Include="..\..\docs\Documentation.md" LogicalName="Explorer.Documentation.md" />
<Content Include="..\..\docs\Documentation.md" Link="Documentation.md" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />

View File

@@ -0,0 +1,77 @@
<Window x:Class="Explorer.App.FolderSyncWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Folder sync"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="680" Width="920"
MinHeight="520" MinWidth="720"
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="Close" 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}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="New" MinWidth="64" Height="28" Command="{Binding NewProfileCommand}" Margin="0,0,8,0"/>
<Button Content="Delete" MinWidth="64" Height="28" Command="{Binding DeleteCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding Profiles}" SelectedItem="{Binding Selected}"
DisplayMemberPath="Name"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Source" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Mode" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<ComboBox ItemsSource="{Binding Modes}" DisplayMemberPath="Label" SelectedValuePath="Mode"
SelectedValue="{Binding Mode}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Copy / Update adds and overwrites from source. Mirror also deletes destination files that are not in the source — deletions stay in the preview until you Queue."/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Copy / Update only. Drive letters can change; the volume identity is stored. Mirror always needs Preview because it can delete."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>
<Button Content="Save profile" Margin="0,12,0,0" Height="28" Command="{Binding SaveCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="4" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Path" Width="240" DisplayMemberBinding="{Binding RelativePath}"/>
<GridViewColumn Header="Action" Width="80" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Detail" Width="180" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,39 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class FolderSyncWindow : Window
{
public FolderSyncWindow(FolderSyncViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e)
{
if (PickFolder("Source folder") is { } path && DataContext is FolderSyncViewModel vm)
{
vm.SourcePath = path;
}
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
if (PickFolder("Destination folder") is { } path && DataContext is FolderSyncViewModel vm)
{
vm.DestPath = path;
}
}
private string? PickFolder(string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
return picker.ShowDialog(this) == true ? picker.FolderName : null;
}
}

View File

@@ -0,0 +1,155 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public static class FolderViewport
{
public static readonly DependencyProperty IsTrackedProperty =
DependencyProperty.RegisterAttached(
"IsTracked",
typeof(bool),
typeof(FolderViewport),
new PropertyMetadata(false, OnTrackedChanged));
public static void SetIsTracked(ListView element, bool value) => element.SetValue(IsTrackedProperty, value);
public static bool GetIsTracked(ListView element) => (bool)element.GetValue(IsTrackedProperty);
private static void OnTrackedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ListView list)
{
return;
}
if (e.NewValue is true)
{
list.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnScrollChanged), true);
list.Loaded += OnLoaded;
}
else
{
list.RemoveHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler(OnScrollChanged));
list.Loaded -= OnLoaded;
}
}
private static void OnLoaded(object sender, RoutedEventArgs e) => Report(sender as ListView);
private static void OnScrollChanged(object sender, ScrollChangedEventArgs e)
{
var list = sender as ListView ?? FindAncestor<ListView>(e.OriginalSource as DependencyObject);
Report(list);
}
private static void Report(ListView? list)
{
if (list?.DataContext is not ExplorerPaneViewModel pane || list.Visibility != Visibility.Visible)
{
return;
}
var count = list.Items.Count;
if (count == 0)
{
pane.NotifyViewport([]);
return;
}
int first;
int lastExclusive;
if (FindWrapPanel(list) is { } wrap)
{
first = wrap.FirstVisibleIndex;
lastExclusive = Math.Max(first, wrap.LastVisibleIndex);
}
else
{
var viewer = FindScrollViewer(list);
int visible;
if (viewer is null || viewer.ExtentHeight <= 0)
{
first = 0;
visible = Math.Min(80, count);
}
else
{
first = (int)Math.Clamp(Math.Floor(viewer.VerticalOffset / viewer.ExtentHeight * count), 0, count - 1);
visible = Math.Max(8, (int)Math.Ceiling(viewer.ViewportHeight / viewer.ExtentHeight * count) + 8);
}
lastExclusive = Math.Min(count, first + visible);
}
lastExclusive = Math.Clamp(lastExclusive, 0, count);
first = Math.Clamp(first, 0, count);
var paths = new string[Math.Max(0, lastExclusive - first)];
for (var i = first; i < lastExclusive; i++)
{
if (list.Items[i] is FolderItemViewModel item)
{
paths[i - first] = item.FullPath;
}
}
pane.NotifyViewport(paths);
}
private static ScrollViewer? FindScrollViewer(DependencyObject root)
{
if (root is ScrollViewer viewer)
{
return viewer;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var found = FindScrollViewer(VisualTreeHelper.GetChild(root, i));
if (found is not null)
{
return found;
}
}
return null;
}
private static VirtualizingWrapPanel? FindWrapPanel(DependencyObject root)
{
if (root is VirtualizingWrapPanel wrap)
{
return wrap;
}
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(root); i++)
{
var found = FindWrapPanel(VisualTreeHelper.GetChild(root, i));
if (found is not null)
{
return found;
}
}
return null;
}
private static T? FindAncestor<T>(DependencyObject? current)
where T : DependencyObject
{
while (current is not null)
{
if (current is T match)
{
return match;
}
current = VisualTreeHelper.GetParent(current);
}
return null;
}
}

View File

@@ -56,16 +56,58 @@
<MenuItem Header="_Refresh" InputGestureText="F5" Command="{Binding RefreshCommand}"/>
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
<MenuItem Header="_Storage" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
<Separator/>
<MenuItem Header="Add _network…" Click="OnAddNetwork"/>
<MenuItem Header="Add One_Drive…" Click="OnAddOneDrive"/>
<MenuItem Header="Add _Google Drive…" Click="OnAddGoogleDrive"/>
<MenuItem Header="Add Ne_xtcloud…" Click="OnAddNextcloud"/>
<MenuItem Header="_Storage">
<MenuItem Header="Storage _analysis" Command="{Binding Analysis.OpenCommand}"/>
<MenuItem Header="_Duplicates" Command="{Binding Duplicates.OpenCommand}"/>
</MenuItem>
<MenuItem Header="_Locations">
<MenuItem Header="_Index this location" Command="{Binding BuildIndexCommand}"/>
<MenuItem Header="Add _network…" Click="OnAddNetwork"/>
<MenuItem Header="Add _OneDrive…" Click="OnAddOneDrive"/>
<MenuItem Header="Add _Google Drive…" Click="OnAddGoogleDrive"/>
<MenuItem Header="Add Ne_xtcloud…" Click="OnAddNextcloud"/>
</MenuItem>
<MenuItem Header="_File Operations">
<MenuItem Header="_Batch rename…" Click="OnBatchRename"/>
<MenuItem Header="_Undo last rename batch" Command="{Binding UndoRenameBatchCommand}"
IsEnabled="{Binding CanUndoRenameBatch}"/>
<Separator/>
<MenuItem Header="_Archives">
<MenuItem Header="_Extract here" Click="OnExtractHere"
IsEnabled="{Binding ShowExtractArchive}"/>
<MenuItem Header="Extract _to…" Click="OnExtractTo"
IsEnabled="{Binding ShowExtractArchive}"/>
<MenuItem Header="Compress to _ZIP" Click="OnCompressZip"
IsEnabled="{Binding ShowCompress}"/>
<MenuItem Header="Compress to 7_z" Click="OnCompressSevenZip"
IsEnabled="{Binding ShowCompress}"/>
<MenuItem Header="_Add to archive…" Click="OnAddToArchive"
IsEnabled="{Binding ShowAddToArchive}"/>
<MenuItem Header="_Verify archive" Click="OnVerifyArchive"
IsEnabled="{Binding ShowVerifyArchive}"/>
</MenuItem>
<MenuItem Header="_Organize folder…" Click="OnOrganizeFolder"/>
</MenuItem>
<MenuItem Header="_Automation">
<MenuItem Header="_Folder sync…" Click="OnFolderSync"/>
<MenuItem Header="_Operation profiles…" Click="OnOperationProfiles"/>
</MenuItem>
<MenuItem Header="_Development">
<MenuItem Header="Open _terminal here" Command="{Binding OpenTerminalCommand}"
IsEnabled="{Binding ShowOpenTerminal}"/>
<MenuItem Header="Open in _Cursor" Command="{Binding OpenInCursorCommand}"
IsEnabled="{Binding ShowOpenInCursor}"/>
</MenuItem>
<MenuItem Header="_Recycle Bin">
<MenuItem Header="_Open Recycle Bin" Click="OnOpenRecycleBin"/>
<MenuItem Header="_Empty Recycle Bin" Click="OnEmptyRecycleBin"/>
</MenuItem>
</MenuItem>
<MenuItem Header="_Settings" Click="OnOpenSettings"/>
<MenuItem Header="_Help">
<MenuItem Header="_Documentation" InputGestureText="F1" Click="OnDocumentation"/>
<MenuItem Header="_About Explorer Workbench" Click="OnAbout"/>
</MenuItem>
</Menu>
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
<Grid>
@@ -146,6 +188,9 @@
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Margin="0,0,12,0"/>
<StackPanel Grid.Column="1" 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}}"/>
<StackPanel Orientation="Horizontal" Margin="0,0,12,0"
Visibility="{Binding Transfers.HasJobs, Converter={StaticResource BoolVis}}">
<ProgressBar Width="88" Height="6" Minimum="0" Maximum="1" VerticalAlignment="Center"
@@ -204,7 +249,7 @@
<TextBlock FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center" Text="File operations queue"/>
</DockPanel>
<TextBlock DockPanel.Dock="Top" FontSize="11" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8"
Text="Copy, move, and delete run one at a time. Pause a queued step to skip it, or reorder with the arrows."
Text="Copy, move, delete, and queued rename run one at a time. Jobs wait if the destination is offline, and failed steps can be retried. Pause a queued step to skip it, or reorder with the arrows."
TextWrapping="Wrap"/>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Transfers.Jobs}">
@@ -261,6 +306,10 @@
Command="{Binding DataContext.Transfers.ResumeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanResume, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Content="Retry"
Command="{Binding DataContext.Transfers.RetryCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanRetry, Converter={StaticResource BoolVis}}"/>
<Button Style="{StaticResource QueueActionButton}" Margin="0"
Content="{Binding Status, Converter={StaticResource TransferAction}}"
Command="{Binding DataContext.Transfers.RemoveCommand, RelativeSource={RelativeSource AncestorType=Window}}"
@@ -367,6 +416,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
@@ -386,15 +436,44 @@
<MenuItem Header="Paste" Command="{Binding PasteCommand}"/>
<MenuItem Header="Delete" Click="OnCtxDelete" InputGestureText="Del"/>
<MenuItem Header="Rename" Click="OnCtxRename"/>
<MenuItem Header="Batch rename…" Click="OnBatchRename"
Visibility="{Binding ShowBatchRename, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Run profile" Tag="RunProfileMenu"
IsEnabled="{Binding ShowRunProfile}">
<MenuItem Header="No profiles yet" IsEnabled="False"/>
</MenuItem>
<MenuItem Header="Organize this folder…" Click="OnOrganizeFolder"
Visibility="{Binding ShowOrganizeFolder, Converter={StaticResource BoolVis}}"/>
<Separator Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract here" Click="OnExtractHere"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Extract to…" Click="OnExtractTo"
Visibility="{Binding ShowExtractArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Verify archive" Click="OnVerifyArchive"
Visibility="{Binding ShowVerifyArchive, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to ZIP" Click="OnCompressZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Compress to 7z" Click="OnCompressSevenZip"
Visibility="{Binding ShowCompress, Converter={StaticResource BoolVis}}"/>
<MenuItem Header="Add to archive…" Click="OnAddToArchive"
Visibility="{Binding ShowAddToArchive, Converter={StaticResource BoolVis}}"/>
<Separator/>
<MenuItem Header="New folder" Click="OnCtxNewFolder"/>
<MenuItem Header="Copy path" Click="OnCtxCopyPath"/>
<MenuItem Header="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}"
@@ -423,6 +502,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
@@ -445,12 +525,17 @@
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}"
local:FolderViewport.IsTracked="True">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
<local:VirtualizingWrapPanel ItemWidth="120" ItemHeight="156"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
@@ -499,6 +584,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
<ListView.View>
<GridView>
@@ -528,6 +614,7 @@
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
local:ListViewLayout.StretchFirstColumn="True"
local:FolderViewport.IsTracked="True"
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
<ListView.View>
<GridView>
@@ -550,12 +637,17 @@
Drop="OnListDrop"
DragOver="OnListDragOver"
GotFocus="OnPaneFocus"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.ScrollUnit="Pixel"
ScrollViewer.CanContentScroll="True"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
HorizontalContentAlignment="Left"
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}"
local:FolderViewport.IsTracked="True">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
<local:VirtualizingWrapPanel ItemWidth="120" ItemHeight="156"/>
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
@@ -819,25 +911,65 @@
</Border>
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Duplicates.IsOpen, Converter={StaticResource BoolVis}}">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="80" Padding="16">
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="48" Padding="16">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseDuplicates"/>
<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"/>
<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">
<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"
Visibility="{Binding Duplicates.IsBusy, Converter={StaticResource BoolVis}}"/>
<ListBox ItemsSource="{Binding Duplicates.Groups}" Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
BorderBrush="{DynamicResource Stroke}">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Padding" Value="8,6"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Duplicates.Groups}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="0,10">
<DockPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,6">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Content="Mark as intentional" Margin="0,0,6,0"
Command="{Binding DataContext.Duplicates.MarkIntentionalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkIntentional, Converter={StaticResource BoolVis}}"/>
<Button Content="Mark as accidental"
Command="{Binding DataContext.Duplicates.MarkAccidentalCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Visibility="{Binding CanMarkAccidental, Converter={StaticResource BoolVis}}"/>
</StackPanel>
<TextBlock Foreground="{DynamicResource Fg}" FontWeight="SemiBold">
<Run Text="{Binding ClassLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding SizeLabel, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding Summary, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding WastedLabel, Mode=OneWay}"/>
</TextBlock>
</DockPanel>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2">
<Button DockPanel.Dock="Right" Content="Show in Explorer" Margin="8,0,0,0"
Command="{Binding DataContext.Duplicates.RevealCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
<TextBlock Text="{Binding LocationLabel}" Foreground="{DynamicResource FgMuted}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
</Border>
</Border>

View File

@@ -15,6 +15,7 @@ namespace Explorer.App;
public partial class MainWindow : Window
{
private DocumentationWindow? _docs;
private Point _dragStart;
private bool _dragPending;
private MouseButton _dragButton;
@@ -46,6 +47,7 @@ public partial class MainWindow : Window
_wiredVm.InlineRenameRequested += OnInlineRenameRequested;
_wiredVm.PropertyChanged += OnViewModelPropertyChanged;
RestoreLayout(_wiredVm);
_ = _wiredVm.RefreshUndoRenameAsync();
}
};
Closing += (_, _) => PersistLayout();
@@ -274,6 +276,37 @@ public partial class MainWindow : Window
await Vm.ForgetSourceAsync(path).ConfigureAwait(true);
}
private async void OnOpenRecycleBin(object sender, RoutedEventArgs e)
=> await Vm.ActivePane.NavigateAsync(LocationRoots.RecycleBin).ConfigureAwait(true);
private async void OnEmptyRecycleBin(object sender, RoutedEventArgs e)
{
if (MessageBox.Show(
this,
"Empty Recycle Bin?\n\nItems will be permanently deleted. This is queued and can be cancelled until it starts.",
"Empty Recycle Bin",
MessageBoxButton.YesNo,
MessageBoxImage.Warning) != MessageBoxResult.Yes)
{
return;
}
await Vm.EmptyRecycleBinAsync().ConfigureAwait(true);
}
private async void OnImportWindowsLocation(object sender, RoutedEventArgs e)
{
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is null)
{
return;
}
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
await Vm.Tree.ReloadAsync(item.FullPath).ConfigureAwait(true);
Vm.Footer = "Added Windows location. Indexing is optional.";
}
private static IEnumerable<NavNodeViewModel> FlattenTree(NavNodeViewModel node)
{
yield return node;
@@ -584,6 +617,7 @@ public partial class MainWindow : Window
if (sender is FrameworkElement { ContextMenu: { } menu })
{
menu.DataContext = DataContext;
_ = FillRunProfileMenuAsync(menu);
}
}
@@ -1045,7 +1079,6 @@ public partial class MainWindow : Window
private void OnCloseSearch(object sender, RoutedEventArgs e) => Vm.Search.IsOpen = false;
private void OnCloseAnalysis(object sender, RoutedEventArgs e) => Vm.Analysis.Close();
private void OnCloseDuplicates(object sender, RoutedEventArgs e) => Vm.Duplicates.IsOpen = false;
private void OnBuildIndex(object sender, RoutedEventArgs e) => Vm.BuildIndexCommand.Execute(null);
@@ -1084,6 +1117,12 @@ public partial class MainWindow : Window
private void OnCtxRename(object sender, RoutedEventArgs e)
{
if (Vm.ActivePane.SelectedItems.Count >= 2)
{
OnBatchRename(sender, e);
return;
}
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
if (item is not null)
{
@@ -1091,6 +1130,143 @@ public partial class MainWindow : Window
}
}
private async void OnBatchRename(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateBatchRenameViewModel();
if (vm is null)
{
return;
}
var dlg = new BatchRenameWindow(vm) { Owner = this };
if (dlg.ShowDialog() == true)
{
Vm.Footer = "Rename queued.";
await Vm.RefreshUndoRenameAsync().ConfigureAwait(true);
}
}
private async void OnExtractHere(object sender, RoutedEventArgs e)
=> await Vm.ExtractSelectedAsync(null).ConfigureAwait(true);
private async void OnExtractTo(object sender, RoutedEventArgs e)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = "Extract to",
Multiselect = false
};
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
{
return;
}
await Vm.ExtractSelectedAsync(picker.FolderName).ConfigureAwait(true);
}
private async void OnCompressZip(object sender, RoutedEventArgs e)
=> await Vm.CompressSelectedAsync(ArchiveFormat.Zip).ConfigureAwait(true);
private async void OnCompressSevenZip(object sender, RoutedEventArgs e)
=> await Vm.CompressSelectedAsync(ArchiveFormat.SevenZip).ConfigureAwait(true);
private async void OnAddToArchive(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Add to archive",
Filter = "Archives|*.zip;*.7z;*.zipx;*.cbz;*.cb7|All files|*.*",
CheckFileExists = true
};
if (dlg.ShowDialog(this) != true || string.IsNullOrWhiteSpace(dlg.FileName))
{
return;
}
await Vm.AddSelectedToArchiveAsync(dlg.FileName).ConfigureAwait(true);
}
private async void OnVerifyArchive(object sender, RoutedEventArgs e)
=> await Vm.VerifySelectedAsync().ConfigureAwait(true);
private async void OnFolderSync(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateFolderSyncViewModel();
await vm.LoadAsync().ConfigureAwait(true);
var dlg = new FolderSyncWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private async void OnOperationProfiles(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateOperationProfilesViewModel();
await vm.LoadAsync().ConfigureAwait(true);
var dlg = new OperationProfilesWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private void OnOrganizeFolder(object sender, RoutedEventArgs e)
{
var vm = Vm.CreateReorganizeViewModel();
var dlg = new ReorganizeWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private async Task FillRunProfileMenuAsync(ContextMenu menu)
{
var host = menu.Items.OfType<MenuItem>().FirstOrDefault(i => Equals(i.Tag, "RunProfileMenu"));
if (host is null)
{
return;
}
host.Items.Clear();
IReadOnlyList<OperationProfile> profiles;
try
{
profiles = await Vm.ListOperationProfilesAsync().ConfigureAwait(true);
}
catch (Exception)
{
host.Items.Add(new MenuItem { Header = "Could not load profiles", IsEnabled = false });
return;
}
if (profiles.Count == 0)
{
host.Items.Add(new MenuItem { Header = "No profiles yet", IsEnabled = false });
return;
}
foreach (var profile in profiles)
{
var item = new MenuItem { Header = profile.Name, Tag = profile.Id };
item.Click += OnRunProfile;
host.Items.Add(item);
}
}
private async void OnRunProfile(object sender, RoutedEventArgs e)
{
if (sender is not MenuItem { Tag: long id })
{
return;
}
var sources = Vm.SelectedRealPaths();
if (sources.Count == 0)
{
Vm.Footer = "Select files or folders to run a profile.";
return;
}
var vm = Vm.CreateOperationProfilesViewModel();
await vm.LoadAsync().ConfigureAwait(true);
await vm.RunOnAsync(id, sources).ConfigureAwait(true);
var dlg = new OperationProfilesWindow(vm) { Owner = this };
dlg.ShowDialog();
}
private void BeginInlineRenameForPath(string path)
{
var item = Vm.ActivePane.Items.FirstOrDefault(i => NavigationTreeViewModel.PathsEqual(i.FullPath, path));
@@ -1299,6 +1475,24 @@ public partial class MainWindow : Window
await Vm.RefreshForgetActionAsync().ConfigureAwait(true);
}
private void OnDocumentation(object sender, RoutedEventArgs e) => ShowDocumentation();
private void OnAbout(object sender, RoutedEventArgs e)
=> new AboutWindow { Owner = this }.ShowDialog();
private void ShowDocumentation()
{
if (_docs is { IsVisible: true })
{
_docs.Activate();
return;
}
_docs = new DocumentationWindow { Owner = this };
_docs.Closed += (_, _) => _docs = null;
_docs.Show();
}
private void OnAddNetwork(object sender, RoutedEventArgs e)
{
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
@@ -1350,6 +1544,13 @@ public partial class MainWindow : Window
return;
}
if (e.Key == Key.F1)
{
ShowDocumentation();
e.Handled = true;
return;
}
if (e.Key == Key.F5)
{
await Vm.RefreshAsync().ConfigureAwait(true);

View File

@@ -0,0 +1,290 @@
using System.Windows;
using System.Windows.Documents;
using System.Windows.Media;
using Explorer.Application;
namespace Explorer.App;
public sealed record DocumentationBrushes(
Brush Foreground,
Brush Muted,
Brush Accent,
Brush Panel,
Brush Stroke,
Brush CodeBackground);
public static class MarkdownFlowConverter
{
public static FlowDocument ToFlowDocument(MarkdownDocument markdown, DocumentationBrushes brushes)
{
var doc = new FlowDocument
{
FontFamily = new FontFamily("Segoe UI"),
FontSize = 14,
Foreground = brushes.Foreground,
PagePadding = new Thickness(20),
TextAlignment = TextAlignment.Left
};
foreach (var block in markdown.Blocks)
{
switch (block)
{
case MarkdownHeading heading:
doc.Blocks.Add(Heading(heading, brushes));
break;
case MarkdownParagraph paragraph:
doc.Blocks.Add(Paragraph(paragraph.Text, brushes, 0, 12));
break;
case MarkdownQuote quote:
var q = Paragraph(quote.Text, brushes, 16, 12);
q.Foreground = brushes.Muted;
q.FontStyle = FontStyles.Italic;
q.BorderBrush = brushes.Accent;
q.BorderThickness = new Thickness(3, 0, 0, 0);
q.Padding = new Thickness(12, 0, 0, 0);
doc.Blocks.Add(q);
break;
case MarkdownList list:
doc.Blocks.Add(ListBlock(list, brushes));
break;
case MarkdownCode code:
doc.Blocks.Add(CodeBlock(code, brushes));
break;
case MarkdownTable table:
doc.Blocks.Add(TableBlock(table, brushes));
break;
case MarkdownRule:
doc.Blocks.Add(new BlockUIContainer(new System.Windows.Controls.Border
{
Height = 1,
Background = brushes.Stroke,
Margin = new Thickness(0, 12, 0, 16)
}));
break;
}
}
return doc;
}
private static Paragraph Heading(MarkdownHeading heading, DocumentationBrushes brushes)
{
var size = heading.Level switch
{
1 => 26d,
2 => 20d,
3 => 16d,
_ => 14d
};
var p = Paragraph(heading.Text, brushes, 0, heading.Level == 1 ? 16 : 10);
p.FontSize = size;
p.FontWeight = FontWeights.SemiBold;
p.Foreground = heading.Level <= 2 ? brushes.Accent : brushes.Foreground;
p.Margin = new Thickness(0, heading.Level == 1 ? 0 : 18, 0, 8);
p.Tag = heading.Id;
return p;
}
private static List ListBlock(MarkdownList list, DocumentationBrushes brushes)
{
var result = new List
{
MarkerStyle = list.Ordered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc,
Margin = new Thickness(8, 0, 0, 12),
Padding = new Thickness(16, 0, 0, 0)
};
foreach (var item in list.Items)
{
var p = Paragraph(item, brushes, 0, 4);
result.ListItems.Add(new ListItem(p));
}
return result;
}
private static Paragraph CodeBlock(MarkdownCode code, DocumentationBrushes brushes)
{
var p = new Paragraph
{
FontFamily = new FontFamily("Consolas"),
FontSize = 13,
Background = brushes.CodeBackground,
Padding = new Thickness(12, 8, 12, 8),
Margin = new Thickness(0, 4, 0, 14),
Foreground = brushes.Foreground
};
p.Inlines.Add(new Run(code.Text));
return p;
}
private static Table TableBlock(MarkdownTable table, DocumentationBrushes brushes)
{
var columns = table.Headers.Count;
foreach (var row in table.Rows)
{
columns = Math.Max(columns, row.Count);
}
var result = new Table
{
CellSpacing = 0,
BorderBrush = brushes.Stroke,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 4, 0, 16)
};
for (var c = 0; c < columns; c++)
{
result.Columns.Add(new TableColumn());
}
var group = new TableRowGroup();
group.Rows.Add(Cells(table.Headers, columns, brushes, header: true));
foreach (var row in table.Rows)
{
group.Rows.Add(Cells(row, columns, brushes, header: false));
}
result.RowGroups.Add(group);
return result;
}
private static TableRow Cells(IReadOnlyList<string> values, int columns, DocumentationBrushes brushes, bool header)
{
var row = new TableRow { Background = header ? brushes.Panel : Brushes.Transparent };
for (var i = 0; i < columns; i++)
{
var text = i < values.Count ? values[i] : "";
var p = Paragraph(text, brushes, 0, 0);
p.FontWeight = header ? FontWeights.SemiBold : FontWeights.Normal;
p.Margin = new Thickness(0);
row.Cells.Add(new TableCell(p)
{
Padding = new Thickness(8, 6, 8, 6),
BorderBrush = brushes.Stroke,
BorderThickness = new Thickness(0, 0, 1, 1)
});
}
return row;
}
private static Paragraph Paragraph(string text, DocumentationBrushes brushes, double indent, double bottom)
{
var p = new Paragraph
{
Margin = new Thickness(indent, 0, 0, bottom),
LineHeight = 22
};
AddInlines(p.Inlines, text, brushes);
return p;
}
private static void AddInlines(InlineCollection inlines, string text, DocumentationBrushes brushes)
{
var i = 0;
while (i < text.Length)
{
var tick = text.IndexOf('`', i);
var bold = text.IndexOf("**", i, StringComparison.Ordinal);
var link = text.IndexOf('[', i);
var next = MinPositive(tick, bold, link);
if (next < 0)
{
inlines.Add(new Run(text[i..]));
return;
}
if (next > i)
{
inlines.Add(new Run(text[i..next]));
}
if (next == tick)
{
var end = text.IndexOf('`', tick + 1);
if (end < 0)
{
inlines.Add(new Run(text[tick..]));
return;
}
inlines.Add(new Run(text[(tick + 1)..end])
{
FontFamily = new FontFamily("Consolas"),
Background = brushes.CodeBackground
});
i = end + 1;
continue;
}
if (next == bold)
{
var end = text.IndexOf("**", bold + 2, StringComparison.Ordinal);
if (end < 0)
{
inlines.Add(new Run(text[bold..]));
return;
}
inlines.Add(new Run(text[(bold + 2)..end]) { FontWeight = FontWeights.SemiBold });
i = end + 2;
continue;
}
var close = text.IndexOf("](", next, StringComparison.Ordinal);
var urlEnd = close > next ? text.IndexOf(')', close + 2) : -1;
if (close < 0 || urlEnd < 0)
{
inlines.Add(new Run(text[next].ToString()));
i = next + 1;
continue;
}
var label = text[(next + 1)..close];
var url = text[(close + 2)..urlEnd];
var hyper = new Hyperlink(new Run(label))
{
Foreground = brushes.Accent,
NavigateUri = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri : null,
ToolTip = url
};
hyper.RequestNavigate += (_, e) =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(e.Uri.AbsoluteUri)
{
UseShellExecute = true
});
}
catch
{
// ignore
}
e.Handled = true;
};
inlines.Add(hyper);
i = urlEnd + 1;
}
}
private static int MinPositive(params int[] values)
{
var min = -1;
foreach (var v in values)
{
if (v < 0)
{
continue;
}
if (min < 0 || v < min)
{
min = v;
}
}
return min;
}
}

View File

@@ -0,0 +1,108 @@
<Window x:Class="Explorer.App.OperationProfilesWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Operation profiles"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="740" Width="980"
MinHeight="560" MinWidth="780"
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="Close" 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}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="320"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" TextWrapping="Wrap" FontSize="12" Margin="0,8,0,0"
Foreground="{DynamicResource FgMuted}"
Text="Drop files onto a profile to preview with those items."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
<Button Content="New" MinWidth="56" Height="28" Command="{Binding NewProfileCommand}" Margin="0,0,8,0"/>
<Button Content="Duplicate" MinWidth="72" Height="28" Command="{Binding DuplicateCommand}" Margin="0,0,8,0"/>
<Button Content="Delete" MinWidth="56" Height="28" Command="{Binding DeleteCommand}"/>
</StackPanel>
<ListBox ItemsSource="{Binding Profiles}" SelectedItem="{Binding Selected}"
DisplayMemberPath="Name"
AllowDrop="True"
DragOver="OnProfileDragOver"
Drop="OnProfileDrop"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"/>
</DockPanel>
<ScrollViewer Grid.Column="2" VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,8"/>
<TextBlock Text="Source" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Destination" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDest" Margin="8,0,0,0"/>
<TextBox Text="{Binding DestPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<CheckBox Content="Require a clean Git working tree" IsChecked="{Binding RequireGitClean}" Margin="0,0,0,8"/>
<CheckBox Content="Rename" IsChecked="{Binding DoRename}" Margin="0,0,0,8"/>
<TextBlock Text="Prefix / suffix" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"
IsEnabled="{Binding DoRename}"/>
<Grid Margin="0,0,0,8" IsEnabled="{Binding DoRename}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding RenamePrefix, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Column="2" Text="{Binding RenameSuffix, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<TextBlock Text="Search / replace" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"
IsEnabled="{Binding DoRename}"/>
<Grid Margin="0,0,0,8" IsEnabled="{Binding DoRename}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding RenameSearch, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox Grid.Column="2" Text="{Binding RenameReplace, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<CheckBox Content="Compress" IsChecked="{Binding DoCompress}" Margin="0,0,0,8"/>
<ComboBox ItemsSource="{Binding Formats}" DisplayMemberPath="Label" SelectedValuePath="Format"
SelectedValue="{Binding ArchiveFormat}" Margin="0,0,0,8"
IsEnabled="{Binding CompressOptionsEnabled}"/>
<CheckBox Content="Copy to destination" IsChecked="{Binding DoCopy}" Margin="0,0,0,8"/>
<CheckBox Content="Run when the destination volume is connected"
IsChecked="{Binding AutoRun}" IsEnabled="{Binding AutoRunEnabled}" Margin="0,0,0,8"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Auto-run is Copy only — not Rename or Compress. Drive letters can change; the volume identity is stored."/>
<TextBlock Text="Exclude names (one glob per line)" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<TextBox Text="{Binding Excludes, UpdateSourceTrigger=PropertyChanged}" AcceptsReturn="True"
Height="90" VerticalScrollBarVisibility="Auto" TextWrapping="Wrap"/>
<Button Content="Save profile" Margin="0,12,0,0" Height="28" Command="{Binding SaveCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="4" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Action" Width="90" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Path" Width="240" DisplayMemberBinding="{Binding Path}"/>
<GridViewColumn Header="Detail" Width="140" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,84 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using Explorer.Domain;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class OperationProfilesWindow : Window
{
public OperationProfilesWindow(OperationProfilesViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e)
{
if (PickFolder("Source folder") is { } path && DataContext is OperationProfilesViewModel vm)
{
vm.SourcePath = path;
}
}
private void OnBrowseDest(object sender, RoutedEventArgs e)
{
if (PickFolder("Destination folder") is { } path && DataContext is OperationProfilesViewModel vm)
{
vm.DestPath = path;
}
}
private void OnProfileDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private async void OnProfileDrop(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)
|| e.Data.GetData(DataFormats.FileDrop) is not string[] files
|| files.Length == 0
|| DataContext is not OperationProfilesViewModel vm
|| sender is not ListBox list)
{
return;
}
var profile = HitProfile(list, e.GetPosition(list)) ?? vm.Selected;
if (profile is null || profile.Id <= 0)
{
vm.Status = "Drop onto a saved profile.";
return;
}
await vm.RunOnAsync(profile.Id, files).ConfigureAwait(true);
}
private static OperationProfile? HitProfile(ListBox list, Point position)
{
if (list.InputHitTest(position) is not DependencyObject hit)
{
return null;
}
while (hit is not null && hit is not ListBoxItem)
{
hit = VisualTreeHelper.GetParent(hit);
}
return (hit as ListBoxItem)?.DataContext as OperationProfile;
}
private string? PickFolder(string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
return picker.ShowDialog(this) == true ? picker.FolderName : null;
}
}

View File

@@ -0,0 +1,86 @@
<Window x:Class="Explorer.App.ReorganizeWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Organize folder"
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
Height="720" Width="980"
MinHeight="560" MinWidth="780"
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="Close" 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}" Margin="8,0,0,0"/>
<Button DockPanel.Dock="Right" Content="Preview" MinWidth="88" Height="32"
Command="{Binding AnalyzeCommand}" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}" TextWrapping="Wrap"/>
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="340"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" FontSize="12" Margin="0,0,0,12"
Text="Classification suggests moves. Unknown, system, backup, and build folders stay put. Nothing is moved until you Queue."/>
<TextBlock Text="Folder to organize" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,12">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseSource" Margin="8,0,0,0"/>
<TextBox Text="{Binding SourcePath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Pictures" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowsePictures" Margin="8,0,0,0"/>
<TextBox Text="{Binding PicturesPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Videos" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseVideos" Margin="8,0,0,0"/>
<TextBox Text="{Binding VideosPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Audio" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseAudio" Margin="8,0,0,0"/>
<TextBox Text="{Binding AudioPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Documents" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDocuments" Margin="8,0,0,0"/>
<TextBox Text="{Binding DocumentsPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Installers / Software" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseInstallers" Margin="8,0,0,0"/>
<TextBox Text="{Binding InstallersPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Archives" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseArchives" Margin="8,0,0,0"/>
<TextBox Text="{Binding ArchivesPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<TextBlock Text="Development" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,4"/>
<DockPanel Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="80" Height="28" Click="OnBrowseDevelopment" Margin="8,0,0,0"/>
<TextBox Text="{Binding DevelopmentPath, UpdateSourceTrigger=PropertyChanged}"/>
</DockPanel>
<Button Content="Save destinations" Height="28" Command="{Binding SaveDestinationsCommand}"/>
</StackPanel>
</ScrollViewer>
<ListView Grid.Column="2" ItemsSource="{Binding Rows}"
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
<ListView.View>
<GridView>
<GridViewColumn Header="Name" Width="160" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="Category" Width="110" DisplayMemberBinding="{Binding Category}"/>
<GridViewColumn Header="Action" Width="70" DisplayMemberBinding="{Binding Action}"/>
<GridViewColumn Header="Destination" Width="200" DisplayMemberBinding="{Binding Destination}"/>
<GridViewColumn Header="Detail" Width="180" DisplayMemberBinding="{Binding Detail}"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</DockPanel>
</Window>

View File

@@ -0,0 +1,37 @@
using System.Windows;
using Explorer.Presentation.ViewModels;
namespace Explorer.App;
public partial class ReorganizeWindow : Window
{
public ReorganizeWindow(ReorganizeViewModel vm)
{
InitializeComponent();
DataContext = vm;
}
private void OnBrowseSource(object sender, RoutedEventArgs e) => Browse(path => vm().SourcePath = path, "Folder to organize");
private void OnBrowsePictures(object sender, RoutedEventArgs e) => Browse(path => vm().PicturesPath = path, "Pictures folder");
private void OnBrowseVideos(object sender, RoutedEventArgs e) => Browse(path => vm().VideosPath = path, "Videos folder");
private void OnBrowseAudio(object sender, RoutedEventArgs e) => Browse(path => vm().AudioPath = path, "Audio folder");
private void OnBrowseDocuments(object sender, RoutedEventArgs e) => Browse(path => vm().DocumentsPath = path, "Documents folder");
private void OnBrowseInstallers(object sender, RoutedEventArgs e) => Browse(path => vm().InstallersPath = path, "Installers folder");
private void OnBrowseArchives(object sender, RoutedEventArgs e) => Browse(path => vm().ArchivesPath = path, "Archives folder");
private void OnBrowseDevelopment(object sender, RoutedEventArgs e) => Browse(path => vm().DevelopmentPath = path, "Development folder");
private ReorganizeViewModel vm() => (ReorganizeViewModel)DataContext;
private void Browse(Action<string> assign, string title)
{
var picker = new Microsoft.Win32.OpenFolderDialog
{
Title = title,
Multiselect = false
};
if (picker.ShowDialog(this) == true && !string.IsNullOrWhiteSpace(picker.FolderName))
{
assign(picker.FolderName);
}
}
}

View File

@@ -54,13 +54,29 @@
<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, and delete steps are removed automatically. Failed items stay until you dismiss them."/>
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,0" FontSize="12"
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="24,0,0,18" FontSize="12"
Text="When enabled, a scan lists files inside ZIP, RAR, 7z, TAR, and similar archives from the archive catalog — files are not extracted. Individual uncompressed sizes are stored. Folder totals still use the archives size on disk. Online-only cloud archives are skipped."/>
<TextBlock Text="7-Zip" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Extract, compress, add, and verify use 7-Zip when it is installed. Leave the path empty to look in Program Files and PATH. 7-Zip is not bundled with Explorer Workbench."/>
<DockPanel Margin="0,0,0,18">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseSevenZip" Margin="8,0,0,0"/>
<TextBox x:Name="SevenZipPath"/>
</DockPanel>
<TextBlock Text="Git" FontSize="16" FontWeight="SemiBold" Margin="0,8,0,10"/>
<TextBlock TextWrapping="Wrap" Foreground="{DynamicResource FgMuted}" Margin="0,0,0,8" FontSize="12"
Text="Repository badges use git.exe when it is installed. Leave the path empty to look in Program Files and PATH. Git is not bundled. Explorer Workbench does not commit, push, or pull."/>
<DockPanel Margin="0,0,0,6">
<Button DockPanel.Dock="Right" Content="Browse…" MinWidth="88" Height="28" Click="OnBrowseGit" Margin="8,0,0,0"/>
<TextBox x:Name="GitPath"/>
</DockPanel>
</StackPanel>
</ScrollViewer>
</DockPanel>

View File

@@ -23,6 +23,8 @@ public partial class SettingsWindow : Window
ShowHidden.IsChecked = prefs.ShowHiddenFiles;
ShowProtected.IsChecked = prefs.ShowProtectedSystemLocations;
AutoClearQueue.IsChecked = prefs.AutoClearQueueWhenDone;
SevenZipPath.Text = prefs.SevenZipPath ?? "";
GitPath.Text = prefs.GitPath ?? "";
}
private void OnThemeChanged(object sender, RoutedEventArgs e)
@@ -45,13 +47,43 @@ public partial class SettingsWindow : Window
IndexArchiveContents = IndexArchives.IsChecked == true,
ShowHiddenFiles = ShowHidden.IsChecked == true,
ShowProtectedSystemLocations = ShowProtected.IsChecked == true,
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true
AutoClearQueueWhenDone = AutoClearQueue.IsChecked == true,
SevenZipPath = string.IsNullOrWhiteSpace(SevenZipPath.Text) ? null : SevenZipPath.Text.Trim(),
GitPath = string.IsNullOrWhiteSpace(GitPath.Text) ? null : GitPath.Text.Trim()
};
await _vm.ApplyPreferencesAsync(prefs).ConfigureAwait(true);
DialogResult = true;
Close();
}
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 OnCancel(object sender, RoutedEventArgs e)
{
_vm.Theme = _originalTheme;

View File

@@ -47,6 +47,11 @@ internal static class ShellIconCache
return isDirectory ? "dir:generic" : "file:generic";
}
if (path == LocationRoots.RecycleBin)
{
return "recycle";
}
if (isDirectory)
{
return PathRules.IsDriveRoot(path) || (path.Length <= 3 && path.Contains(':', StringComparison.Ordinal))
@@ -79,6 +84,10 @@ internal static class ShellIconCache
psz = isDirectory ? "folder" : "file";
flags |= ShgfiUseFileAttributes;
}
else if (path == LocationRoots.RecycleBin)
{
psz = @"::{645FF040-5081-101B-9F08-00AA002F954E}";
}
else if (isDirectory && PathRules.IsDriveRoot(path))
{
psz = PathRules.EnsureDirectoryTrailingSlashIfRoot(path);

View File

@@ -0,0 +1,335 @@
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Presentation;
using Explorer.Presentation.ViewModels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Explorer.App;
public sealed class ThumbnailService : BackgroundService, IThumbnailService
{
public const int CacheCapacity = 384;
private readonly ThumbnailScheduler _scheduler = new();
private readonly LruCache<ThumbnailKey, object?> _bitmaps = new(CacheCapacity);
private readonly ILogger<ThumbnailService> _logger;
private readonly SemaphoreSlim _ready = new(0, 1);
private readonly List<WeakReference<ExplorerPaneViewModel>> _panes = [];
private readonly object _panesGate = new();
private Dispatcher? _dispatcher;
public ThumbnailService(ILogger<ThumbnailService> logger) => _logger = logger;
public ThumbnailSchedulerStats Stats => _scheduler.Snapshot();
public void OnSessionChanged(ExplorerPaneViewModel pane, int generation)
{
Remember(pane);
_scheduler.Reset(generation);
Pulse();
_logger.LogDebug("Thumbnail session {Generation} for {Path}", generation, pane.CurrentPath);
}
public void OnPreviewEnabledChanged(ExplorerPaneViewModel pane)
{
Remember(pane);
var enabled = pane.ViewMode == FolderViewMode.Preview;
_scheduler.SetEnabled(enabled);
if (!enabled)
{
_logger.LogDebug("Thumbnail pipeline paused; Preview disabled");
return;
}
OnViewportChanged(pane, []);
}
public void OnViewportChanged(ExplorerPaneViewModel pane, IReadOnlyList<FolderItemViewModel> visible)
{
Remember(pane);
_dispatcher ??= System.Windows.Application.Current?.Dispatcher;
var enabled = pane.ViewMode == FolderViewMode.Preview;
_scheduler.SetEnabled(enabled);
if (!enabled)
{
return;
}
var generation = pane.BrowseGeneration;
var items = pane.SnapshotItems();
var visibleSet = new HashSet<string>(visible.Select(v => v.FullPath), StringComparer.OrdinalIgnoreCase);
var first = 0;
var last = 0;
if (visible.Count > 0)
{
first = IndexOf(items, visible[0].FullPath);
last = IndexOf(items, visible[^1].FullPath);
if (first < 0 || last < 0)
{
first = 0;
last = Math.Min(items.Count, visible.Count) - 1;
}
if (last < first)
{
(first, last) = (last, first);
}
}
else if (items.Count > 0)
{
last = Math.Min(items.Count, 24) - 1;
}
var prefetchStart = Math.Max(0, first - ThumbnailScheduler.PrefetchItems);
var prefetchEnd = Math.Min(items.Count, last + 1 + ThumbnailScheduler.PrefetchItems);
var jobs = new List<ThumbnailJob>(prefetchEnd - prefetchStart);
for (var i = prefetchStart; i < prefetchEnd; i++)
{
var item = items[i];
if (!item.IsImage || item.MayHydrateOnRead)
{
continue;
}
var key = ThumbnailKey.From(item.FullPath, item.Item.ModifiedUtc, ThumbnailScheduler.DefaultPixelWidth);
var rank = i >= first && i <= last ? 0 : 1;
if (_bitmaps.TryGet(key, out var cached))
{
_scheduler.MarkCached(key);
_scheduler.RecordCacheHit();
if (cached is not null && item.Thumbnail is null)
{
item.SetThumbnail(cached);
}
continue;
}
jobs.Add(new ThumbnailJob(key, rank, generation));
}
var queued = _scheduler.SetWindow(generation, jobs);
_logger.LogDebug(
"Thumbnail window gen={Generation} visible={Visible} queued={Queued} {Stats}",
generation,
visibleSet.Count,
queued.Count,
_scheduler.Snapshot());
Pulse();
}
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var threads = new Thread[ThumbnailScheduler.DefaultConcurrency];
stoppingToken.Register(Pulse);
stoppingToken.Register(Pulse);
for (var i = 0; i < threads.Length; i++)
{
var thread = new Thread(() => WorkerLoop(stoppingToken))
{
IsBackground = true,
Name = $"Explorer-Thumbnail-{i}"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
threads[i] = thread;
}
return Task.Run(() =>
{
foreach (var thread in threads)
{
thread.Join();
}
}, CancellationToken.None);
}
private void WorkerLoop(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
_ready.Wait(stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
while (_scheduler.TryTake(out var job))
{
Process(job);
}
}
}
private void Process(ThumbnailJob job)
{
if (!_scheduler.IsCurrent(job))
{
_scheduler.Complete(job, generated: false, failed: false);
return;
}
if (_bitmaps.TryGet(job.Key, out var cached))
{
_scheduler.RecordCacheHit();
_scheduler.Complete(job, generated: false, failed: false);
Apply(job, cached);
return;
}
var started = Environment.TickCount64;
object? bitmap = null;
var failed = false;
try
{
bitmap = Decode(job.Key);
}
catch (Exception ex)
{
failed = true;
_logger.LogDebug(ex, "Thumbnail decode failed for {Path}", job.Key.Path);
}
_bitmaps.Set(job.Key, bitmap);
_scheduler.Complete(job, generated: bitmap is not null, failed);
_logger.LogDebug(
"Thumbnail {Result} path={Path} ms={Ms} {Stats}",
failed ? "failed" : bitmap is null ? "empty" : "generated",
job.Key.Path,
Environment.TickCount64 - started,
_scheduler.Snapshot());
if (bitmap is not null && _scheduler.IsCurrent(job))
{
Apply(job, bitmap);
}
Pulse();
}
private void Apply(ThumbnailJob job, object? bitmap)
{
if (bitmap is null)
{
return;
}
var dispatcher = _dispatcher ?? System.Windows.Application.Current?.Dispatcher;
if (dispatcher is null)
{
return;
}
dispatcher.BeginInvoke(() =>
{
if (!_scheduler.IsCurrent(job))
{
return;
}
ExplorerPaneViewModel? pane = null;
lock (_panesGate)
{
foreach (var entry in _panes)
{
if (entry.TryGetTarget(out var candidate) && candidate.BrowseGeneration == job.Generation)
{
pane = candidate;
break;
}
}
}
if (pane is null)
{
return;
}
foreach (var item in pane.SnapshotItems())
{
if (string.Equals(item.FullPath, job.Key.Path, StringComparison.OrdinalIgnoreCase))
{
item.SetThumbnail(bitmap);
break;
}
}
}, DispatcherPriority.Background);
}
private static object? Decode(ThumbnailKey key)
{
if (!File.Exists(key.Path))
{
return null;
}
using var stream = new FileStream(
key.Path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
64 * 1024,
FileOptions.SequentialScan);
var image = new BitmapImage();
image.BeginInit();
image.StreamSource = stream;
image.CacheOption = BitmapCacheOption.OnLoad;
image.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
image.DecodePixelWidth = key.PixelWidth;
image.EndInit();
image.Freeze();
return image;
}
private void Remember(ExplorerPaneViewModel pane)
{
lock (_panesGate)
{
for (var i = _panes.Count - 1; i >= 0; i--)
{
if (!_panes[i].TryGetTarget(out var existing) || existing == pane)
{
if (existing == pane)
{
return;
}
_panes.RemoveAt(i);
}
}
_panes.Add(new WeakReference<ExplorerPaneViewModel>(pane));
}
}
private void Pulse()
{
try
{
_ready.Release();
}
catch (SemaphoreFullException)
{
}
}
private static int IndexOf(IReadOnlyList<FolderItemViewModel> items, string path)
{
for (var i = 0; i < items.Count; i++)
{
if (string.Equals(items[i].FullPath, path, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
}

View File

@@ -0,0 +1,237 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
namespace Explorer.App;
public sealed class VirtualizingWrapPanel : VirtualizingPanel, IScrollInfo
{
public static readonly DependencyProperty ItemWidthProperty =
DependencyProperty.Register(
nameof(ItemWidth),
typeof(double),
typeof(VirtualizingWrapPanel),
new FrameworkPropertyMetadata(120d, FrameworkPropertyMetadataOptions.AffectsMeasure));
public static readonly DependencyProperty ItemHeightProperty =
DependencyProperty.Register(
nameof(ItemHeight),
typeof(double),
typeof(VirtualizingWrapPanel),
new FrameworkPropertyMetadata(148d, FrameworkPropertyMetadataOptions.AffectsMeasure));
private Size _extent;
private Size _viewport;
private Point _offset;
private int _columns = 1;
private int _firstVisible;
private int _lastVisible;
public double ItemWidth
{
get => (double)GetValue(ItemWidthProperty);
set => SetValue(ItemWidthProperty, value);
}
public double ItemHeight
{
get => (double)GetValue(ItemHeightProperty);
set => SetValue(ItemHeightProperty, value);
}
public int FirstVisibleIndex => _firstVisible;
public int LastVisibleIndex => _lastVisible;
public ScrollViewer? ScrollOwner { get; set; }
public bool CanVerticallyScroll { get; set; } = true;
public bool CanHorizontallyScroll { get; set; }
public double ExtentWidth => _extent.Width;
public double ExtentHeight => _extent.Height;
public double ViewportWidth => _viewport.Width;
public double ViewportHeight => _viewport.Height;
public double HorizontalOffset => _offset.X;
public double VerticalOffset => _offset.Y;
protected override Size MeasureOverride(Size availableSize)
{
EnsureGenerator();
var items = ItemsControl.GetItemsOwner(this);
var count = items?.Items.Count ?? 0;
var itemWidth = Math.Max(1, ItemWidth);
var itemHeight = Math.Max(1, ItemHeight);
var viewportWidth = double.IsInfinity(availableSize.Width) ? itemWidth : Math.Max(itemWidth, availableSize.Width);
var viewportHeight = double.IsInfinity(availableSize.Height) ? itemHeight : Math.Max(0, availableSize.Height);
_columns = Math.Max(1, (int)(viewportWidth / itemWidth));
var rows = count == 0 ? 0 : (int)Math.Ceiling(count / (double)_columns);
_extent = new Size(viewportWidth, rows * itemHeight);
_viewport = new Size(viewportWidth, viewportHeight);
_offset.Y = Math.Clamp(_offset.Y, 0, Math.Max(0, _extent.Height - _viewport.Height));
var firstRow = (int)(_offset.Y / itemHeight);
var visibleRows = Math.Max(1, (int)Math.Ceiling(_viewport.Height / itemHeight) + 1);
_firstVisible = Math.Min(count, firstRow * _columns);
_lastVisible = Math.Min(count, _firstVisible + visibleRows * _columns);
GenerateChildren(_firstVisible, _lastVisible, new Size(itemWidth, itemHeight));
ScrollOwner?.InvalidateScrollInfo();
return _viewport;
}
protected override Size ArrangeOverride(Size finalSize)
{
var itemWidth = Math.Max(1, ItemWidth);
var itemHeight = Math.Max(1, ItemHeight);
foreach (UIElement child in InternalChildren)
{
var index = ContainerIndex(child);
if (index < 0)
{
child.Arrange(new Rect());
continue;
}
var column = index % _columns;
var row = index / _columns;
var x = column * itemWidth;
var y = row * itemHeight - _offset.Y;
child.Arrange(new Rect(x, y, itemWidth, itemHeight));
}
return finalSize;
}
protected override void OnItemsChanged(object sender, ItemsChangedEventArgs args)
{
switch (args.Action)
{
case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
case System.Collections.Specialized.NotifyCollectionChangedAction.Move:
RemoveInternalChildRange(args.Position.Index, args.ItemUICount);
break;
case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
RemoveInternalChildRange(0, InternalChildren.Count);
break;
}
InvalidateMeasure();
base.OnItemsChanged(sender, args);
}
protected override void BringIndexIntoView(int index)
{
var itemHeight = Math.Max(1, ItemHeight);
var row = index / Math.Max(1, _columns);
SetVerticalOffset(row * itemHeight);
}
public void LineUp() => SetVerticalOffset(VerticalOffset - ItemHeight);
public void LineDown() => SetVerticalOffset(VerticalOffset + ItemHeight);
public void LineLeft() { }
public void LineRight() { }
public void PageUp() => SetVerticalOffset(VerticalOffset - ViewportHeight);
public void PageDown() => SetVerticalOffset(VerticalOffset + ViewportHeight);
public void PageLeft() { }
public void PageRight() { }
public void MouseWheelUp() => SetVerticalOffset(VerticalOffset - ItemHeight * SystemParameters.WheelScrollLines);
public void MouseWheelDown() => SetVerticalOffset(VerticalOffset + ItemHeight * SystemParameters.WheelScrollLines);
public void MouseWheelLeft() { }
public void MouseWheelRight() { }
public void SetHorizontalOffset(double offset) { }
public void SetVerticalOffset(double offset)
{
_offset.Y = Math.Clamp(offset, 0, Math.Max(0, _extent.Height - _viewport.Height));
InvalidateMeasure();
ScrollOwner?.InvalidateScrollInfo();
}
public Rect MakeVisible(Visual visual, Rect rectangle)
{
var index = ContainerIndex(visual);
if (index >= 0)
{
BringIndexIntoView(index);
}
return rectangle;
}
private void GenerateChildren(int first, int last, Size childSize)
{
var generator = (IRecyclingItemContainerGenerator)ItemContainerGenerator;
CleanUp(first, last, generator);
if (first >= last)
{
return;
}
var position = generator.GeneratorPositionFromIndex(first);
var childIndex = position.Offset == 0 ? position.Index : position.Index + 1;
if (childIndex < 0)
{
childIndex = 0;
}
using (generator.StartAt(position, GeneratorDirection.Forward, true))
{
for (var i = first; i < last; i++, childIndex++)
{
var child = (UIElement)generator.GenerateNext(out var newlyRealized);
if (newlyRealized)
{
if (childIndex >= InternalChildren.Count)
{
AddInternalChild(child);
}
else
{
InsertInternalChild(childIndex, child);
}
}
generator.PrepareItemContainer(child);
child.Measure(childSize);
}
}
}
private void CleanUp(int first, int last, IRecyclingItemContainerGenerator generator)
{
for (var i = InternalChildren.Count - 1; i >= 0; i--)
{
var index = ContainerIndex(InternalChildren[i]);
if (index >= first && index < last)
{
continue;
}
if (index >= 0)
{
var pos = generator.GeneratorPositionFromIndex(index);
RemoveInternalChildRange(i, 1);
generator.Recycle(pos, 1);
}
else
{
RemoveInternalChildRange(i, 1);
}
}
}
private int ContainerIndex(DependencyObject container)
=> ItemContainerGenerator is ItemContainerGenerator generator
? generator.IndexFromContainer(container)
: -1;
private void EnsureGenerator()
{
if (ItemContainerGenerator is not null)
{
return;
}
var owner = ItemsControl.GetItemsOwner(this);
owner?.ApplyTemplate();
}
}

View File

@@ -0,0 +1,65 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class BrowseHydration
{
public const int LocalWorkers = 8;
public const int ConstrainedWorkers = 2;
public const int LocalProviderBatch = 64;
public const int ConstrainedProviderBatch = 16;
public const int PublishBatch = 48;
public const int NearbyWindow = 32;
public static int WorkerCount(SourceKind? kind, bool constrained)
=> kind is SourceKind.Smb or SourceKind.Nfs or SourceKind.Cloud || constrained
? ConstrainedWorkers
: LocalWorkers;
public static int ProviderBatchSize(SourceKind? kind, bool constrained)
=> kind is SourceKind.Smb or SourceKind.Nfs or SourceKind.Cloud || constrained
? ConstrainedProviderBatch
: LocalProviderBatch;
public static List<FileSystemItem> Prioritize(
IReadOnlyList<FileSystemItem> pending,
IReadOnlyList<string> visiblePaths)
{
if (pending.Count == 0)
{
return [];
}
if (visiblePaths.Count == 0)
{
return pending.ToList();
}
var visible = new HashSet<string>(visiblePaths, StringComparer.OrdinalIgnoreCase);
var nearby = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < pending.Count; i++)
{
if (!visible.Contains(pending[i].FullPath))
{
continue;
}
var from = Math.Max(0, i - NearbyWindow);
var to = Math.Min(pending.Count, i + NearbyWindow + 1);
for (var j = from; j < to; j++)
{
nearby.Add(pending[j].FullPath);
}
}
return pending
.Select((item, index) => (
item,
rank: visible.Contains(item.FullPath) ? 0 : nearby.Contains(item.FullPath) ? 1 : 2,
index))
.OrderBy(x => x.rank)
.ThenBy(x => x.index)
.Select(x => x.item)
.ToList();
}
}

View File

@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -40,35 +42,70 @@ public sealed class BrowseService
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
{
var groupNetwork = _preferences.Load().GroupNetworkPlaces;
return await ListSourcesAsync(
var listing = await ListSourcesAsync(
LocationRoots.ThisPc,
source => !groupNetwork || !source.Kind.IsNetwork(),
cancellationToken).ConfigureAwait(false);
var items = listing.Items.ToList();
if (!groupNetwork)
{
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
}
items.Add(RecycleBinItem());
return new FolderListing { Path = listing.Path, Items = items };
}
public Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
=> ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken);
public Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
public async Task<FolderListing> ListNetworkAsync(CancellationToken cancellationToken = default)
{
var items = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.Select(place =>
var listing = await ListSourcesAsync(LocationRoots.Network, source => source.Kind.IsNetwork(), cancellationToken)
.ConfigureAwait(false);
var items = listing.Items.ToList();
items.AddRange(await UntrackedNetworkItemsAsync(cancellationToken).ConfigureAwait(false));
return new FolderListing { Path = listing.Path, Items = items };
}
public async Task<FolderListing> ListCloudAsync(CancellationToken cancellationToken = default)
{
var items = new List<FileSystemItem>();
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
{
var exists = Directory.Exists(place.Path);
var space = exists ? _volumes.GetSpace(place.Path) : default;
var quota = await _providers.TryGetQuotaAsync(place.Path, cancellationToken).ConfigureAwait(false);
var capacity = quota?.TotalBytes ?? space.CapacityBytes;
long? free = quota is { TotalBytes: long total, UsedBytes: long used }
? Math.Max(0, total - used)
: space.FreeBytes;
items.Add(new FileSystemItem
{
var exists = Directory.Exists(place.Path);
var space = exists ? _volumes.GetSpace(place.Path) : default;
return new FileSystemItem
{
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = space.FreeBytes,
CapacityBytes = space.CapacityBytes
};
})
.ToList();
return Task.FromResult(new FolderListing { Path = LocationRoots.Cloud, Items = items });
FullPath = place.Path,
Name = exists ? place.DisplayName : $"{place.DisplayName} (Offline)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = free,
CapacityBytes = capacity
});
}
return new FolderListing { Path = LocationRoots.Cloud, Items = items };
}
public FolderListing ListRecycleBin(string? path = null)
{
var root = string.IsNullOrWhiteSpace(path) ? LocationRoots.RecycleBin : path;
var summary = _recycle?.TrySummarize(root);
var hint = summary is null
? "Recycle Bin contents are managed by Windows."
: $"{summary.ItemCount:N0} deleted items · {FormatBytes(summary.UsedBytes)} used";
return new FolderListing
{
Path = LocationRoots.RecycleBin,
IsOffline = false,
Items = [RecycleBinItem(summary)],
Error = hint
};
}
private async Task<FolderListing> ListSourcesAsync(
@@ -109,6 +146,31 @@ public sealed class BrowseService
}
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
{
var items = new List<FileSystemItem>();
var byPath = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
string? error = null;
var offline = false;
var listingPath = path;
await foreach (var delta in ListProgressiveAsync(path, viewport: null, cancellationToken).ConfigureAwait(false))
{
listingPath = delta.Path;
offline = delta.IsOffline;
if (delta.Error is not null)
{
error = delta.Error;
}
ApplyDelta(items, byPath, delta);
}
return new FolderListing { Path = listingPath, IsOffline = offline, Items = items, Error = error };
}
public async IAsyncEnumerable<BrowseDelta> ListProgressiveAsync(
string path,
BrowseViewport? viewport = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
if (source is { IsIndexed: true } && _preferences.Load().IndexArchiveContents)
@@ -116,69 +178,472 @@ public sealed class BrowseService
var archiveListing = await TryListArchiveAsync(source, path, cancellationToken).ConfigureAwait(false);
if (archiveListing is not null)
{
return archiveListing;
yield return CompleteDelta(archiveListing);
yield break;
}
}
if (IsRecycleBinPath(path))
if (path == LocationRoots.RecycleBin || IsRecycleBinPath(path))
{
return ListRecycleBin(path);
yield return CompleteDelta(ListRecycleBin(path));
yield break;
}
var reachable = _volumes.IsPathReachable(path);
if (reachable)
if (!reachable)
{
var items = _enumerator.EnumerateChildrenSafe(path, out var error);
var listing = (await _providers.EnrichAsync(items.ToList(), cancellationToken).ConfigureAwait(false)).ToList();
if (source is { IsIndexed: true })
{
listing = await OverlayFolderSizesAsync(source, path, listing, cancellationToken).ConfigureAwait(false);
}
return FinishListing(path, AttachVolumeSpace(listing), isOffline: false, error);
yield return CompleteDelta(await ListOfflineAsync(source, path, cancellationToken).ConfigureAwait(false));
yield break;
}
if (source is { IsIndexed: true })
await foreach (var delta in ListLiveProgressiveAsync(path, source, viewport, cancellationToken).ConfigureAwait(false))
{
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
if (dir is null)
{
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
}
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
.ConfigureAwait(false);
var items = children
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
.Select(c => new FileSystemItem
{
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, c.PathRel),
Name = c.Name,
IsDirectory = c.IsDirectory,
SizeBytes = c.IsDirectory ? c.AggregateSize : c.SizeBytes,
CreatedUtc = c.CreatedUtc,
ModifiedUtc = c.ModifiedUtc,
Attributes = c.Attributes,
FileId = c.FileId,
ReparseTag = c.ReparseTag,
AllocatedSizeBytes = c.AllocatedSizeBytes,
Cloud = c.CloudAvailability is { } availability
? new CloudPresence(null, availability, c.SizeBytes, c.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null
})
.ToList();
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
yield return delta;
}
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
public bool CanBrowseArchive(string name)
=> _preferences.Load().IndexArchiveContents && ArchiveFormats.IsArchive(name);
private static void ApplyDelta(
List<FileSystemItem> items,
Dictionary<string, int> byPath,
BrowseDelta delta)
{
foreach (var added in delta.Added)
{
Upsert(items, byPath, added);
}
foreach (var updated in delta.Updated)
{
Upsert(items, byPath, updated);
}
}
private static void Upsert(
List<FileSystemItem> items,
Dictionary<string, int> byPath,
FileSystemItem item)
{
if (byPath.TryGetValue(item.FullPath, out var index))
{
items[index] = item;
return;
}
byPath[item.FullPath] = items.Count;
items.Add(item);
}
private static BrowseDelta CompleteDelta(FolderListing listing)
=> new()
{
Path = listing.Path,
IsOffline = listing.IsOffline,
Error = listing.Error,
Added = listing.Items,
EnumerationComplete = true,
HydrationComplete = true,
CompletedStages = ItemHydrationFlags.All
};
private async Task<FolderListing> ListOfflineAsync(Source? source, string path, CancellationToken cancellationToken)
{
if (source is not { IsIndexed: true })
{
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
}
var rel = source.LastRootPath is null ? "" : PathRules.MakeRelative(source.LastRootPath, path);
var dir = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
if (dir is null)
{
return new FolderListing { Path = path, IsOffline = true, Error = "Not available" };
}
var children = await _store.Entries.GetChildrenAsync(source.Id, dir.Id, null, cancellationToken)
.ConfigureAwait(false);
var items = children
.Where(c => c.Status is EntryStatus.Present or EntryStatus.Offline)
.Select(c => FromIndex(source, c))
.ToList();
return FinishListing(path, AttachVolumeSpace(items), isOffline: true, error: null);
}
private static FileSystemItem FromIndex(Source source, IndexEntry entry)
=> new()
{
FullPath = PathRules.Combine(source.LastRootPath ?? source.DisplayName, entry.PathRel),
Name = entry.Name,
IsDirectory = entry.IsDirectory,
SizeBytes = entry.IsDirectory ? entry.AggregateSize : entry.SizeBytes,
CreatedUtc = entry.CreatedUtc,
ModifiedUtc = entry.ModifiedUtc,
Attributes = entry.Attributes,
FileId = entry.FileId,
ReparseTag = entry.ReparseTag,
AllocatedSizeBytes = entry.AllocatedSizeBytes,
Cloud = entry.CloudAvailability is { } availability
? new CloudPresence(null, availability, entry.SizeBytes, entry.AllocatedSizeBytes, availability == CloudAvailability.OnlineOnly)
: null,
Hydration = ItemHydrationFlags.All
};
private async IAsyncEnumerable<BrowseDelta> ListLiveProgressiveAsync(
string path,
Source? source,
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));
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();
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);
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)
{
yield return new BrowseDelta
{
Path = path,
Added = batch.ToArray(),
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
batch.Clear();
}
}
yield return new BrowseDelta
{
Path = path,
Error = sink.Error,
Added = batch.Count > 0 ? batch.ToArray() : [],
EnumerationComplete = true,
CompletedStages = ItemHydrationFlags.Shell | ItemHydrationFlags.Metadata | ItemHydrationFlags.Location
};
if (indexMap is null)
{
indexMap = await indexTask.ConfigureAwait(false);
}
var indexUpdates = OverlayPendingIndex(all, byPath, indexMap, prefs);
if (indexUpdates.Count > 0)
{
yield return new BrowseDelta
{
Path = path,
Updated = indexUpdates,
CompletedStages = ItemHydrationFlags.Index
};
}
var accessUpdates = await ProbeAccessDeniedAsync(all, byPath, prefs, source?.Kind, cancellationToken)
.ConfigureAwait(false);
if (accessUpdates.Count > 0)
{
yield return new BrowseDelta { Path = path, Updated = accessUpdates };
}
var constrained = _providers.Find(path) is not null;
if (constrained)
{
await foreach (var enriched in EnrichInBatchesAsync(all, byPath, viewport, source?.Kind, constrained: true, cancellationToken)
.ConfigureAwait(false))
{
if (enriched.Count == 0)
{
continue;
}
yield return new BrowseDelta
{
Path = path,
Updated = enriched,
CompletedStages = ItemHydrationFlags.Provider
};
}
}
yield return new BrowseDelta
{
Path = path,
Error = sink.Error,
HydrationComplete = true,
CompletedStages = ItemHydrationFlags.All
};
}
private async IAsyncEnumerable<FileSystemItem> StreamEnumerationAsync(
string path,
FileEnumerationSink sink,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var channel = Channel.CreateBounded<FileSystemItem>(new BoundedChannelOptions(256)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait
});
var writer = Task.Run(async () =>
{
try
{
foreach (var item in _enumerator.EnumerateChildrenStreaming(path, sink, cancellationToken))
{
await channel.Writer.WriteAsync(item, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Reader observes cancellation.
}
catch (Exception ex)
{
sink.Error ??= ex.Message;
}
finally
{
channel.Writer.TryComplete();
}
}, CancellationToken.None);
try
{
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
yield return item;
}
}
finally
{
try
{
await writer.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// superseded
}
}
}
private async Task<Dictionary<string, IndexEntry>> LoadIndexChildrenAsync(
Source source,
string path,
CancellationToken cancellationToken)
{
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? (string.IsNullOrEmpty(rel)
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
: null);
if (indexed is null)
{
return new Dictionary<string, IndexEntry>(StringComparer.Ordinal);
}
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
return children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
}
private FileSystemItem OverlayIndex(
FileSystemItem item,
IReadOnlyDictionary<string, IndexEntry> byName,
UiPreferences preferences)
{
if (!byName.TryGetValue(NameNormalizer.Normalize(item.Name), out var entry))
{
return item;
}
var size = item.IsDirectory ? entry.AggregateSize : item.SizeBytes;
var cloud = item.Cloud;
if (cloud is null && entry.CloudAvailability is { } availability)
{
cloud = new CloudPresence(
null,
availability,
entry.SizeBytes,
entry.AllocatedSizeBytes,
availability == CloudAvailability.OnlineOnly);
}
var hydrated = item.Overlay(
sizeBytes: size,
allocatedSizeBytes: item.AllocatedSizeBytes ?? entry.AllocatedSizeBytes,
fileId: item.FileId ?? entry.FileId,
cloud: cloud,
hydration: item.Hydration | ItemHydrationFlags.Index);
return Annotate(hydrated, sizeFromIndex: item.IsDirectory && entry.AggregateSize > 0, preferences, probeAccess: false);
}
private List<FileSystemItem> OverlayPendingIndex(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
IReadOnlyDictionary<string, IndexEntry> indexMap,
UiPreferences preferences)
{
if (indexMap.Count == 0)
{
return [];
}
var updates = new List<FileSystemItem>();
for (var i = 0; i < all.Count; i++)
{
var current = all[i];
if ((current.Hydration & ItemHydrationFlags.Index) != 0)
{
continue;
}
var updated = OverlayIndex(current, indexMap, preferences);
if (ReferenceEquals(updated, current))
{
all[i] = current.Overlay(hydration: current.Hydration | ItemHydrationFlags.Index);
continue;
}
all[i] = updated;
byPath[updated.FullPath] = i;
updates.Add(updated);
}
return updates;
}
private async Task<List<FileSystemItem>> ProbeAccessDeniedAsync(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
UiPreferences preferences,
SourceKind? kind,
CancellationToken cancellationToken)
{
if (!preferences.ShowProtectedSystemLocations)
{
return [];
}
var candidates = all
.Where(i => i.IsDirectory && i.Location.IsProtected && !i.Location.IsRecycleBin && !i.Location.AccessDenied)
.ToList();
if (candidates.Count == 0)
{
return [];
}
var updates = new List<FileSystemItem>();
var gate = new object();
await Parallel.ForEachAsync(
candidates,
new ParallelOptions
{
MaxDegreeOfParallelism = BrowseHydration.WorkerCount(kind, constrained: false),
CancellationToken = cancellationToken
},
(item, token) =>
{
token.ThrowIfCancellationRequested();
if (!IsAccessDenied(item.FullPath))
{
return ValueTask.CompletedTask;
}
var updated = Annotate(item, sizeFromIndex: item.SizeBytes > 0 && item.IsDirectory, preferences, probeAccess: true);
lock (gate)
{
if (byPath.TryGetValue(updated.FullPath, out var index))
{
all[index] = updated;
}
updates.Add(updated);
}
return ValueTask.CompletedTask;
}).ConfigureAwait(false);
return updates;
}
private async IAsyncEnumerable<IReadOnlyList<FileSystemItem>> EnrichInBatchesAsync(
List<FileSystemItem> all,
Dictionary<string, int> byPath,
BrowseViewport? viewport,
SourceKind? kind,
bool constrained,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
if (all.Count == 0)
{
yield break;
}
var pending = all.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
var batchSize = BrowseHydration.ProviderBatchSize(kind, constrained);
while (pending.Count > 0)
{
cancellationToken.ThrowIfCancellationRequested();
var leftover = all.Where(i => pending.ContainsKey(i.FullPath)).ToList();
var ordered = BrowseHydration.Prioritize(leftover, viewport?.Snapshot() ?? []);
var chunk = ordered.Take(batchSize).ToList();
var enriched = await _providers.EnrichAsync(chunk, cancellationToken).ConfigureAwait(false);
var changed = new List<FileSystemItem>(chunk.Count);
for (var i = 0; i < chunk.Count; i++)
{
var original = chunk[i];
pending.Remove(original.FullPath);
var next = i < enriched.Count ? enriched[i] : original;
if (ReferenceEquals(next, original))
{
continue;
}
if (byPath.TryGetValue(next.FullPath, out var index))
{
all[index] = next;
}
changed.Add(next);
}
yield return changed;
}
}
private async Task<FolderListing?> TryListArchiveAsync(Source source, string path, CancellationToken cancellationToken)
{
if (source.LastRootPath is null)
@@ -243,54 +708,6 @@ public sealed class BrowseService
return false;
}
private async Task<List<FileSystemItem>> OverlayFolderSizesAsync(
Source source,
string path,
List<FileSystemItem> listing,
CancellationToken cancellationToken)
{
var rel = PathRules.MakeRelative(source.LastRootPath ?? path, path);
var indexed = await _store.Entries.GetByPathAsync(source.Id, rel, cancellationToken).ConfigureAwait(false)
?? (string.IsNullOrEmpty(rel)
? await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false)
: null);
if (indexed is null)
{
return listing;
}
var children = await _store.Entries.GetChildrenAsync(source.Id, indexed.Id, EntryStatus.Present, cancellationToken)
.ConfigureAwait(false);
var byName = children.ToDictionary(c => c.NameNorm, StringComparer.Ordinal);
return listing.Select(i =>
{
if (!i.IsDirectory || !byName.TryGetValue(NameNormalizer.Normalize(i.Name), out var e))
{
return i;
}
return new FileSystemItem
{
FullPath = i.FullPath,
Name = i.Name,
IsDirectory = true,
SizeBytes = e.AggregateSize,
CreatedUtc = i.CreatedUtc,
ModifiedUtc = i.ModifiedUtc,
Attributes = i.Attributes,
FileId = i.FileId,
ReparseTag = i.ReparseTag,
AllocatedSizeBytes = i.AllocatedSizeBytes ?? e.AllocatedSizeBytes,
Cloud = i.Cloud,
Location = i.Location,
SizeKnowledge = i.SizeKnowledge,
DisplayName = i.DisplayName,
FreeSpaceBytes = i.FreeSpaceBytes,
CapacityBytes = i.CapacityBytes
};
}).ToList();
}
private FolderListing FinishListing(string path, IReadOnlyList<FileSystemItem> items, bool isOffline, string? error)
{
var prefs = _preferences.Load();
@@ -306,73 +723,44 @@ public sealed class BrowseService
return new FolderListing { Path = path, IsOffline = isOffline, Items = visible, Error = error };
}
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences)
private FileSystemItem Annotate(FileSystemItem item, bool sizeFromIndex, UiPreferences preferences, bool probeAccess = true)
{
var looksRestricted = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory);
var accessDenied = preferences.ShowProtectedSystemLocations
var accessDenied = probeAccess
&& preferences.ShowProtectedSystemLocations
&& item.IsDirectory
&& (looksRestricted.IsProtected || looksRestricted.IsRecycleBin)
&& IsAccessDenied(item.FullPath);
var location = LocationClassifier.Classify(item.FullPath, item.Name, item.Attributes, item.IsDirectory, accessDenied);
var knowledge = LocationVisibility.ResolveSizeKnowledge(location, item.IsDirectory, item.SizeBytes, sizeFromIndex);
return new FileSystemItem
return item.Overlay(
location: location,
sizeKnowledge: knowledge,
displayName: location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
hydration: item.Hydration | ItemHydrationFlags.Location);
}
private FileSystemItem AttachVolumeSpace(FileSystemItem item, Dictionary<string, VolumeSpace> cache)
{
var key = SpaceKey(item.FullPath);
if (!cache.TryGetValue(key, out var space))
{
FullPath = item.FullPath,
Name = item.Name,
IsDirectory = item.IsDirectory,
SizeBytes = item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
AllocatedSizeBytes = item.AllocatedSizeBytes,
Cloud = item.Cloud,
Location = location,
SizeKnowledge = knowledge,
DisplayName = location.IsRecycleBin ? "Recycle Bin" : item.DisplayName,
FreeSpaceBytes = item.FreeSpaceBytes,
CapacityBytes = item.CapacityBytes
};
space = _volumes.GetSpace(item.FullPath);
cache[key] = space;
}
if (space.FreeBytes is null && space.CapacityBytes is null)
{
return item;
}
return item.Overlay(freeSpaceBytes: space.FreeBytes, capacityBytes: space.CapacityBytes);
}
private IReadOnlyList<FileSystemItem> AttachVolumeSpace(IReadOnlyList<FileSystemItem> items)
{
var cache = new Dictionary<string, VolumeSpace>(StringComparer.OrdinalIgnoreCase);
return items.Select(item =>
{
var key = SpaceKey(item.FullPath);
if (!cache.TryGetValue(key, out var space))
{
space = _volumes.GetSpace(item.FullPath);
cache[key] = space;
}
if (space.FreeBytes is null && space.CapacityBytes is null)
{
return item;
}
return new FileSystemItem
{
FullPath = item.FullPath,
Name = item.Name,
IsDirectory = item.IsDirectory,
SizeBytes = item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
AllocatedSizeBytes = item.AllocatedSizeBytes,
Cloud = item.Cloud,
Location = item.Location,
SizeKnowledge = item.SizeKnowledge,
DisplayName = item.DisplayName,
FreeSpaceBytes = space.FreeBytes,
CapacityBytes = space.CapacityBytes
};
}).ToList();
return items.Select(item => AttachVolumeSpace(item, cache)).ToList();
}
private static string SpaceKey(string path)
@@ -386,18 +774,57 @@ public sealed class BrowseService
return string.IsNullOrWhiteSpace(root) ? path : root;
}
private FolderListing ListRecycleBin(string path)
private FileSystemItem RecycleBinItem(RecycleBinSummary? summary = null)
{
var summary = _recycle?.TrySummarize(path);
var hint = summary is null
? "Recycle Bin contents are managed by Windows."
: $"{summary.ItemCount} deleted items · {summary.UsedBytes} bytes used";
if (_elevation is { IsElevated: false })
summary ??= _recycle?.TrySummarize(LocationRoots.RecycleBin);
return new FileSystemItem
{
hint += " " + _elevation.ProtectedContentHint;
FullPath = LocationRoots.RecycleBin,
Name = LocationRoots.RecycleBin,
DisplayName = LocationRoots.RecycleBin,
IsDirectory = true,
Attributes = AttributeFlags.Directory,
SizeBytes = summary?.UsedBytes ?? 0,
SizeKnowledge = summary is null ? SizeKnowledge.Unknown : SizeKnowledge.Calculated,
Location = new LocationInfo(false, false, false, false, true)
};
}
private async Task<IReadOnlyList<FileSystemItem>> UntrackedNetworkItemsAsync(CancellationToken cancellationToken)
{
var untracked = await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(false);
return untracked
.Where(fp => fp.Kind.IsNetwork())
.Select(fp =>
{
var space = _volumes.GetSpace(fp.RootPath);
return new FileSystemItem
{
FullPath = fp.RootPath,
Name = fp.DisplayName ?? fp.RootPath,
DisplayName = (fp.DisplayName ?? fp.RootPath) + " (Windows)",
IsDirectory = true,
Attributes = AttributeFlags.Directory,
FreeSpaceBytes = space.FreeBytes ?? fp.FreeBytes,
CapacityBytes = space.CapacityBytes ?? fp.CapacityBytes,
AvailableToImport = true
};
})
.ToList();
}
private static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
double value = Math.Max(0, bytes);
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return new FolderListing { Path = path, IsOffline = false, Items = [], Error = hint };
return unit == 0 ? $"{bytes} B" : $"{value:0.#} {units[unit]}";
}
private static bool IsRecycleBinPath(string path)

View File

@@ -0,0 +1,30 @@
namespace Explorer.Application;
public static class CursorLocator
{
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)
{
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(local, "Programs", "cursor", "Cursor.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "Cursor.exe");
yield return Path.Combine(directory, "cursor.exe");
}
}
}

View File

@@ -12,4 +12,7 @@
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Explorer.Application.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,251 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class FileOperationProfilePlanner
{
private readonly RenamePlanner _rename;
public FileOperationProfilePlanner(RenamePlanner rename) => _rename = rename;
public OperationPlan Build(
OperationProfile profile,
IReadOnlyList<string> sourcePaths,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
GitStatus? gitStatus,
bool gitAvailable,
bool compressAvailable,
string compressMissingHint,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
var preview = new List<ProfilePreviewRow>();
var sources = sourcePaths.Where(p => !string.IsNullOrWhiteSpace(p)).Select(p => p.Trim()).ToList();
if (sources.Count == 0)
{
return Error("Choose a source folder or drop files onto the profile.");
}
if (!profile.DoCopy && !profile.DoCompress && !profile.HasRenameRules)
{
return Error("Turn on Copy, Compress, or Rename.");
}
if (profile.RequireGitClean)
{
if (!gitAvailable)
{
return Error("git.exe is not available.");
}
if (gitStatus is null)
{
return Error("Source is not a Git repository.");
}
if (!gitStatus.WorkingTreeClean)
{
return Error("Working tree is not clean.");
}
}
var needsDest = profile.DoCopy || profile.DoCompress;
var destRoot = profile.DestPath?.Trim() ?? "";
if (needsDest)
{
if (string.IsNullOrWhiteSpace(destRoot))
{
return Error("Choose a destination folder.");
}
if (!pathReachable(destRoot) && !pathReachable(PathRules.Parent(destRoot)))
{
return Error("Destination is not available.", destRoot);
}
}
foreach (var source in sources)
{
if (!pathReachable(source))
{
return Error("Source is not available.", source);
}
}
var payload = Collect(sources, profile.Excludes, enumerator, wouldHydrate, issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
if (payload.Items.Count == 0)
{
return Error("Nothing to process after excludes.");
}
var operations = new List<PlannedOperation>();
var working = payload.Items.Select(i => i.FullPath).ToList();
if (profile.HasRenameRules)
{
var subjects = payload.Items
.Select(i => new RenameSubject(i.FullPath, i.Name, i.IsDirectory))
.ToList();
var renamePlan = _rename.Build(subjects, profile.RenameRules(), pathExists);
issues.AddRange(renamePlan.Issues);
if (renamePlan.HasErrors)
{
return new OperationPlan
{
Issues = issues,
Preview = renamePlan.Preview,
ProfilePreview = preview
};
}
foreach (var op in renamePlan.Operations)
{
operations.Add(op);
preview.Add(new ProfilePreviewRow("Rename", op.SourcePath, op.NewName));
}
working = payload.Items.Select(item =>
{
var op = renamePlan.Operations.FirstOrDefault(o =>
string.Equals(o.SourcePath, item.FullPath, StringComparison.OrdinalIgnoreCase));
return op?.DestinationPath ?? item.FullPath;
}).ToList();
if (renamePlan.Operations.Count == 0)
{
preview.Add(new ProfilePreviewRow("Rename", payload.Label, "No names change."));
}
}
if (profile.DoCompress)
{
if (!compressAvailable)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, compressMissingHint));
return new OperationPlan { Issues = issues, ProfilePreview = preview, Preview = [] };
}
var ext = profile.ArchiveFormat == ArchiveFormat.Zip ? "zip" : "7z";
var archive = PathRules.Combine(destRoot, payload.Label + "." + ext);
if (pathExists?.Invoke(archive) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "An archive with that name already exists.", archive));
return new OperationPlan { Issues = issues, ProfilePreview = preview };
}
operations.Add(new PlannedOperation(TransferOp.Compress, string.Join("|", working), archive));
preview.Add(new ProfilePreviewRow("Compress", archive, $"{working.Count} item(s)"));
}
if (profile.DoCopy)
{
var copyDest = payload.ContainerName is null
? destRoot
: PathRules.Combine(destRoot, payload.ContainerName);
foreach (var path in working)
{
var dest = PathRules.Combine(copyDest, PathRules.GetFileName(path));
operations.Add(new PlannedOperation(TransferOp.Copy, path, dest));
preview.Add(new ProfilePreviewRow("Copy", dest, null));
}
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
ProfilePreview = preview
};
}
private static Payload Collect(
IReadOnlyList<string> sources,
string excludes,
IFileSystemEnumerator enumerator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues)
{
var rules = FolderSyncPlanner.ParseExcludes(excludes);
var evaluator = new ExcludeEvaluator(rules, skipHidden: false, skipSystem: false);
var items = new List<FileSystemItem>();
string? container = null;
var label = sources.Count == 1 ? PathRules.GetFileName(sources[0]) : "archive";
if (string.IsNullOrEmpty(label))
{
label = "archive";
}
foreach (var path in sources)
{
var item = enumerator.GetItem(path);
if (item is null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Source was not found.", path));
continue;
}
if (item.IsDirectory)
{
container ??= item.Name;
var children = enumerator.EnumerateChildrenSafe(item.FullPath, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, item.FullPath));
continue;
}
var included = children
.Where(child => !evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, null))
.ToList();
if (included.Count == 0 && string.IsNullOrWhiteSpace(excludes))
{
items.Add(item);
continue;
}
foreach (var child in included)
{
Add(child, wouldHydrate, issues, items);
}
}
else if (!evaluator.ShouldExclude(item.FullPath, item.Name, false, item.Attributes, null))
{
Add(item, wouldHydrate, issues, items);
}
}
if (sources.Count != 1 || enumerator.GetItem(sources[0]) is not { IsDirectory: true })
{
container = null;
}
return new Payload(label, items, container);
}
private static void Add(
FileSystemItem item,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
List<FileSystemItem> items)
{
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
return;
}
items.Add(item);
}
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
private sealed record Payload(string Label, IReadOnlyList<FileSystemItem> Items, string? ContainerName);
}

View File

@@ -0,0 +1,71 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class FolderListingSort
{
public static bool DependsOnIncompleteMetadata(string property)
=> property is "Size" or "Free";
public static bool ShouldDeferAutoSort(string property, bool enumerationComplete, bool sizeMetadataReady)
{
if (!enumerationComplete)
{
return true;
}
if (property == "Size" && !sizeMetadataReady)
{
return true;
}
return false;
}
public static IEnumerable<FileSystemItem> Order(
IEnumerable<FileSystemItem> items,
string property,
bool descending)
{
var names = StringComparer.CurrentCultureIgnoreCase;
return property switch
{
"Size" => descending
? items.OrderByDescending(i => i.SizeBytes).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.SizeBytes).ThenBy(i => i.Name, names),
"Free" => descending
? items.OrderByDescending(i => i.FreeSpaceBytes ?? -1).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.FreeSpaceBytes ?? long.MaxValue).ThenBy(i => i.Name, names),
"Modified" => descending
? items.OrderByDescending(i => i.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.ModifiedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(TypeKey).ThenByDescending(i => i.Name, names)
: items.OrderBy(TypeKey).ThenBy(i => i.Name, names),
_ => descending
? items.OrderBy(i => i.IsDirectory).ThenByDescending(i => i.Name, names)
: items.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, names)
};
}
public static string TypeKey(FileSystemItem item)
{
if (item.AvailableToImport)
{
return "Available in Windows";
}
if (item.Location.IsRecycleBin)
{
return "Recycle Bin";
}
if (item.IsDirectory)
{
return "File folder";
}
var ext = NameNormalizer.Extension(item.Name);
return string.IsNullOrEmpty(ext) ? "File" : ext.ToUpperInvariant() + " file";
}
}

View File

@@ -0,0 +1,275 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class FolderSyncPlanner
{
public OperationPlan Build(
string sourceRoot,
string destRoot,
SyncMode mode,
string excludes,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
Func<FileSystemItem, bool>? wouldHydrate = null)
{
var issues = new List<PlanIssue>();
if (string.IsNullOrWhiteSpace(sourceRoot) || string.IsNullOrWhiteSpace(destRoot))
{
return Error("Source and destination folders are required.");
}
if (PathRules.FromExtended(sourceRoot).TrimEnd('\\')
.Equals(PathRules.FromExtended(destRoot).TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
return Error("Source and destination must be different folders.");
}
if (IsUnder(sourceRoot, destRoot) || IsUnder(destRoot, sourceRoot))
{
return Error("Source and destination cannot contain each other.");
}
if (!pathReachable(sourceRoot))
{
return Error("Source is not available.", sourceRoot);
}
var destOnline = pathReachable(destRoot) || pathReachable(PathRules.Parent(destRoot));
if (!destOnline)
{
return Error("Destination is not available.", destRoot);
}
var sourceRootItem = enumerator.GetItem(sourceRoot);
if (sourceRootItem is not { IsDirectory: true })
{
return Error("Source folder was not found.", sourceRoot);
}
var rules = ParseExcludes(excludes);
var evaluator = new ExcludeEvaluator(rules, skipHidden: false, skipSystem: false);
var sourceFiles = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var sourceDirs = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
Walk(sourceRoot, "", enumerator, evaluator, wouldHydrate, issues, sourceFiles, sourceDirs);
var destFiles = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var destDirs = new Dictionary<string, FileSystemItem>(StringComparer.OrdinalIgnoreCase);
var destRootItem = enumerator.GetItem(destRoot);
if (destRootItem is { IsDirectory: true })
{
Walk(destRoot, "", enumerator, evaluator, null, issues, destFiles, destDirs);
}
var operations = new List<PlannedOperation>();
var preview = new List<SyncPreviewRow>();
foreach (var (rel, src) in sourceFiles.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
var destPath = PathRules.Combine(destRoot, rel);
if (!destFiles.TryGetValue(rel, out var dest))
{
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Copy", null));
continue;
}
if (dest.IsDirectory)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A folder with that name already exists at the destination.", destPath));
preview.Add(new SyncPreviewRow(rel, "Error", "Destination is a folder."));
continue;
}
if (IsSame(src, dest))
{
preview.Add(new SyncPreviewRow(rel, "Skip", "Already up to date."));
continue;
}
if (IsNewer(dest, src))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Destination is newer; skipped.", destPath));
preview.Add(new SyncPreviewRow(rel, "Skip", "Destination is newer."));
continue;
}
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Update", null));
}
foreach (var (rel, src) in sourceDirs.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
if (sourceFiles.Keys.Any(f => f.StartsWith(rel + "\\", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
if (destDirs.ContainsKey(rel) || destFiles.ContainsKey(rel))
{
continue;
}
var destPath = PathRules.Combine(destRoot, rel);
operations.Add(new PlannedOperation(TransferOp.Copy, src.FullPath, destPath));
preview.Add(new SyncPreviewRow(rel, "Copy", "Empty folder"));
}
if (mode == SyncMode.Mirror)
{
foreach (var (rel, dest) in destFiles.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
if (sourceFiles.ContainsKey(rel) || evaluator.ShouldExclude(dest.FullPath, dest.Name, false, dest.Attributes, null))
{
continue;
}
operations.Add(new PlannedOperation(TransferOp.Delete, dest.FullPath, "recycle"));
preview.Add(new SyncPreviewRow(rel, "Delete", "Only on destination"));
}
foreach (var (rel, dest) in destDirs.OrderByDescending(p => p.Key.Count(c => c == '\\')))
{
if (sourceDirs.ContainsKey(rel) || sourceFiles.Keys.Any(f => f.StartsWith(rel + "\\", StringComparison.OrdinalIgnoreCase)))
{
continue;
}
operations.Add(new PlannedOperation(TransferOp.Delete, dest.FullPath, "recycle"));
preview.Add(new SyncPreviewRow(rel, "Delete", "Only on destination"));
}
}
return new OperationPlan
{
Operations = issues.Any(i => i.Severity == PlanIssueSeverity.Error) ? [] : operations,
Issues = issues,
SyncPreview = preview
};
}
public static string RemapToVolume(string path, string? volumeGuid, IReadOnlyList<Source> sources)
{
if (string.IsNullOrWhiteSpace(volumeGuid) || string.IsNullOrWhiteSpace(path))
{
return path;
}
var hits = sources
.Where(s => !string.IsNullOrEmpty(s.VolumeGuid)
&& string.Equals(s.VolumeGuid, volumeGuid, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrEmpty(s.LastRootPath))
.ToList();
if (hits.Count != 1)
{
return path;
}
var oldRoot = PathRules.VolumeRoot(path);
var rel = PathRules.MakeRelative(oldRoot, path);
return PathRules.Combine(hits[0].LastRootPath!, rel);
}
public static IReadOnlyList<ExcludeRule> ParseExcludes(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return [];
}
return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => !line.StartsWith('#'))
.Select(line => new ExcludeRule { Kind = ExcludeKind.Glob, Pattern = line, Enabled = true })
.ToList();
}
private static void Walk(
string root,
string relative,
IFileSystemEnumerator enumerator,
ExcludeEvaluator evaluator,
Func<FileSystemItem, bool>? wouldHydrate,
List<PlanIssue> issues,
Dictionary<string, FileSystemItem> files,
Dictionary<string, FileSystemItem> dirs)
{
var full = string.IsNullOrEmpty(relative) ? root : PathRules.Combine(root, relative);
var children = enumerator.EnumerateChildrenSafe(full, out var error);
if (error is not null)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, error, full));
return;
}
foreach (var child in children)
{
var rel = string.IsNullOrEmpty(relative) ? child.Name : relative + "\\" + child.Name;
if (evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, null))
{
continue;
}
if (wouldHydrate?.Invoke(child) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", child.FullPath));
continue;
}
if (child.IsDirectory)
{
if (!ReparsePolicy.ShouldRecurseIntoDirectory(child))
{
continue;
}
dirs[rel] = child;
Walk(root, rel, enumerator, evaluator, wouldHydrate, issues, files, dirs);
}
else
{
files[rel] = child;
}
}
}
private static bool IsSame(FileSystemItem source, FileSystemItem dest)
{
if (source.SizeBytes != dest.SizeBytes)
{
return false;
}
if (source.ModifiedUtc is null || dest.ModifiedUtc is null)
{
return source.SizeBytes == dest.SizeBytes;
}
return Abs(source.ModifiedUtc.Value - dest.ModifiedUtc.Value) <= AppConstants.SyncTimestampSkew;
}
private static bool IsNewer(FileSystemItem candidate, FileSystemItem other)
{
if (candidate.ModifiedUtc is null || other.ModifiedUtc is null)
{
return false;
}
return candidate.ModifiedUtc.Value - other.ModifiedUtc.Value > AppConstants.SyncTimestampSkew;
}
private static TimeSpan Abs(TimeSpan value) => value < TimeSpan.Zero ? -value : value;
private static bool IsUnder(string parent, string child)
{
var p = PathRules.FromExtended(parent).TrimEnd('\\');
var c = PathRules.FromExtended(child).TrimEnd('\\');
return c.StartsWith(p + "\\", StringComparison.OrdinalIgnoreCase);
}
private static OperationPlan Error(string message, string? path = null)
=> new()
{
Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)]
};
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Application;
public static class GitLocator
{
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
yield return Path.Combine(programFiles, "Git", "cmd", "git.exe");
yield return Path.Combine(programFiles, "Git", "bin", "git.exe");
yield return Path.Combine(programFilesX86, "Git", "cmd", "git.exe");
yield return Path.Combine(local, "Programs", "Git", "cmd", "git.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "git.exe");
}
}
}

View File

@@ -0,0 +1,98 @@
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public static class GitPorcelainParser
{
private static readonly Regex AheadBehind = new(@"\+(\d+) -(\d+)", RegexOptions.CultureInvariant);
public static GitStatus? Parse(string output, string repoRoot)
{
if (string.IsNullOrWhiteSpace(output) || string.IsNullOrWhiteSpace(repoRoot))
{
return null;
}
var branch = "HEAD";
var oid = "";
var ahead = 0;
var behind = 0;
var modified = 0;
var untracked = 0;
foreach (var raw in output.Split(["\r\n", "\n"], StringSplitOptions.None))
{
var line = raw.TrimEnd();
if (line.Length == 0)
{
continue;
}
if (line.StartsWith("# branch.head ", StringComparison.Ordinal))
{
branch = line["# branch.head ".Length..].Trim();
continue;
}
if (line.StartsWith("# branch.oid ", StringComparison.Ordinal))
{
oid = line["# branch.oid ".Length..].Trim();
continue;
}
if (line.StartsWith("# branch.ab ", StringComparison.Ordinal))
{
var match = AheadBehind.Match(line);
if (match.Success)
{
ahead = int.Parse(match.Groups[1].Value);
behind = int.Parse(match.Groups[2].Value);
}
continue;
}
if (line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("? ", StringComparison.Ordinal))
{
untracked++;
continue;
}
if (line.StartsWith("! ", StringComparison.Ordinal))
{
continue;
}
if (line.StartsWith("1 ", StringComparison.Ordinal)
|| line.StartsWith("2 ", StringComparison.Ordinal)
|| line.StartsWith("u ", StringComparison.Ordinal))
{
modified++;
}
}
if (branch.Equals("(detached)", StringComparison.OrdinalIgnoreCase))
{
branch = oid.Length >= 7 ? oid[..7] : "detached";
}
else if (string.IsNullOrWhiteSpace(branch))
{
branch = "HEAD";
}
return new GitStatus
{
RepoRoot = repoRoot,
Branch = branch,
ModifiedCount = modified,
UntrackedCount = untracked,
Ahead = ahead,
Behind = behind
};
}
}

View File

@@ -0,0 +1,53 @@
using Explorer.Domain;
namespace Explorer.Application;
public static class GitRepoDetector
{
public static bool IsRepoRoot(string path, Func<string, bool>? directoryExists = null, Func<string, bool>? fileExists = null)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return false;
}
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var git = Path.Combine(PathRules.FromExtended(path), ".git");
return directoryExists(git) || fileExists(git);
}
public static string? FindRoot(string path, Func<string, bool>? directoryExists = null, Func<string, bool>? fileExists = null)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return null;
}
directoryExists ??= Directory.Exists;
fileExists ??= File.Exists;
var current = PathRules.FromExtended(path);
if (fileExists(current) && !directoryExists(current))
{
current = PathRules.Parent(current);
}
while (!string.IsNullOrEmpty(current))
{
if (IsRepoRoot(current, directoryExists, fileExists))
{
return current;
}
var parent = PathRules.Parent(current);
if (string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
{
break;
}
current = parent;
}
return null;
}
}

View File

@@ -0,0 +1,35 @@
using Explorer.Domain;
namespace Explorer.Application;
public sealed record ArchiveProgress(int Percent, long FilesDone, string? CurrentPath);
public interface IArchiveExecutor
{
bool IsAvailable { get; }
string MissingHint { get; }
Task ExtractAsync(
string archivePath,
string destinationDirectory,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task CompressAsync(
IReadOnlyList<string> sources,
string archivePath,
ArchiveFormat format,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task AddAsync(
string archivePath,
IReadOnlyList<string> sources,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
Task VerifyAsync(
string archivePath,
IProgress<ArchiveProgress>? progress,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,17 @@
using Explorer.Domain;
namespace Explorer.Application;
public interface IGitStatusProvider
{
bool IsAvailable { get; }
string? FindRepoRoot(string path);
bool IsRepoRoot(string path);
Task<GitStatus?> GetStatusAsync(string path, CancellationToken cancellationToken = default);
}
public interface IWorkspaceLauncher
{
void OpenTerminal(string directory);
bool TryOpenInCursor(string directory);
}

View File

@@ -1,13 +1,17 @@
namespace Explorer.Application;
public sealed record RecycleBinSummary(int ItemCount, long UsedBytes);
public sealed record RecycleBinSummary(long ItemCount, long UsedBytes)
{
public static RecycleBinSummary FromQuery(long usedBytes, long itemCount)
=> new(Math.Max(0, itemCount), Math.Max(0, usedBytes));
}
public interface IRecycleBinCatalog
{
RecycleBinSummary? TrySummarize(string recycleBinPath);
RecycleBinSummary? TrySummarize(string? rootPath = null);
}
public sealed class RecycleBinCatalog : IRecycleBinCatalog
{
public RecycleBinSummary? TrySummarize(string recycleBinPath) => null;
public RecycleBinSummary? TrySummarize(string? rootPath = null) => null;
}

View File

@@ -6,6 +6,11 @@ public static class LocationVisibility
{
public static bool ShouldShow(LocationInfo info, UiPreferences preferences)
{
if (info.IsRecycleBin)
{
return false;
}
if (info.IsProtected)
{
return preferences.ShowProtectedSystemLocations;

View File

@@ -0,0 +1,63 @@
namespace Explorer.Application;
public sealed class LruCache<TKey, TValue>
where TKey : notnull
{
private readonly int _capacity;
private readonly Dictionary<TKey, LinkedListNode<(TKey Key, TValue Value)>> _map;
private readonly LinkedList<(TKey Key, TValue Value)> _order;
private readonly object _gate = new();
public LruCache(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_capacity = capacity;
_map = new Dictionary<TKey, LinkedListNode<(TKey, TValue)>>(capacity);
_order = new LinkedList<(TKey, TValue)>();
}
public int Count
{
get { lock (_gate) return _map.Count; }
}
public bool TryGet(TKey key, out TValue value)
{
lock (_gate)
{
if (_map.TryGetValue(key, out var node))
{
_order.Remove(node);
_order.AddFirst(node);
value = node.Value.Value;
return true;
}
}
value = default!;
return false;
}
public void Set(TKey key, TValue value)
{
lock (_gate)
{
if (_map.TryGetValue(key, out var existing))
{
_order.Remove(existing);
existing.Value = (key, value);
_order.AddFirst(existing);
return;
}
var node = _order.AddFirst((key, value));
_map[key] = node;
while (_map.Count > _capacity)
{
var last = _order.Last!;
_order.RemoveLast();
_map.Remove(last.Value.Key);
}
}
}
}

View File

@@ -0,0 +1,258 @@
namespace Explorer.Application;
public sealed record MarkdownDocument(IReadOnlyList<MarkdownBlock> Blocks, IReadOnlyList<MarkdownHeading> Headings);
public abstract record MarkdownBlock;
public sealed record MarkdownHeading(int Level, string Text, string Id) : MarkdownBlock;
public sealed record MarkdownParagraph(string Text) : MarkdownBlock;
public sealed record MarkdownList(bool Ordered, IReadOnlyList<string> Items) : MarkdownBlock;
public sealed record MarkdownCode(string Language, string Text) : MarkdownBlock;
public sealed record MarkdownTable(IReadOnlyList<string> Headers, IReadOnlyList<IReadOnlyList<string>> Rows) : MarkdownBlock;
public sealed record MarkdownRule : MarkdownBlock;
public sealed record MarkdownQuote(string Text) : MarkdownBlock;
public static class MarkdownParser
{
public static MarkdownDocument Parse(string text)
{
var lines = (text ?? "").Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
var blocks = new List<MarkdownBlock>();
var headings = new List<MarkdownHeading>();
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
while (i < lines.Length)
{
var raw = lines[i];
if (string.IsNullOrWhiteSpace(raw))
{
i++;
continue;
}
if (raw.StartsWith("```", StringComparison.Ordinal))
{
var lang = raw[3..].Trim();
i++;
var body = new List<string>();
while (i < lines.Length && !lines[i].StartsWith("```", StringComparison.Ordinal))
{
body.Add(lines[i]);
i++;
}
if (i < lines.Length)
{
i++;
}
blocks.Add(new MarkdownCode(lang, string.Join("\n", body)));
continue;
}
if (IsRule(raw))
{
blocks.Add(new MarkdownRule());
i++;
continue;
}
var heading = ParseHeading(raw);
if (heading is not null)
{
var id = UniqueId(Slug(heading.Text), ids);
var block = heading with { Id = id };
blocks.Add(block);
if (block.Level is 2 or 3)
{
headings.Add(block);
}
i++;
continue;
}
if (raw.TrimStart().StartsWith('|'))
{
var table = ReadTable(lines, ref i);
if (table is not null)
{
blocks.Add(table);
continue;
}
}
if (LooksLikeList(raw, out var ordered))
{
var items = new List<string>();
while (i < lines.Length && LooksLikeList(lines[i], out var next) && next == ordered)
{
items.Add(ListText(lines[i]));
i++;
}
blocks.Add(new MarkdownList(ordered, items));
continue;
}
if (raw.StartsWith("> ", StringComparison.Ordinal) || raw == ">")
{
var quote = new List<string>();
while (i < lines.Length && (lines[i].StartsWith("> ", StringComparison.Ordinal) || lines[i] == ">"))
{
quote.Add(lines[i].StartsWith("> ", StringComparison.Ordinal) ? lines[i][2..] : "");
i++;
}
blocks.Add(new MarkdownQuote(string.Join(" ", quote)));
continue;
}
var para = new List<string> { raw.Trim() };
i++;
while (i < lines.Length
&& !string.IsNullOrWhiteSpace(lines[i])
&& ParseHeading(lines[i]) is null
&& !IsRule(lines[i])
&& !lines[i].StartsWith("```", StringComparison.Ordinal)
&& !LooksLikeList(lines[i], out _)
&& !lines[i].TrimStart().StartsWith('|'))
{
para.Add(lines[i].Trim());
i++;
}
blocks.Add(new MarkdownParagraph(string.Join(" ", para)));
}
return new MarkdownDocument(blocks, headings);
}
private static MarkdownHeading? ParseHeading(string line)
{
if (!line.StartsWith('#') || line.Length < 3)
{
return null;
}
var level = 0;
while (level < line.Length && line[level] == '#' && level < 6)
{
level++;
}
if (level == 0 || level >= line.Length || line[level] != ' ')
{
return null;
}
return new MarkdownHeading(level, line[(level + 1)..].Trim(), "");
}
private static MarkdownTable? ReadTable(string[] lines, ref int i)
{
var header = SplitRow(lines[i]);
if (header.Count == 0)
{
return null;
}
i++;
if (i >= lines.Length || !IsAlignmentRow(lines[i]))
{
return new MarkdownTable(header, []);
}
i++;
var rows = new List<IReadOnlyList<string>>();
while (i < lines.Length && lines[i].TrimStart().StartsWith('|'))
{
rows.Add(SplitRow(lines[i]));
i++;
}
return new MarkdownTable(header, rows);
}
private static List<string> SplitRow(string line)
{
var trimmed = line.Trim();
if (trimmed.StartsWith('|'))
{
trimmed = trimmed[1..];
}
if (trimmed.EndsWith('|'))
{
trimmed = trimmed[..^1];
}
return trimmed.Split('|').Select(c => c.Trim()).ToList();
}
private static bool IsAlignmentRow(string line)
=> line.Contains('|') && line.All(c => c is '|' or ':' or '-' or ' ' or '\t');
private static bool IsRule(string line)
{
var t = line.Trim();
return t is "---" or "***" or "___" || (t.Length >= 3 && t.All(c => c == '-' || c == '*' || c == '_'));
}
private static bool LooksLikeList(string line, out bool ordered)
{
ordered = false;
var t = line.TrimStart();
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
{
return true;
}
var dot = t.IndexOf(". ", StringComparison.Ordinal);
if (dot > 0 && t[..dot].All(char.IsDigit))
{
ordered = true;
return true;
}
return false;
}
private static string ListText(string line)
{
var t = line.TrimStart();
if (t.StartsWith("- ", StringComparison.Ordinal) || t.StartsWith("* ", StringComparison.Ordinal))
{
return t[2..].Trim();
}
var dot = t.IndexOf(". ", StringComparison.Ordinal);
return dot > 0 ? t[(dot + 2)..].Trim() : t;
}
private static string Slug(string text)
{
var chars = text.Where(c => char.IsLetterOrDigit(c) || c is ' ' or '-').ToArray();
return new string(chars).Trim().Replace(' ', '-').ToLowerInvariant();
}
private static string UniqueId(string slug, HashSet<string> ids)
{
var id = string.IsNullOrEmpty(slug) ? "section" : slug;
var n = 2;
var candidate = id;
while (!ids.Add(candidate))
{
candidate = id + "-" + n;
n++;
}
return candidate;
}
}

View File

@@ -0,0 +1,288 @@
using System.Text.RegularExpressions;
using Explorer.Domain;
namespace Explorer.Application;
public sealed class RenamePlanner
{
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(250);
public OperationPlan Build(
IReadOnlyList<RenameSubject> subjects,
RenameRuleSet rules,
Func<string, bool>? pathExists = null)
{
var issues = new List<PlanIssue>();
Regex? regex = null;
if (rules.UseRegex && !string.IsNullOrEmpty(rules.Search))
{
try
{
var options = RegexOptions.CultureInvariant;
if (!rules.MatchCase)
{
options |= RegexOptions.IgnoreCase;
}
regex = new Regex(rules.Search, options, RegexTimeout);
}
catch (ArgumentException ex)
{
return new OperationPlan
{
Issues = [new PlanIssue(PlanIssueSeverity.Error, "Invalid regular expression: " + ex.Message)]
};
}
}
var rows = new List<(RenameSubject Subject, string NewName, string Dest, bool Unchanged, string? Status)>();
var index = 0;
foreach (var subject in subjects)
{
string newName;
try
{
newName = Apply(subject.Name, rules, index, regex);
}
catch (RegexMatchTimeoutException)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Search pattern took too long.", subject.FullPath));
rows.Add((subject, subject.Name, subject.FullPath, true, "Search pattern took too long."));
index++;
continue;
}
index++;
if (!WindowsFileNames.IsValid(newName, out var invalid))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, invalid ?? "Invalid name.", subject.FullPath));
rows.Add((subject, newName, PathRules.Combine(PathRules.Parent(subject.FullPath), newName), false, invalid));
continue;
}
var dest = PathRules.Combine(PathRules.Parent(subject.FullPath), newName);
var unchanged = dest.Equals(subject.FullPath, StringComparison.OrdinalIgnoreCase)
&& newName.Equals(subject.Name, StringComparison.Ordinal);
rows.Add((subject, newName, dest, unchanged, unchanged ? "Unchanged" : null));
}
var changed = rows.Where(r => !r.Unchanged && r.Status is null).ToList();
foreach (var clash in changed.GroupBy(r => DestKey(r.Dest), StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1))
{
foreach (var row in clash)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Two files would get the same name.", row.Subject.FullPath));
}
}
var moving = new HashSet<string>(changed.Select(r => r.Subject.FullPath), StringComparer.OrdinalIgnoreCase);
if (pathExists is not null)
{
foreach (var row in changed)
{
if (moving.Contains(row.Dest))
{
continue;
}
if (pathExists(row.Dest))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", row.Subject.FullPath));
}
}
}
foreach (var row in changed)
{
if (!moving.Contains(row.Dest)
&& rows.Any(other => other.Unchanged
&& other.Dest.Equals(row.Dest, StringComparison.OrdinalIgnoreCase)))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", row.Subject.FullPath));
}
}
var preview = rows.Select(r =>
{
var status = r.Status;
if (status is null && issues.Any(i => i.Path == r.Subject.FullPath && i.Severity == PlanIssueSeverity.Error))
{
status = issues.First(i => i.Path == r.Subject.FullPath).Message;
}
return new RenamePreviewRow(r.Subject.FullPath, r.Subject.Name, r.NewName, status, r.Unchanged);
}).ToList();
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, Preview = preview };
}
var ordered = Order(changed.Select(r => (r.Subject.FullPath, r.Dest, r.NewName)).ToList(), issues);
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, Preview = preview };
}
return new OperationPlan
{
Operations = ordered.Select(r => new PlannedOperation(TransferOp.Rename, r.Source, r.Dest, r.NewName)).ToList(),
Issues = issues,
Preview = preview
};
}
public OperationPlan BuildUndo(RenameBatch batch, Func<string, bool> pathExists)
{
var issues = new List<PlanIssue>();
var operations = new List<PlannedOperation>();
foreach (var item in batch.Items.OrderByDescending(i => i.SortOrder))
{
if (!pathExists(item.NewPath))
{
continue;
}
if (pathExists(item.OldPath)
&& !item.OldPath.Equals(item.NewPath, StringComparison.OrdinalIgnoreCase))
{
issues.Add(new PlanIssue(
PlanIssueSeverity.Error,
"Original name is already taken.",
item.OldPath));
continue;
}
operations.Add(new PlannedOperation(
TransferOp.Rename,
item.NewPath,
item.OldPath,
PathRules.GetFileName(item.OldPath)));
}
if (issues.Count > 0)
{
return new OperationPlan { Issues = issues };
}
if (operations.Count == 0)
{
var pending = batch.Items.Any(i =>
pathExists(i.OldPath)
&& !pathExists(i.NewPath)
&& !i.OldPath.Equals(i.NewPath, StringComparison.OrdinalIgnoreCase));
return new OperationPlan
{
Issues =
[
pending
? new PlanIssue(PlanIssueSeverity.Error, "Rename has not finished yet.")
: new PlanIssue(PlanIssueSeverity.Warning, "Nothing left to undo.")
]
};
}
return new OperationPlan { Operations = operations };
}
internal static string Apply(string name, RenameRuleSet rules, int index, Regex? regex)
{
var (stem, extension) = WindowsFileNames.Split(name);
var text = rules.IncludeExtensionInSearch ? name : stem;
text = Replace(text, rules, regex);
text = (rules.Prefix ?? "") + text + (rules.Suffix ?? "");
var counter = rules.UseCounter
? WindowsFileNames.FormatCounter(rules.CounterStart + index * Math.Max(1, rules.CounterStep), rules.CounterPadding)
: "";
if (text.Contains("{Counter}", StringComparison.OrdinalIgnoreCase))
{
text = Regex.Replace(text, "\\{Counter\\}", counter, RegexOptions.IgnoreCase);
}
else if (rules.UseCounter)
{
text += counter;
}
text = WindowsFileNames.ApplyCase(text, rules.CaseMode);
text = text.Replace("{Extension}", extension, StringComparison.OrdinalIgnoreCase);
if (rules.IncludeExtensionInSearch && !rules.ChangeExtension)
{
return text;
}
var newExt = rules.ChangeExtension ? (rules.NewExtension ?? "").Trim().TrimStart('.') : extension;
return WindowsFileNames.Join(text, newExt);
}
private static string Replace(string text, RenameRuleSet rules, Regex? regex)
{
if (string.IsNullOrEmpty(rules.Search))
{
return text;
}
if (regex is not null)
{
return regex.Replace(text, rules.Replace ?? "");
}
var comparison = rules.MatchCase ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
var search = rules.Search;
var replace = rules.Replace ?? "";
var start = 0;
while (start <= text.Length - search.Length)
{
var found = text.IndexOf(search, start, comparison);
if (found < 0)
{
break;
}
text = text[..found] + replace + text[(found + search.Length)..];
start = found + replace.Length;
if (search.Length == 0)
{
break;
}
}
return text;
}
private static string DestKey(string path)
=> PathRules.FromExtended(path);
internal static List<(string Source, string Dest, string NewName)> Order(
List<(string Source, string Dest, string NewName)> items,
List<PlanIssue> issues)
{
if (items.Count <= 1)
{
return items;
}
var comparer = StringComparer.OrdinalIgnoreCase;
var remaining = items.ToList();
var ordered = new List<(string Source, string Dest, string NewName)>();
var sources = remaining.Select(i => i.Source).ToHashSet(comparer);
while (remaining.Count > 0)
{
var ready = remaining.Where(i => !sources.Contains(i.Dest) || comparer.Equals(i.Dest, i.Source)).ToList();
if (ready.Count == 0)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Rename cycle: two files would swap names that cannot be applied in order."));
return items;
}
foreach (var item in ready)
{
ordered.Add(item);
remaining.Remove(item);
sources.Remove(item.Source);
}
}
return ordered;
}
}

View File

@@ -0,0 +1,192 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application;
public sealed class ReorganizePlanner
{
public static readonly TimeSpan OldInstallerAge = TimeSpan.FromDays(365);
public OperationPlan Build(
string sourceRoot,
OrganizeDestinations destinations,
IFileSystemEnumerator enumerator,
Func<string, bool> pathReachable,
Func<string, bool> isRepoRoot,
Func<FileSystemItem, bool>? wouldHydrate = null,
Func<string, bool>? pathExists = null,
DateTimeOffset? now = null)
{
if (string.IsNullOrWhiteSpace(sourceRoot))
{
return Error("Choose a folder to organize.");
}
if (!pathReachable(sourceRoot))
{
return Error("Source is not available.", sourceRoot);
}
var root = enumerator.GetItem(sourceRoot);
if (root is null || !root.IsDirectory)
{
return Error("Source was not found.", sourceRoot);
}
var children = enumerator.EnumerateChildrenSafe(sourceRoot, out var error);
if (error is not null)
{
return Error(error, sourceRoot);
}
var issues = new List<PlanIssue>();
var operations = new List<PlannedOperation>();
var preview = new List<OrganizePreviewRow>();
var utc = now ?? DateTimeOffset.UtcNow;
var destTaken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in children)
{
if (item.IsReparsePoint && item.IsDirectory)
{
preview.Add(Row(item, FileCategory.Unknown, "Skip", null, "Reparse point left untouched."));
continue;
}
if (wouldHydrate?.Invoke(item) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Online-only cloud file skipped.", item.FullPath));
preview.Add(Row(item, FileCategory.Unknown, "Skip", null, "Online-only cloud file skipped."));
continue;
}
var childCategories = item.IsDirectory && !FileClassifier.SkipDescent(item.Name)
? ChildCategories(item, enumerator, isRepoRoot)
: null;
var classification = FileClassifier.Classify(
item.Name,
item.FullPath,
item.IsDirectory,
item.Attributes,
item.IsDirectory && isRepoRoot(item.FullPath),
childCategories);
if (FileClassifier.ShouldLeave(classification.Category))
{
preview.Add(Row(item, classification.Category, "Skip", null, classification.Reason));
continue;
}
var destRoot = destinations.For(classification.Category);
if (destRoot is null)
{
preview.Add(Row(item, classification.Category, "Skip", null, "No destination for " + OrganizeDestinations.Label(classification.Category).ToLowerInvariant() + "."));
continue;
}
if (Same(sourceRoot, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Destination is the folder being organized."));
continue;
}
if (Same(item.FullPath, destRoot) || SameOrUnder(item.FullPath, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Would move the destination into itself."));
continue;
}
if (!pathReachable(destRoot) && !pathReachable(PathRules.Parent(destRoot)))
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "Destination is not available.", destRoot));
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Destination is not available."));
continue;
}
if (AlreadyThere(item.FullPath, destRoot))
{
preview.Add(Row(item, classification.Category, "Skip", destRoot, "Already in the destination folder."));
continue;
}
var dest = PathRules.Combine(destRoot, item.Name);
var detail = classification.Reason;
if (classification.Category == FileCategory.Installer
&& item.ModifiedUtc is { } modified
&& utc - modified >= OldInstallerAge)
{
detail = "Older than 1 year.";
issues.Add(new PlanIssue(PlanIssueSeverity.Warning, "Old installer.", item.FullPath));
}
if (!destTaken.Add(dest) || pathExists?.Invoke(dest) == true)
{
issues.Add(new PlanIssue(PlanIssueSeverity.Error, "A file with that name already exists.", dest));
preview.Add(Row(item, classification.Category, "Skip", dest, "A file with that name already exists."));
continue;
}
operations.Add(new PlannedOperation(TransferOp.Move, item.FullPath, dest));
preview.Add(Row(item, classification.Category, "Move", dest, detail));
}
if (issues.Any(i => i.Severity == PlanIssueSeverity.Error))
{
return new OperationPlan { Issues = issues, OrganizePreview = preview };
}
return new OperationPlan
{
Operations = operations,
Issues = issues,
OrganizePreview = preview
};
}
private static IReadOnlyList<FileCategory> ChildCategories(
FileSystemItem folder,
IFileSystemEnumerator enumerator,
Func<string, bool> isRepoRoot)
{
var children = enumerator.EnumerateChildrenSafe(folder.FullPath, out var error);
if (error is not null)
{
return [];
}
return children.Select(child => FileClassifier.Classify(
child.Name,
child.FullPath,
child.IsDirectory,
child.Attributes,
child.IsDirectory && isRepoRoot(child.FullPath)).Category).ToList();
}
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 AlreadyThere(string sourcePath, string destRoot)
=> Same(PathRules.Parent(sourcePath), destRoot);
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 OrganizePreviewRow Row(
FileSystemItem item,
FileCategory category,
string action,
string? destination,
string? detail)
=> new(item.Name, OrganizeDestinations.Label(category), action, destination, detail);
private static OperationPlan Error(string message, string? path = null)
=> new() { Issues = [new PlanIssue(PlanIssueSeverity.Error, message, path)] };
}

View File

@@ -0,0 +1,37 @@
namespace Explorer.Application;
public static class SevenZipLocator
{
public const string MissingHint = "7-Zip is not installed. Install 7-Zip or set its path in Settings.";
public static string? Find(string? configuredPath, Func<string, bool>? fileExists = null, string? pathVariable = null)
{
fileExists ??= File.Exists;
if (!string.IsNullOrWhiteSpace(configuredPath) && fileExists(configuredPath.Trim()))
{
return configuredPath.Trim();
}
foreach (var candidate in Candidates(pathVariable))
{
if (fileExists(candidate))
{
return candidate;
}
}
return null;
}
public static IEnumerable<string> Candidates(string? pathVariable = null)
{
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "7-Zip", "7z.exe");
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "7-Zip", "7z.exe");
var path = pathVariable ?? Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
yield return Path.Combine(directory, "7z.exe");
yield return Path.Combine(directory, "7za.exe");
}
}
}

View File

@@ -64,6 +64,10 @@ public sealed class SourceManager
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
}
}
else if (fp.Kind.IsNetwork())
{
continue;
}
else
{
source = new Source
@@ -302,6 +306,29 @@ public sealed class SourceManager
return created;
}
public async Task<IReadOnlyList<VolumeFingerprint>> ListUntrackedOnlineVolumesAsync(CancellationToken cancellationToken = default)
{
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var untracked = new List<VolumeFingerprint>();
foreach (var fp in _volumes.EnumerateOnlineVolumes())
{
var match = VolumeIdentityMatcher.Match(fp, known);
if (match.Source is not null && !match.Ambiguous)
{
continue;
}
if (known.Any(s => s.LastRootPath is not null && RootsEqual(s.LastRootPath, fp.RootPath)))
{
continue;
}
untracked.Add(fp);
}
return untracked;
}
private async Task<Source?> FindSourceRootAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))

View File

@@ -188,25 +188,34 @@ public sealed class StorageProviderRegistry
return null;
}
}
public async Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
{
var provider = Find(rootPath);
if (provider is null)
{
return null;
}
try
{
return await provider.TryGetQuotaAsync(rootPath, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Provider {Id} failed reading quota for {Path}", provider.Manifest.Id, rootPath);
return null;
}
}
}
public static class CloudPresenceMapper
{
public static FileSystemItem Apply(FileSystemItem item, ProviderItemState state)
=> new()
{
FullPath = item.FullPath,
Name = item.Name,
IsDirectory = item.IsDirectory,
SizeBytes = item.SizeBytes,
CreatedUtc = item.CreatedUtc,
ModifiedUtc = item.ModifiedUtc,
Attributes = item.Attributes,
FileId = item.FileId,
ReparseTag = item.ReparseTag,
AllocatedSizeBytes = state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
Cloud = ToPresence(state)
};
=> item.Overlay(
allocatedSizeBytes: state.AllocatedSizeBytes ?? item.AllocatedSizeBytes,
cloud: ToPresence(state),
hydration: item.Hydration | ItemHydrationFlags.Provider);
public static CloudPresence ToPresence(ProviderItemState state)
=> new(

View File

@@ -0,0 +1,233 @@
namespace Explorer.Application;
public readonly record struct ThumbnailKey(string Path, long ModifiedTicks, int PixelWidth)
{
public static ThumbnailKey From(string path, DateTimeOffset? modifiedUtc, int pixelWidth)
=> new(path, modifiedUtc?.UtcTicks ?? 0, pixelWidth);
}
public readonly record struct ThumbnailJob(
ThumbnailKey Key,
int Rank,
int Generation);
public readonly record struct ThumbnailSchedulerStats(
int Generation,
int Window,
int Pending,
int InFlight,
int Requested,
int CacheHits,
int Generated,
int Cancelled,
int Failed);
public sealed class ThumbnailScheduler
{
public const int DefaultConcurrency = 2;
public const int DefaultPixelWidth = 128;
public const int MaxPending = 96;
public const int PrefetchItems = 24;
private readonly object _gate = new();
private readonly List<ThumbnailJob> _window = [];
private readonly HashSet<ThumbnailKey> _windowKeys = [];
private readonly HashSet<ThumbnailKey> _inFlight = [];
private readonly HashSet<ThumbnailKey> _cached = [];
private int _generation;
private bool _enabled;
private int _requested;
private int _cacheHits;
private int _generated;
private int _cancelled;
private int _failed;
public int Generation
{
get { lock (_gate) return _generation; }
}
public int InFlight
{
get { lock (_gate) return _inFlight.Count; }
}
public void Reset(int generation)
{
lock (_gate)
{
_cancelled += _window.Count + _inFlight.Count;
_generation = generation;
_window.Clear();
_windowKeys.Clear();
_inFlight.Clear();
}
}
public void SetEnabled(bool enabled)
{
lock (_gate)
{
_enabled = enabled;
if (enabled)
{
return;
}
_cancelled += _window.Count + _inFlight.Count;
_window.Clear();
_windowKeys.Clear();
_inFlight.Clear();
}
}
public bool IsCached(ThumbnailKey key)
{
lock (_gate)
{
return _cached.Contains(key);
}
}
public void MarkCached(ThumbnailKey key)
{
lock (_gate)
{
_cached.Add(key);
}
}
public void RecordCacheHit()
{
lock (_gate)
{
_cacheHits++;
_requested++;
}
}
public IReadOnlyList<ThumbnailJob> SetWindow(int generation, IReadOnlyList<ThumbnailJob> jobs)
{
ArgumentNullException.ThrowIfNull(jobs);
lock (_gate)
{
if (generation != _generation || !_enabled)
{
_cancelled += jobs.Count;
return [];
}
var next = jobs
.Where(j => j.Generation == _generation)
.GroupBy(j => j.Key)
.Select(g => g.OrderBy(j => j.Rank).First())
.OrderBy(j => j.Rank)
.Take(MaxPending)
.ToList();
var nextKeys = next.Select(j => j.Key).ToHashSet();
foreach (var previous in _window)
{
if (!nextKeys.Contains(previous.Key))
{
_cancelled++;
}
}
foreach (var key in _inFlight.ToArray())
{
if (!nextKeys.Contains(key))
{
_inFlight.Remove(key);
_cancelled++;
}
}
_window.Clear();
_window.AddRange(next);
_windowKeys.Clear();
foreach (var job in next)
{
_windowKeys.Add(job.Key);
}
_requested += next.Count(j => !_cached.Contains(j.Key) && !_inFlight.Contains(j.Key));
return next;
}
}
public bool TryTake(out ThumbnailJob job)
{
lock (_gate)
{
if (!_enabled)
{
job = default;
return false;
}
foreach (var candidate in _window)
{
if (_cached.Contains(candidate.Key) || _inFlight.Contains(candidate.Key))
{
continue;
}
_inFlight.Add(candidate.Key);
job = candidate;
return true;
}
}
job = default;
return false;
}
public bool IsCurrent(ThumbnailJob job)
{
lock (_gate)
{
return _enabled
&& job.Generation == _generation
&& _windowKeys.Contains(job.Key);
}
}
public void Complete(ThumbnailJob job, bool generated, bool failed)
{
lock (_gate)
{
_inFlight.Remove(job.Key);
if (failed)
{
_failed++;
_cached.Add(job.Key);
return;
}
if (generated)
{
_generated++;
_cached.Add(job.Key);
}
}
}
public ThumbnailSchedulerStats Snapshot()
{
lock (_gate)
{
var pending = _window.Count(j => !_cached.Contains(j.Key) && !_inFlight.Contains(j.Key));
return new ThumbnailSchedulerStats(
_generation,
_window.Count,
pending,
_inFlight.Count,
_requested,
_cacheHits,
_generated,
_cancelled,
_failed);
}
}
}

View File

@@ -15,7 +15,16 @@ public sealed record UiPreferences(
double? WindowLeft = null,
double? WindowTop = null,
bool WindowMaximized = false,
double? TreeWidth = null)
double? TreeWidth = null,
string? SevenZipPath = null,
string? GitPath = null,
string? OrganizePictures = null,
string? OrganizeVideos = null,
string? OrganizeAudio = null,
string? OrganizeDocuments = null,
string? OrganizeInstallers = null,
string? OrganizeArchives = null,
string? OrganizeDevelopment = null)
{
public static UiPreferences Default { get; } = new("Dark", false, false, false, true, false);
}
@@ -60,6 +69,9 @@ public sealed class UiPreferencesStore
"show-hidden=" + (preferences.ShowHiddenFiles ? "true" : "false"),
"show-protected=" + (preferences.ShowProtectedSystemLocations ? "true" : "false"),
"auto-clear-queue=" + (preferences.AutoClearQueueWhenDone ? "true" : "false"),
.. SevenZipLines(preferences),
.. GitLines(preferences),
.. OrganizeLines(preferences),
.. LayoutLines(preferences)
]);
}
@@ -78,6 +90,15 @@ public sealed class UiPreferencesStore
var showHidden = true;
var showProtected = false;
var autoClearQueue = false;
string? sevenZipPath = null;
string? gitPath = null;
string? organizePictures = null;
string? organizeVideos = null;
string? organizeAudio = null;
string? organizeDocuments = null;
string? organizeInstallers = null;
string? organizeArchives = null;
string? organizeDevelopment = null;
double? windowWidth = null;
double? windowHeight = null;
double? windowLeft = null;
@@ -128,6 +149,42 @@ public sealed class UiPreferencesStore
{
autoClearQueue = IsTrue(value);
}
else if (key.Equals("seven-zip", StringComparison.OrdinalIgnoreCase))
{
sevenZipPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("git", StringComparison.OrdinalIgnoreCase))
{
gitPath = string.IsNullOrWhiteSpace(value) ? null : value;
}
else if (key.Equals("organize-pictures", StringComparison.OrdinalIgnoreCase))
{
organizePictures = EmptyToNull(value);
}
else if (key.Equals("organize-videos", StringComparison.OrdinalIgnoreCase))
{
organizeVideos = EmptyToNull(value);
}
else if (key.Equals("organize-audio", StringComparison.OrdinalIgnoreCase))
{
organizeAudio = EmptyToNull(value);
}
else if (key.Equals("organize-documents", StringComparison.OrdinalIgnoreCase))
{
organizeDocuments = EmptyToNull(value);
}
else if (key.Equals("organize-installers", StringComparison.OrdinalIgnoreCase))
{
organizeInstallers = EmptyToNull(value);
}
else if (key.Equals("organize-archives", StringComparison.OrdinalIgnoreCase))
{
organizeArchives = EmptyToNull(value);
}
else if (key.Equals("organize-development", StringComparison.OrdinalIgnoreCase))
{
organizeDevelopment = EmptyToNull(value);
}
else if (key.Equals("window-width", StringComparison.OrdinalIgnoreCase))
{
windowWidth = ParseDouble(value);
@@ -156,7 +213,63 @@ public sealed class UiPreferencesStore
return new UiPreferences(
theme, groupNetwork, groupCloud, indexArchives, showHidden, showProtected, autoClearQueue,
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth);
windowWidth, windowHeight, windowLeft, windowTop, windowMaximized, treeWidth, sevenZipPath, gitPath,
organizePictures, organizeVideos, organizeAudio, organizeDocuments, organizeInstallers, organizeArchives,
organizeDevelopment);
}
private static IEnumerable<string> SevenZipLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.SevenZipPath))
{
yield return "seven-zip=" + preferences.SevenZipPath;
}
}
private static IEnumerable<string> GitLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.GitPath))
{
yield return "git=" + preferences.GitPath;
}
}
private static IEnumerable<string> OrganizeLines(UiPreferences preferences)
{
if (!string.IsNullOrWhiteSpace(preferences.OrganizePictures))
{
yield return "organize-pictures=" + preferences.OrganizePictures;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeVideos))
{
yield return "organize-videos=" + preferences.OrganizeVideos;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeAudio))
{
yield return "organize-audio=" + preferences.OrganizeAudio;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeDocuments))
{
yield return "organize-documents=" + preferences.OrganizeDocuments;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeInstallers))
{
yield return "organize-installers=" + preferences.OrganizeInstallers;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeArchives))
{
yield return "organize-archives=" + preferences.OrganizeArchives;
}
if (!string.IsNullOrWhiteSpace(preferences.OrganizeDevelopment))
{
yield return "organize-development=" + preferences.OrganizeDevelopment;
}
}
private static IEnumerable<string> LayoutLines(UiPreferences preferences)
@@ -203,6 +316,9 @@ public sealed class UiPreferencesStore
public static string NormalizeTheme(string? theme)
=> theme is not null && theme.Equals("Light", StringComparison.OrdinalIgnoreCase) ? "Light" : "Dark";
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
private static bool IsTrue(string value)
=> value.Equals("true", StringComparison.OrdinalIgnoreCase)
|| value.Equals("1", StringComparison.OrdinalIgnoreCase)

View File

@@ -15,6 +15,10 @@ public interface IIndexStore
IAnalysisStore Analysis { get; }
IHistoryStore History { get; }
IHashStore Hashes { get; }
IFileRelationStore Relations { get; }
IRenameBatchStore RenameBatches { get; }
ISyncProfileStore SyncProfiles { get; }
IOperationProfileStore OperationProfiles { get; }
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
@@ -78,6 +82,8 @@ public interface ITransferStore
{
Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default);
Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default);
Task<IReadOnlyList<TransferJob>> GetIncompleteAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<TransferJob>> GetHistoryAsync(int take, CancellationToken cancellationToken = default);
}
public interface ISearchStore
@@ -161,6 +167,36 @@ public interface IHashStore
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
}
public interface IFileRelationStore
{
Task<IReadOnlyList<FileRelation>> GetAmongAsync(IReadOnlyList<long> entryIds, CancellationToken cancellationToken = default);
Task UpsertAsync(FileRelation relation, CancellationToken cancellationToken = default);
Task DeleteAmongAsync(IReadOnlyList<long> entryIds, IReadOnlyList<FileRelationKind> kinds, CancellationToken cancellationToken = default);
}
public interface IRenameBatchStore
{
Task<long> CreateAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default);
Task<RenameBatch?> GetLatestUndoableAsync(CancellationToken cancellationToken = default);
Task MarkUndoneAsync(long id, CancellationToken cancellationToken = default);
}
public interface ISyncProfileStore
{
Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default);
Task<SyncProfile?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(SyncProfile profile, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}
public interface IOperationProfileStore
{
Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default);
Task<OperationProfile?> GetAsync(long id, CancellationToken cancellationToken = default);
Task<long> UpsertAsync(OperationProfile profile, CancellationToken cancellationToken = default);
Task DeleteAsync(long id, CancellationToken cancellationToken = default);
}
public sealed class HashWorkItem
{
public long EntryId { get; init; }

View File

@@ -30,6 +30,18 @@ public interface IFileSystemEnumerator
IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath);
FileSystemItem? GetItem(string path);
IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error);
IEnumerable<FileSystemItem> EnumerateChildrenStreaming(
string directoryPath,
FileEnumerationSink sink,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(sink);
cancellationToken.ThrowIfCancellationRequested();
var items = EnumerateChildrenSafe(directoryPath, out var error);
sink.Error = error;
return items;
}
}
public interface IUsnJournal
@@ -90,6 +102,7 @@ public interface IShellFileOperations
bool CreateShortcut(string targetPath, string shortcutPath, out string? error);
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error, Func<bool>? pauseRequested = null);
bool EmptyRecycleBin(out string? error);
}
public interface IIconService

View File

@@ -5,7 +5,7 @@ public static class AppConstants
public const string ProductFolderName = "ExplorerWorkbench";
public const string DatabaseFileName = "index.db";
public const string LogFolderName = "logs";
public const int SchemaVersion = 3;
public const int SchemaVersion = 8;
public const int DefaultTombstoneRetentionDays = 30;
public const int ScanBatchSize = 3000;
public const int SearchPageSize = 500;
@@ -19,4 +19,5 @@ public static class AppConstants
public const int NetworkScanParallelism = 1;
public const int ProgressHzMilliseconds = 100;
public const int MaxArchiveEntries = 8000;
public static readonly TimeSpan SyncTimestampSkew = TimeSpan.FromSeconds(2);
}

View File

@@ -68,4 +68,18 @@ public static class ArchiveFormats
relative = string.Join('\\', parts);
return relative.Length > 0;
}
public static string Stem(string name)
{
var lower = name.ToLowerInvariant();
if (lower.EndsWith(".tar.gz", StringComparison.Ordinal)
|| lower.EndsWith(".tar.bz2", StringComparison.Ordinal)
|| lower.EndsWith(".tar.xz", StringComparison.Ordinal))
{
return name[..name.LastIndexOf(".tar", StringComparison.OrdinalIgnoreCase)];
}
var stem = Path.GetFileNameWithoutExtension(name);
return string.IsNullOrEmpty(stem) ? name : stem;
}
}

View File

@@ -0,0 +1,41 @@
namespace Explorer.Domain;
public sealed class FileEnumerationSink
{
public string? Error { get; set; }
}
public sealed class BrowseDelta
{
public required string Path { get; init; }
public bool IsOffline { get; init; }
public string? Error { get; init; }
public IReadOnlyList<FileSystemItem> Added { get; init; } = [];
public IReadOnlyList<FileSystemItem> Updated { get; init; } = [];
public bool EnumerationComplete { get; init; }
public bool HydrationComplete { get; init; }
public ItemHydrationFlags CompletedStages { get; init; }
}
public sealed class BrowseViewport
{
private readonly object _gate = new();
private string[] _visible = [];
public void SetVisible(IReadOnlyList<string> paths)
{
ArgumentNullException.ThrowIfNull(paths);
lock (_gate)
{
_visible = paths.Count == 0 ? [] : [.. paths];
}
}
public IReadOnlyList<string> Snapshot()
{
lock (_gate)
{
return _visible;
}
}
}

View File

@@ -0,0 +1,136 @@
using Explorer.Domain.Abstractions;
namespace Explorer.Domain;
public sealed class ClassifiedDuplicateGroup
{
public required DuplicateGroup Group { get; init; }
public DuplicateClass Classification { get; init; }
public int UniqueFileCount { get; init; }
public long WastedBytes { get; init; }
}
public static class DuplicateClassifier
{
public static bool IsHardlinkOnly(IReadOnlyList<IndexEntry> entries)
{
if (entries.Count < 2)
{
return false;
}
long? sourceId = null;
long? fileId = null;
foreach (var entry in entries)
{
if (entry.FileId is not > 0)
{
return false;
}
sourceId ??= entry.SourceId;
fileId ??= entry.FileId;
if (entry.SourceId != sourceId || entry.FileId != fileId)
{
return false;
}
}
return true;
}
public static int UniqueFileCount(IReadOnlyList<IndexEntry> entries)
{
var ids = new HashSet<(long SourceId, long Identity)>();
foreach (var entry in entries)
{
var identity = entry.FileId is > 0 ? entry.FileId.Value : -entry.Id;
ids.Add((entry.SourceId, identity));
}
return ids.Count;
}
public static DuplicateClass Classify(IReadOnlyList<IndexEntry> entries, IReadOnlyList<FileRelation> relations)
{
if (IsHardlinkOnly(entries))
{
return DuplicateClass.Hardlink;
}
var kinds = relations.Select(r => r.Kind).ToHashSet();
if (kinds.Contains(FileRelationKind.SyncCopy))
{
return DuplicateClass.Synchronized;
}
if (kinds.Contains(FileRelationKind.BackupCopy))
{
return DuplicateClass.Backup;
}
if (kinds.Contains(FileRelationKind.IntentionalDuplicate))
{
return DuplicateClass.Intentional;
}
if (kinds.Contains(FileRelationKind.AccidentalDuplicate))
{
return DuplicateClass.Accidental;
}
return DuplicateClass.Unknown;
}
public static ClassifiedDuplicateGroup ClassifyGroup(DuplicateGroup group, IReadOnlyList<FileRelation> relations)
{
var classification = Classify(group.Entries, relations);
var unique = UniqueFileCount(group.Entries);
return new ClassifiedDuplicateGroup
{
Group = group,
Classification = classification,
UniqueFileCount = unique,
WastedBytes = classification == DuplicateClass.Hardlink || unique <= 1
? 0
: group.SizeBytes * (unique - 1)
};
}
public static bool IsIntentional(DuplicateClass classification)
=> classification is DuplicateClass.Intentional or DuplicateClass.Synchronized or DuplicateClass.Backup;
public static bool IsHiddenByDefault(DuplicateClass classification)
=> classification == DuplicateClass.Hardlink || IsIntentional(classification);
public static IReadOnlyList<FileRelation> RelationsFor(
IReadOnlyList<IndexEntry> entries,
IReadOnlyList<FileRelation> all)
{
var ids = entries.Select(e => e.Id).ToHashSet();
return all.Where(r => ids.Contains(r.LeftEntryId) && ids.Contains(r.RightEntryId)).ToList();
}
public static IEnumerable<(long Left, long Right)> Pairs(IReadOnlyList<IndexEntry> entries)
{
var ids = entries.Select(e => e.Id).Distinct().OrderBy(id => id).ToList();
for (var i = 0; i < ids.Count; i++)
{
for (var j = i + 1; j < ids.Count; j++)
{
yield return (ids[i], ids[j]);
}
}
}
public static string Label(DuplicateClass classification)
=> classification switch
{
DuplicateClass.Accidental => "Accidental",
DuplicateClass.Synchronized => "Synchronized",
DuplicateClass.Backup => "Backup",
DuplicateClass.Intentional => "Intentional",
DuplicateClass.Hardlink => "Hard link",
_ => "Unknown"
};
}

View File

@@ -122,7 +122,11 @@ public sealed class TransferJob
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? StartedUtc { get; set; }
public string? Error { get; set; }
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
public int RetryCount { get; set; }
public string? WaitReason { get; set; }
public int SortOrder { get; set; }
public bool Dismissed { get; set; }
public IReadOnlyList<string> AdditionalSources { get; set; } = [];
}
public sealed class FileSystemItem
@@ -143,7 +147,47 @@ public sealed class FileSystemItem
public string? DisplayName { get; init; }
public long? FreeSpaceBytes { get; init; }
public long? CapacityBytes { get; init; }
public bool AvailableToImport { get; init; }
public ItemHydrationFlags Hydration { get; init; } = ItemHydrationFlags.All;
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
public FileSystemItem Overlay(
long? sizeBytes = null,
DateTimeOffset? createdUtc = null,
DateTimeOffset? modifiedUtc = null,
int? attributes = null,
long? fileId = null,
int? reparseTag = null,
long? allocatedSizeBytes = null,
CloudPresence? cloud = null,
LocationInfo? location = null,
SizeKnowledge? sizeKnowledge = null,
string? displayName = null,
long? freeSpaceBytes = null,
long? capacityBytes = null,
bool? availableToImport = null,
ItemHydrationFlags? hydration = null)
=> new()
{
FullPath = FullPath,
Name = Name,
IsDirectory = IsDirectory,
SizeBytes = sizeBytes ?? SizeBytes,
CreatedUtc = createdUtc ?? CreatedUtc,
ModifiedUtc = modifiedUtc ?? ModifiedUtc,
Attributes = attributes ?? Attributes,
FileId = fileId ?? FileId,
ReparseTag = reparseTag ?? ReparseTag,
AllocatedSizeBytes = allocatedSizeBytes ?? AllocatedSizeBytes,
Cloud = cloud ?? Cloud,
Location = location ?? Location,
SizeKnowledge = sizeKnowledge ?? SizeKnowledge,
DisplayName = displayName ?? DisplayName,
FreeSpaceBytes = freeSpaceBytes ?? FreeSpaceBytes,
CapacityBytes = capacityBytes ?? CapacityBytes,
AvailableToImport = availableToImport ?? AvailableToImport,
Hydration = hydration ?? Hydration
};
}
public sealed record CloudPresence(
@@ -173,3 +217,13 @@ public sealed class FolderListing
public IReadOnlyList<FileSystemItem> Items { get; init; } = [];
public string? Error { get; init; }
}
public sealed class FileRelation
{
public long Id { get; set; }
public long LeftEntryId { get; init; }
public long RightEntryId { get; init; }
public FileRelationKind Kind { get; init; }
public FileRelationOrigin Origin { get; init; } = FileRelationOrigin.User;
public DateTimeOffset CreatedUtc { get; init; }
}

View File

@@ -14,6 +14,18 @@ public static class SourceKinds
public static bool IsNetwork(this SourceKind kind) => kind is SourceKind.Smb or SourceKind.Nfs;
}
[Flags]
public enum ItemHydrationFlags
{
None = 0,
Shell = 1,
Metadata = 2,
Location = 4,
Index = 8,
Provider = 16,
All = Shell | Metadata | Location | Index | Provider
}
public enum SourceStatus
{
Online,
@@ -81,7 +93,18 @@ public enum TransferOp
Move,
Delete,
Rename,
NewFolder
NewFolder,
EmptyRecycleBin,
Extract,
Compress,
AddToArchive,
VerifyArchive
}
public enum ArchiveFormat
{
Zip,
SevenZip
}
public enum TransferStatus
@@ -89,6 +112,7 @@ public enum TransferStatus
Queued,
Running,
Paused,
Waiting,
Cancelling,
Cancelled,
Failed,
@@ -122,6 +146,56 @@ public enum IndexFreshness
NotIndexed
}
public enum FileRelationKind
{
AccidentalDuplicate,
Hardlink,
SyncCopy,
BackupCopy,
ConvertedFrom,
ArchivedFrom,
RenamedFrom,
IntentionalDuplicate
}
public enum FileRelationOrigin
{
User,
Detected,
Sync
}
public enum DuplicateClass
{
Unknown,
Accidental,
Synchronized,
Backup,
Intentional,
Hardlink
}
public enum SyncMode
{
CopyUpdate,
Mirror
}
public enum FileCategory
{
Unknown,
Photos,
Video,
Audio,
Documents,
CodeRepository,
Installer,
Backup,
Archive,
SystemData,
BuildOutput
}
public static class AttributeFlags
{
public const int ReadOnly = 1;

View File

@@ -0,0 +1,170 @@
namespace Explorer.Domain;
public static class FileClassifier
{
private static readonly HashSet<string> Photos = new(StringComparer.OrdinalIgnoreCase)
{
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "ico", "jfif", "heic", "heif",
"raw", "cr2", "nef", "arw", "dng", "orf", "rw2"
};
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
{
"mp4", "mkv", "avi", "mov", "wmv", "webm", "m4v", "mpg", "mpeg", "ts", "mts", "m2ts", "3gp"
};
private static readonly HashSet<string> Audio = new(StringComparer.OrdinalIgnoreCase)
{
"mp3", "wav", "flac", "aac", "m4a", "ogg", "wma", "aiff", "aif", "opus"
};
private static readonly HashSet<string> Documents = new(StringComparer.OrdinalIgnoreCase)
{
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
"txt", "rtf", "md", "csv", "one"
};
private static readonly HashSet<string> Installers = new(StringComparer.OrdinalIgnoreCase)
{
"msi", "msix", "appx", "msixbundle", "exe", "iso", "img", "apk", "msp", "msu"
};
private static readonly HashSet<string> Backups = new(StringComparer.OrdinalIgnoreCase)
{
"bak", "old", "wbk"
};
private static readonly HashSet<string> BuildNames = new(StringComparer.OrdinalIgnoreCase)
{
"node_modules", "bin", "obj", ".vs", "packages", "dist", "__pycache__", ".idea", ".git"
};
private static readonly HashSet<string> PhotoFolders = new(StringComparer.OrdinalIgnoreCase)
{
"photos", "pictures", "dcim", "camera", "images"
};
private static readonly HashSet<string> VideoFolders = new(StringComparer.OrdinalIgnoreCase)
{
"videos", "movies", "film", "films"
};
private static readonly HashSet<string> AudioFolders = new(StringComparer.OrdinalIgnoreCase)
{
"music", "audio", "sound"
};
private static readonly HashSet<string> BackupFolders = new(StringComparer.OrdinalIgnoreCase)
{
"backup", "backups"
};
public static FileClassification Classify(
string name,
string fullPath,
bool isDirectory,
int attributes,
bool isRepoRoot = false,
IReadOnlyList<FileCategory>? childCategories = null)
{
var location = LocationClassifier.Classify(fullPath, name, attributes, isDirectory);
if (location.IsProtected || location.IsRecycleBin)
{
return new FileClassification(FileCategory.SystemData, "Protected Windows location.");
}
if (BuildNames.Contains(name))
{
return new FileClassification(FileCategory.BuildOutput, "Known build or tool folder.");
}
if (isRepoRoot)
{
return new FileClassification(FileCategory.CodeRepository, "Git repository.");
}
if (!isDirectory)
{
if (ArchiveFormats.IsArchive(name))
{
return new FileClassification(FileCategory.Archive, "Archive file.");
}
var ext = NameNormalizer.Extension(name);
if (ext is not null)
{
if (Photos.Contains(ext))
{
return new FileClassification(FileCategory.Photos, "Image file.");
}
if (Videos.Contains(ext))
{
return new FileClassification(FileCategory.Video, "Video file.");
}
if (Audio.Contains(ext))
{
return new FileClassification(FileCategory.Audio, "Audio file.");
}
if (Documents.Contains(ext))
{
return new FileClassification(FileCategory.Documents, "Document.");
}
if (Installers.Contains(ext))
{
return new FileClassification(FileCategory.Installer, "Installer.");
}
if (Backups.Contains(ext))
{
return new FileClassification(FileCategory.Backup, "Backup copy.");
}
}
return new FileClassification(FileCategory.Unknown, "No matching signal.");
}
if (PhotoFolders.Contains(name))
{
return new FileClassification(FileCategory.Photos, "Photo folder name.");
}
if (VideoFolders.Contains(name))
{
return new FileClassification(FileCategory.Video, "Video folder name.");
}
if (AudioFolders.Contains(name))
{
return new FileClassification(FileCategory.Audio, "Audio folder name.");
}
if (BackupFolders.Contains(name))
{
return new FileClassification(FileCategory.Backup, "Backup folder name.");
}
if (childCategories is { Count: > 0 })
{
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(FileCategory.Unknown, "No matching signal.");
}
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);
}

View File

@@ -0,0 +1,47 @@
namespace Explorer.Domain;
public sealed record GitStatus
{
public required string RepoRoot { get; init; }
public required string Branch { get; init; }
public int ModifiedCount { get; init; }
public int UntrackedCount { get; init; }
public int Ahead { get; init; }
public int Behind { get; init; }
public bool WorkingTreeClean => ModifiedCount == 0 && UntrackedCount == 0;
public string Badge
{
get
{
var parts = new List<string> { Branch };
if (ModifiedCount > 0)
{
parts.Add($"{ModifiedCount} modified");
}
if (UntrackedCount > 0)
{
parts.Add($"{UntrackedCount} untracked");
}
if (Ahead > 0)
{
parts.Add($"{Ahead} ahead");
}
if (Behind > 0)
{
parts.Add($"{Behind} behind");
}
if (WorkingTreeClean && Ahead == 0 && Behind == 0)
{
parts.Add("clean");
}
return string.Join(" · ", parts);
}
}
}

View File

@@ -5,7 +5,8 @@ public static class LocationRoots
public const string ThisPc = "This PC";
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;
=> path is ThisPc or Network or Cloud or RecycleBin;
}

View File

@@ -0,0 +1,35 @@
namespace Explorer.Domain;
public enum PlanIssueSeverity
{
Error,
Warning
}
public sealed record PlanIssue(PlanIssueSeverity Severity, string Message, string? Path = null);
public sealed record PlannedOperation(
TransferOp Op,
string SourcePath,
string? DestinationPath,
string? NewName = null);
public sealed class OperationPlan
{
public IReadOnlyList<PlannedOperation> Operations { get; init; } = [];
public IReadOnlyList<PlanIssue> Issues { get; init; } = [];
public IReadOnlyList<RenamePreviewRow> Preview { get; init; } = [];
public IReadOnlyList<SyncPreviewRow> SyncPreview { get; init; } = [];
public IReadOnlyList<ProfilePreviewRow> ProfilePreview { get; init; } = [];
public IReadOnlyList<OrganizePreviewRow> OrganizePreview { get; init; } = [];
public bool HasErrors => Issues.Any(i => i.Severity == PlanIssueSeverity.Error);
public bool CanEnqueue => !HasErrors && Operations.Count > 0;
}
public sealed record RenamePreviewRow(
string SourcePath,
string OldName,
string NewName,
string? Status,
bool Unchanged);

View File

@@ -0,0 +1,44 @@
namespace Explorer.Domain;
public sealed class OperationProfile
{
public long Id { get; set; }
public required string Name { get; set; }
public string SourcePath { get; set; } = "";
public string DestPath { get; set; } = "";
public bool RequireGitClean { get; set; }
public bool DoCompress { get; set; }
public ArchiveFormat ArchiveFormat { get; set; } = ArchiveFormat.SevenZip;
public bool DoCopy { get; set; }
public bool DoRename { get; set; }
public string RenamePrefix { get; set; } = "";
public string RenameSuffix { get; set; } = "";
public string RenameSearch { get; set; } = "";
public string RenameReplace { get; set; } = "";
public string Excludes { get; set; } = "";
public bool AutoRun { get; set; }
public string? SourceVolumeGuid { get; set; }
public string? DestVolumeGuid { get; set; }
public bool IsBuiltIn { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? LastRunUtc { get; set; }
public string? LastStatus { get; set; }
public bool HasRenameRules
=> DoRename && (!string.IsNullOrWhiteSpace(RenamePrefix)
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
public bool CanAutoRun => AutoRun && DoCopy && !DoCompress && !HasRenameRules;
public RenameRuleSet RenameRules()
=> new()
{
Prefix = RenamePrefix ?? "",
Suffix = RenameSuffix ?? "",
Search = RenameSearch ?? "",
Replace = RenameReplace ?? ""
};
}
public sealed record ProfilePreviewRow(string Action, string Path, string? Detail);

View File

@@ -0,0 +1,53 @@
namespace Explorer.Domain;
public sealed record FileClassification(FileCategory Category, string Reason);
public sealed record OrganizePreviewRow(
string Name,
string Category,
string Action,
string? Destination,
string? Detail);
public sealed class OrganizeDestinations
{
public string Pictures { get; set; } = "";
public string Videos { get; set; } = "";
public string Audio { get; set; } = "";
public string Documents { get; set; } = "";
public string Installers { get; set; } = "";
public string Archives { get; set; } = "";
public string Development { get; set; } = "";
public string? For(FileCategory category)
=> category switch
{
FileCategory.Photos => EmptyToNull(Pictures),
FileCategory.Video => EmptyToNull(Videos),
FileCategory.Audio => EmptyToNull(Audio),
FileCategory.Documents => EmptyToNull(Documents),
FileCategory.Installer => EmptyToNull(Installers),
FileCategory.Archive => EmptyToNull(Archives),
FileCategory.CodeRepository => EmptyToNull(Development),
_ => null
};
public static string Label(FileCategory category)
=> category switch
{
FileCategory.Photos => "Photos",
FileCategory.Video => "Video",
FileCategory.Audio => "Audio",
FileCategory.Documents => "Documents",
FileCategory.CodeRepository => "Code repository",
FileCategory.Installer => "Installer",
FileCategory.Backup => "Backup",
FileCategory.Archive => "Archive",
FileCategory.SystemData => "System data",
FileCategory.BuildOutput => "Build output",
_ => "Unknown"
};
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -0,0 +1,39 @@
namespace Explorer.Domain;
public enum RenameCaseMode
{
Unchanged,
Lower,
Upper,
Title
}
public sealed record RenameSubject(string FullPath, string Name, bool IsDirectory);
public sealed class RenameRuleSet
{
public string Search { get; init; } = "";
public string Replace { get; init; } = "";
public bool UseRegex { get; init; }
public bool MatchCase { get; init; }
public bool IncludeExtensionInSearch { get; init; }
public string Prefix { get; init; } = "";
public string Suffix { get; init; } = "";
public bool UseCounter { get; init; }
public int CounterStart { get; init; } = 1;
public int CounterStep { get; init; } = 1;
public int CounterPadding { get; init; }
public RenameCaseMode CaseMode { get; init; }
public bool ChangeExtension { get; init; }
public string NewExtension { get; init; } = "";
}
public sealed class RenameBatch
{
public long Id { get; init; }
public DateTimeOffset CreatedUtc { get; init; }
public bool Undone { get; init; }
public IReadOnlyList<RenameBatchItem> Items { get; init; } = [];
}
public sealed record RenameBatchItem(string OldPath, string NewPath, int SortOrder);

View File

@@ -0,0 +1,19 @@
namespace Explorer.Domain;
public sealed class SyncProfile
{
public long Id { get; set; }
public required string Name { get; set; }
public required string SourcePath { get; set; }
public required string DestPath { get; set; }
public SyncMode Mode { get; set; } = SyncMode.CopyUpdate;
public string Excludes { get; set; } = "";
public bool AutoRun { get; set; }
public string? SourceVolumeGuid { get; set; }
public string? DestVolumeGuid { get; set; }
public DateTimeOffset CreatedUtc { get; set; }
public DateTimeOffset? LastRunUtc { get; set; }
public string? LastStatus { get; set; }
}
public sealed record SyncPreviewRow(string RelativePath, string Action, string? Detail);

View File

@@ -0,0 +1,127 @@
namespace Explorer.Domain;
public static class WindowsFileNames
{
private static readonly HashSet<string> Reserved = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
public static bool IsValid(string name, out string? error)
{
if (string.IsNullOrWhiteSpace(name))
{
error = "Name is empty.";
return false;
}
if (name is "." or "..")
{
error = "Name is not allowed.";
return false;
}
if (name.Length > 255)
{
error = "Name is too long.";
return false;
}
if (name.EndsWith(' ') || name.EndsWith('.'))
{
error = "Name cannot end with a space or period.";
return false;
}
foreach (var c in name)
{
if (c is '<' or '>' or ':' or '"' or '/' or '\\' or '|' or '?' or '*' || c < 32)
{
error = "Name contains characters Windows does not allow.";
return false;
}
}
var stem = name;
var dot = name.IndexOf('.');
if (dot >= 0)
{
stem = name[..dot];
}
if (Reserved.Contains(stem))
{
error = $"“{stem}” is a reserved Windows name.";
return false;
}
error = null;
return true;
}
public static (string Stem, string Extension) Split(string name)
{
var dot = name.LastIndexOf('.');
if (dot <= 0 || dot == name.Length - 1)
{
return (name, "");
}
return (name[..dot], name[(dot + 1)..]);
}
public static string Join(string stem, string extension)
{
extension = extension.Trim().TrimStart('.');
return string.IsNullOrEmpty(extension) ? stem : stem + "." + extension;
}
public static string ApplyCase(string value, RenameCaseMode mode)
=> mode switch
{
RenameCaseMode.Lower => value.ToLowerInvariant(),
RenameCaseMode.Upper => value.ToUpperInvariant(),
RenameCaseMode.Title => ToTitle(value),
_ => value
};
private static string ToTitle(string value)
{
if (value.Length == 0)
{
return value;
}
var chars = value.ToLowerInvariant().ToCharArray();
var word = true;
for (var i = 0; i < chars.Length; i++)
{
if (char.IsLetterOrDigit(chars[i]))
{
if (word)
{
chars[i] = char.ToUpperInvariant(chars[i]);
word = false;
}
}
else
{
word = true;
}
}
return new string(chars);
}
public static string FormatCounter(int value, int padding)
{
if (padding <= 0)
{
return value.ToString();
}
return value.ToString().PadLeft(padding, '0');
}
}

View File

@@ -0,0 +1,22 @@
namespace Explorer.FileOperations;
public static class FileOperationErrors
{
public const string FileInUse = "The file is in use. Retry when it is available.";
public const string NameExists = "A file with that name already exists.";
public const string DestinationUnavailable = "Destination unavailable";
public const string CloudHydration = "Online-only cloud files are not extracted or compressed.";
public static bool IsLock(string? error)
{
if (string.IsNullOrWhiteSpace(error))
{
return false;
}
return error.Contains("being used by another process", StringComparison.OrdinalIgnoreCase)
|| error.Contains("sharing violation", StringComparison.OrdinalIgnoreCase)
|| error.Contains("lock violation", StringComparison.OrdinalIgnoreCase)
|| error.Contains(FileInUse, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -62,6 +62,45 @@ public sealed class FileOperationService
return dest;
}
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
=> _queue.EnqueueRenameAsync(path, newName, cancellationToken);
public async Task EnqueueRenameAsync(IReadOnlyList<(string Path, string NewName)> items, CancellationToken cancellationToken = default)
{
foreach (var (path, newName) in items)
{
await _queue.EnqueueRenameAsync(path, newName, cancellationToken).ConfigureAwait(false);
}
}
public Task EmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> _queue.EnqueueEmptyRecycleBinAsync(cancellationToken);
public Task ExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> _queue.EnqueueExtractAsync(archivePath, destinationDirectory, cancellationToken);
public Task CompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueCompressAsync(sources, archivePath, cancellationToken);
public Task AddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> _queue.EnqueueAddToArchiveAsync(archivePath, sources, cancellationToken);
public Task VerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> _queue.EnqueueVerifyArchiveAsync(archivePath, cancellationToken);
public static string UniqueArchivePath(string directory, string stem, string extension)
{
extension = extension.Trim().TrimStart('.');
var dest = Path.Combine(directory, stem + "." + extension);
var i = 2;
while (File.Exists(PathRules.ToExtended(dest)) || Directory.Exists(PathRules.ToExtended(dest)))
{
dest = Path.Combine(directory, $"{stem} ({i++}).{extension}");
}
return dest;
}
public void Rename(string path, string newName)
{
var parent = PathRules.Parent(path);

View File

@@ -0,0 +1,191 @@
using System.Collections.Concurrent;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class FolderSyncService
{
private readonly FolderSyncPlanner _planner;
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly FileOperationService _ops;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly ConcurrentDictionary<string, string> _pendingCopies = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public FolderSyncService(
FolderSyncPlanner planner,
IIndexStore store,
SourceManager sources,
FileOperationService ops,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration)
{
_planner = planner;
_store = store;
_sources = sources;
_ops = ops;
_volumes = volumes;
_enumerator = enumerator;
_hydration = hydration;
}
public Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default)
=> _store.SyncProfiles.ListAsync(cancellationToken);
public async Task<long> SaveAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{
if (profile.CreatedUtc == default)
{
profile.CreatedUtc = DateTimeOffset.UtcNow;
}
AttachVolumeGuids(profile);
profile.Id = await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id;
}
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.SyncProfiles.DeleteAsync(id, cancellationToken);
public async Task<OperationPlan> PreviewAsync(SyncProfile profile, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var sourcePath = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var destPath = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
return _planner.Build(
sourcePath,
destPath,
profile.Mode,
profile.Excludes,
_enumerator,
path => _volumes.IsPathReachable(path),
item => _hydration.WouldHydrateOnRead(item));
}
public async Task<OperationPlan> EnqueueAsync(SyncProfile profile, OperationPlan? plan = null, CancellationToken cancellationToken = default)
{
plan ??= await PreviewAsync(profile, cancellationToken).ConfigureAwait(false);
if (!plan.CanEnqueue)
{
return plan;
}
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{
_pendingCopies[op.DestinationPath] = op.SourcePath;
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
else if (op.Op == TransferOp.Delete)
{
await _ops.DeleteAsync([op.SourcePath], permanent: false, cancellationToken).ConfigureAwait(false);
}
}
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var deletes = plan.Operations.Count(o => o.Op == TransferOp.Delete);
profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = deletes > 0
? $"Queued {copies} copy, {deletes} delete"
: $"Queued {copies} copy";
await _store.SyncProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return plan;
}
public async Task TryAutoRunAsync(CancellationToken cancellationToken = default)
{
var profiles = await _store.SyncProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var profile in profiles)
{
if (!profile.AutoRun || profile.Mode != SyncMode.CopyUpdate)
{
continue;
}
var dest = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
var source = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var ready = _volumes.IsPathReachable(source)
&& (_volumes.IsPathReachable(dest) || _volumes.IsPathReachable(PathRules.Parent(dest)));
if (!_autoRunOnline.TryGetValue(profile.Id, out var wasReady))
{
_autoRunOnline[profile.Id] = ready;
continue;
}
if (!ready)
{
_autoRunOnline[profile.Id] = false;
continue;
}
if (wasReady)
{
continue;
}
_autoRunOnline[profile.Id] = true;
var plan = await PreviewAsync(profile, cancellationToken).ConfigureAwait(false);
if (plan.CanEnqueue)
{
await EnqueueAsync(profile, plan, cancellationToken).ConfigureAwait(false);
}
}
}
public async Task TryMarkRelationAsync(TransferJob job, CancellationToken cancellationToken = default)
{
if (job.Op != TransferOp.Copy || job.Status != TransferStatus.Done || job.DestinationPath is null)
{
return;
}
if (!_pendingCopies.TryRemove(job.DestinationPath, out var sourcePath))
{
return;
}
var leftSource = await _sources.FindByPathAsync(sourcePath, cancellationToken).ConfigureAwait(false);
var rightSource = await _sources.FindByPathAsync(job.DestinationPath, cancellationToken).ConfigureAwait(false);
if (leftSource is not { IsIndexed: true, LastRootPath: not null }
|| rightSource is not { IsIndexed: true, LastRootPath: not null })
{
return;
}
var leftRel = PathRules.MakeRelative(leftSource.LastRootPath, sourcePath);
var rightRel = PathRules.MakeRelative(rightSource.LastRootPath, job.DestinationPath);
var left = await _store.Entries.GetByPathAsync(leftSource.Id, leftRel, cancellationToken).ConfigureAwait(false);
var right = await _store.Entries.GetByPathAsync(rightSource.Id, rightRel, cancellationToken).ConfigureAwait(false);
if (left is null || right is null)
{
return;
}
await _store.Relations.UpsertAsync(new FileRelation
{
LeftEntryId = left.Id,
RightEntryId = right.Id,
Kind = FileRelationKind.SyncCopy,
Origin = FileRelationOrigin.Sync,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken).ConfigureAwait(false);
}
private void AttachVolumeGuids(SyncProfile profile)
{
var src = _volumes.Probe(profile.SourcePath);
var dst = _volumes.Probe(profile.DestPath);
profile.SourceVolumeGuid = src?.VolumeGuid ?? profile.SourceVolumeGuid;
profile.DestVolumeGuid = dst?.VolumeGuid ?? profile.DestVolumeGuid;
}
}

View File

@@ -0,0 +1,9 @@
using Explorer.Domain;
namespace Explorer.FileOperations;
public interface IOperationExecutor
{
bool CanExecute(TransferOp op);
Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,441 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class NativeFileOperationExecutor : IOperationExecutor
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IArchiveExecutor? _archives;
private readonly IHydrationGuard? _hydration;
public NativeFileOperationExecutor(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null)
{
_shell = shell;
_enumerator = enumerator;
_archives = archives;
_hydration = hydration;
}
public bool CanExecute(TransferOp op)
=> op is TransferOp.Copy or TransferOp.Move or TransferOp.Delete or TransferOp.Rename
or TransferOp.EmptyRecycleBin or TransferOp.Extract or TransferOp.Compress
or TransferOp.AddToArchive or TransferOp.VerifyArchive;
public async Task ExecuteAsync(TransferJob job, Func<bool> pauseRequested, Action? reportProgress, CancellationToken cancellationToken)
{
switch (job.Op)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, pauseRequested, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, pauseRequested, reportProgress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
case TransferOp.Rename:
Rename(job);
break;
case TransferOp.EmptyRecycleBin:
EmptyRecycleBin(job);
break;
case TransferOp.Extract:
case TransferOp.Compress:
case TransferOp.AddToArchive:
case TransferOp.VerifyArchive:
await ArchiveAsync(job, reportProgress, cancellationToken).ConfigureAwait(false);
break;
default:
job.Status = TransferStatus.Failed;
job.Error = $"Unsupported operation {job.Op}";
break;
}
}
private async Task CopyOrMove(
TransferJob job,
bool move,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, pauseRequested, reportProgress, stoppingToken).ConfigureAwait(false);
if (move && job.Status is not TransferStatus.Failed and not TransferStatus.Cancelling and not TransferStatus.Paused)
{
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
}
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, pauseRequested, reportProgress, stoppingToken, out var error))
{
ApplyHalt(job, src, error, pauseRequested);
return;
}
job.BytesDone = job.BytesTotal ?? job.BytesDone;
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(
string src,
string dst,
bool move,
TransferJob job,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
job.FilesDone = 0;
job.FilesTotal = 0;
job.BytesDone = 0;
job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling || pauseRequested())
{
ApplyHalt(job, job.CurrentPath, pauseRequested() ? "Paused" : "Cancelled", pauseRequested);
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
continue;
}
job.FilesTotal++;
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var destExists = File.Exists(PathRules.ToExtended(childDest));
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
if (destExists && destLen == child.SizeBytes)
{
job.BytesDone += child.SizeBytes;
job.FilesDone++;
reportProgress?.Invoke();
continue;
}
job.CurrentPath = child.FullPath;
var committed = job.BytesDone;
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, pauseRequested, reportProgress, stoppingToken, out var err))
{
ApplyHalt(job, child.FullPath, err, pauseRequested);
return;
}
job.BytesDone = committed + child.SizeBytes;
job.FilesDone++;
reportProgress?.Invoke();
}
}
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private bool TransferFile(
string src,
string dst,
bool move,
bool resumePartial,
TransferJob job,
long committed,
Func<bool> pauseRequested,
Action? reportProgress,
CancellationToken stoppingToken,
out string? error)
{
var progress = new Progress<long>(b =>
{
job.BytesDone = committed + b;
reportProgress?.Invoke();
});
var ok = move
? _shell.MoveFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, pauseRequested)
: _shell.CopyFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, pauseRequested);
return ok;
}
private static void ApplyHalt(TransferJob job, string? path, string? error, Func<bool> pauseRequested)
{
job.CurrentPath = path;
if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
{
job.Status = TransferStatus.Cancelling;
job.Error = error == "Cancelled" ? null : error;
return;
}
if (pauseRequested() || error == "Paused")
{
job.Status = TransferStatus.Paused;
job.Error = null;
return;
}
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(error) ? FileOperationErrors.FileInUse : error;
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
if (!_shell.Delete(paths, recycle, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(error) ? FileOperationErrors.FileInUse : error;
}
}
private static void Rename(TransferJob job)
{
var src = PathRules.ToExtended(job.SourcePath);
var dst = PathRules.ToExtended(job.DestinationPath ?? throw new InvalidOperationException("Missing destination"));
if (File.Exists(dst) || Directory.Exists(dst))
{
var same = job.SourcePath.Equals(job.DestinationPath, StringComparison.OrdinalIgnoreCase);
if (!same)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.NameExists;
return;
}
var parent = PathRules.Parent(job.SourcePath);
var temp = PathRules.Combine(parent, ".ew-rename-" + Guid.NewGuid().ToString("N"));
try
{
Move(src, PathRules.ToExtended(temp));
Move(PathRules.ToExtended(temp), dst);
}
catch (IOException ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
return;
}
try
{
Move(src, dst);
}
catch (IOException ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
}
private static void Move(string src, string dst)
{
if (Directory.Exists(src))
{
Directory.Move(src, dst);
}
else if (File.Exists(src))
{
File.Move(src, dst);
}
else
{
throw new FileNotFoundException("Source not found", src);
}
}
private void EmptyRecycleBin(TransferJob job)
{
if (!_shell.EmptyRecycleBin(out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private async Task ArchiveAsync(TransferJob job, Action? reportProgress, CancellationToken cancellationToken)
{
if (_archives is null || !_archives.IsAvailable)
{
job.Status = TransferStatus.Failed;
job.Error = _archives?.MissingHint ?? SevenZipLocator.MissingHint;
return;
}
var progress = new Progress<ArchiveProgress>(p =>
{
job.BytesTotal = 100;
job.BytesDone = p.Percent;
job.FilesDone = p.FilesDone;
if (!string.IsNullOrWhiteSpace(p.CurrentPath))
{
job.CurrentPath = p.CurrentPath;
}
reportProgress?.Invoke();
});
try
{
switch (job.Op)
{
case TransferOp.Extract:
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
await _archives.ExtractAsync(
job.SourcePath,
job.DestinationPath ?? throw new InvalidOperationException("Missing destination"),
progress,
cancellationToken)
.ConfigureAwait(false);
break;
case TransferOp.Compress:
var compressSources = ArchiveSources(job);
if (await AnyHydrateAsync(compressSources, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
var archive = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
if (File.Exists(PathRules.ToExtended(archive)) || Directory.Exists(PathRules.ToExtended(archive)))
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.NameExists;
return;
}
var format = ArchiveFormats.IsZipFamily(archive) ? ArchiveFormat.Zip : ArchiveFormat.SevenZip;
await _archives.CompressAsync(compressSources, archive, format, progress, cancellationToken)
.ConfigureAwait(false);
break;
case TransferOp.AddToArchive:
var addSources = ArchiveSources(job);
var addArchive = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
if (await AnyHydrateAsync(addSources.Append(addArchive), cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
if (!File.Exists(PathRules.ToExtended(addArchive)))
{
job.Status = TransferStatus.Failed;
job.Error = "Archive not found.";
return;
}
await _archives.AddAsync(addArchive, addSources, progress, cancellationToken).ConfigureAwait(false);
break;
case TransferOp.VerifyArchive:
if (await WouldHydrateAsync(job.SourcePath, cancellationToken).ConfigureAwait(false))
{
FailHydration(job);
return;
}
await _archives.VerifyAsync(job.SourcePath, progress, cancellationToken).ConfigureAwait(false);
break;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
}
private static IReadOnlyList<string> ArchiveSources(TransferJob job)
=> job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
private static void FailHydration(TransferJob job)
{
job.Status = TransferStatus.Failed;
job.Error = FileOperationErrors.CloudHydration;
}
private async Task<bool> AnyHydrateAsync(IEnumerable<string> paths, CancellationToken cancellationToken)
{
foreach (var path in paths)
{
if (await WouldHydrateAsync(path, cancellationToken).ConfigureAwait(false))
{
return true;
}
}
return false;
}
private async Task<bool> WouldHydrateAsync(string path, CancellationToken cancellationToken)
{
if (_hydration is null)
{
return false;
}
var item = _enumerator.GetItem(path);
if (item is not null && _hydration.WouldHydrateOnRead(item))
{
return true;
}
return await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,88 @@
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
internal static class OperationAvailability
{
public static bool IsReady(IVolumeService volumes, TransferJob job)
{
foreach (var path in PathsToCheck(job))
{
if (!IsVolumeReachable(volumes, path))
{
return false;
}
}
return true;
}
public static IEnumerable<string> PathsToCheck(TransferJob job)
{
switch (job.Op)
{
case TransferOp.Delete:
if (job.AdditionalSources.Count > 0)
{
foreach (var src in job.AdditionalSources)
{
yield return src;
}
yield break;
}
foreach (var src in job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries))
{
yield return src;
}
yield break;
case TransferOp.Copy:
case TransferOp.Move:
case TransferOp.Rename:
case TransferOp.Extract:
case TransferOp.Compress:
case TransferOp.AddToArchive:
if (!string.IsNullOrWhiteSpace(job.DestinationPath))
{
yield return PathRules.Parent(job.DestinationPath);
}
yield break;
case TransferOp.VerifyArchive:
yield return job.SourcePath;
yield break;
case TransferOp.EmptyRecycleBin:
yield break;
}
}
public static bool IsVolumeReachable(IVolumeService volumes, string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return true;
}
if (volumes.IsPathReachable(path))
{
return true;
}
var root = PathRules.VolumeRoot(path);
if (string.IsNullOrWhiteSpace(root))
{
return false;
}
if (!string.Equals(root, path, StringComparison.OrdinalIgnoreCase) && volumes.IsPathReachable(root))
{
return true;
}
var slashed = root.EndsWith('\\') ? root : root + "\\";
return volumes.IsPathReachable(slashed);
}
}

View File

@@ -0,0 +1,262 @@
using System.Collections.Concurrent;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class OperationProfileService
{
private readonly FileOperationProfilePlanner _planner;
private readonly IIndexStore _store;
private readonly FileOperationService _ops;
private readonly RenameBatchService _renames;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IGitStatusProvider _git;
private readonly IHydrationGuard _hydration;
private readonly IArchiveExecutor _archives;
private readonly ConcurrentDictionary<long, bool> _autoRunOnline = [];
public OperationProfileService(
FileOperationProfilePlanner planner,
IIndexStore store,
FileOperationService ops,
RenameBatchService renames,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IGitStatusProvider git,
IHydrationGuard hydration,
IArchiveExecutor archives)
{
_planner = planner;
_store = store;
_ops = ops;
_renames = renames;
_volumes = volumes;
_enumerator = enumerator;
_git = git;
_hydration = hydration;
_archives = archives;
}
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await EnsureDefaultsAsync(cancellationToken).ConfigureAwait(false);
return await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<long> SaveAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{
if (profile.CreatedUtc == default)
{
profile.CreatedUtc = DateTimeOffset.UtcNow;
}
AttachVolumeGuids(profile);
profile.AutoRun = profile.CanAutoRun;
profile.Id = await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return profile.Id;
}
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.OperationProfiles.DeleteAsync(id, cancellationToken);
public async Task<OperationProfile> DuplicateAsync(OperationProfile profile, CancellationToken cancellationToken = default)
{
var copy = Clone(profile);
copy.Id = 0;
copy.IsBuiltIn = false;
copy.Name = profile.Name + " copy";
copy.LastRunUtc = null;
copy.LastStatus = null;
copy.CreatedUtc = DateTimeOffset.UtcNow;
copy.Id = await SaveAsync(copy, cancellationToken).ConfigureAwait(false);
return copy;
}
public async Task<OperationPlan> PreviewAsync(
OperationProfile profile,
IReadOnlyList<string>? sourceOverride = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
var sourcePath = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, known);
var destPath = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, known);
var working = Clone(profile);
working.Id = profile.Id;
working.SourcePath = sourcePath;
working.DestPath = destPath;
working.IsBuiltIn = profile.IsBuiltIn;
var sources = sourceOverride is { Count: > 0 }
? sourceOverride
: string.IsNullOrWhiteSpace(sourcePath) ? [] : new[] { sourcePath };
var gitPath = sources.Count > 0 ? sources[0] : sourcePath;
GitStatus? git = null;
if (working.RequireGitClean && !string.IsNullOrWhiteSpace(gitPath))
{
git = await _git.GetStatusAsync(gitPath, cancellationToken).ConfigureAwait(false);
}
return _planner.Build(
working,
sources,
_enumerator,
path => _volumes.IsPathReachable(path),
git,
_git.IsAvailable,
_archives.IsAvailable,
_archives.MissingHint,
RenameBatchService.PathExists,
item => _hydration.WouldHydrateOnRead(item));
}
public async Task<OperationPlan> EnqueueAsync(
OperationProfile profile,
OperationPlan? plan = null,
IReadOnlyList<string>? sourceOverride = null,
CancellationToken cancellationToken = default)
{
plan ??= await PreviewAsync(profile, sourceOverride, cancellationToken).ConfigureAwait(false);
if (!plan.CanEnqueue)
{
return plan;
}
var renames = plan.Operations.Where(o => o.Op == TransferOp.Rename).ToList();
if (renames.Count > 0)
{
await _renames.EnqueueAsync(new OperationPlan { Operations = renames }, cancellationToken)
.ConfigureAwait(false);
}
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Compress && op.DestinationPath is not null)
{
var parts = op.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
await _ops.CompressAsync(parts, op.DestinationPath, cancellationToken).ConfigureAwait(false);
}
else if (op.Op == TransferOp.Copy && op.DestinationPath is not null)
{
await _ops.CopyAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
}
var copies = plan.Operations.Count(o => o.Op == TransferOp.Copy);
var compress = plan.Operations.Count(o => o.Op == TransferOp.Compress);
var renamed = renames.Count;
profile.LastRunUtc = DateTimeOffset.UtcNow;
profile.LastStatus = $"Queued {copies} copy, {compress} compress, {renamed} rename";
await _store.OperationProfiles.UpsertAsync(profile, cancellationToken).ConfigureAwait(false);
return plan;
}
public async Task TryAutoRunAsync(CancellationToken cancellationToken = default)
{
var profiles = await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
foreach (var profile in profiles)
{
if (!profile.CanAutoRun)
{
continue;
}
var dest = FolderSyncPlanner.RemapToVolume(profile.DestPath, profile.DestVolumeGuid, sources);
var source = FolderSyncPlanner.RemapToVolume(profile.SourcePath, profile.SourceVolumeGuid, sources);
var ready = !string.IsNullOrWhiteSpace(source)
&& !string.IsNullOrWhiteSpace(dest)
&& _volumes.IsPathReachable(source)
&& (_volumes.IsPathReachable(dest) || _volumes.IsPathReachable(PathRules.Parent(dest)));
if (!_autoRunOnline.TryGetValue(profile.Id, out var wasReady))
{
_autoRunOnline[profile.Id] = ready;
continue;
}
if (!ready)
{
_autoRunOnline[profile.Id] = false;
continue;
}
if (wasReady)
{
continue;
}
_autoRunOnline[profile.Id] = true;
var plan = await PreviewAsync(profile, cancellationToken: cancellationToken).ConfigureAwait(false);
if (plan.CanEnqueue)
{
await EnqueueAsync(profile, plan, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
private async Task EnsureDefaultsAsync(CancellationToken cancellationToken)
{
var existing = await _store.OperationProfiles.ListAsync(cancellationToken).ConfigureAwait(false);
if (existing.Count > 0)
{
return;
}
var now = DateTimeOffset.UtcNow;
await _store.OperationProfiles.UpsertAsync(new OperationProfile
{
Name = "Archive folder",
RequireGitClean = true,
DoCompress = true,
ArchiveFormat = ArchiveFormat.SevenZip,
Excludes = ".git\nbin\nobj\n.vs",
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
await _store.OperationProfiles.UpsertAsync(new OperationProfile
{
Name = "Copy to destination",
DoCopy = true,
AutoRun = true,
IsBuiltIn = true,
CreatedUtc = now
}, cancellationToken).ConfigureAwait(false);
}
private void AttachVolumeGuids(OperationProfile profile)
{
if (!string.IsNullOrWhiteSpace(profile.SourcePath))
{
profile.SourceVolumeGuid = _volumes.Probe(profile.SourcePath)?.VolumeGuid ?? profile.SourceVolumeGuid;
}
if (!string.IsNullOrWhiteSpace(profile.DestPath))
{
profile.DestVolumeGuid = _volumes.Probe(profile.DestPath)?.VolumeGuid ?? profile.DestVolumeGuid;
}
}
private static OperationProfile Clone(OperationProfile profile)
=> new()
{
Name = profile.Name,
SourcePath = profile.SourcePath,
DestPath = profile.DestPath,
RequireGitClean = profile.RequireGitClean,
DoCompress = profile.DoCompress,
ArchiveFormat = profile.ArchiveFormat,
DoCopy = profile.DoCopy,
DoRename = profile.DoRename,
RenamePrefix = profile.RenamePrefix,
RenameSuffix = profile.RenameSuffix,
RenameSearch = profile.RenameSearch,
RenameReplace = profile.RenameReplace,
Excludes = profile.Excludes,
AutoRun = profile.AutoRun,
SourceVolumeGuid = profile.SourceVolumeGuid,
DestVolumeGuid = profile.DestVolumeGuid
};
}

View File

@@ -0,0 +1,79 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class RenameBatchService
{
private readonly RenamePlanner _planner;
private readonly IIndexStore _store;
private readonly FileOperationService _ops;
public RenameBatchService(RenamePlanner planner, IIndexStore store, FileOperationService ops)
{
_planner = planner;
_store = store;
_ops = ops;
}
public static bool PathExists(string path)
{
var ext = PathRules.ToExtended(path);
return File.Exists(ext) || Directory.Exists(ext);
}
public OperationPlan Preview(IReadOnlyList<RenameSubject> subjects, RenameRuleSet rules)
=> _planner.Build(subjects, rules, PathExists);
public async Task<long> EnqueueAsync(OperationPlan plan, CancellationToken cancellationToken = default)
{
if (!plan.CanEnqueue)
{
throw new InvalidOperationException(plan.Issues.FirstOrDefault()?.Message ?? "Rename plan has errors.");
}
var items = plan.Operations
.Select((op, i) => new RenameBatchItem(op.SourcePath, op.DestinationPath ?? op.SourcePath, i))
.ToList();
var batchId = await _store.RenameBatches.CreateAsync(items, cancellationToken).ConfigureAwait(false);
await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken)
.ConfigureAwait(false);
return batchId;
}
public Task<RenameBatch?> GetUndoableAsync(CancellationToken cancellationToken = default)
=> _store.RenameBatches.GetLatestUndoableAsync(cancellationToken);
public async Task<OperationPlan> UndoLastAsync(CancellationToken cancellationToken = default)
{
var batch = await _store.RenameBatches.GetLatestUndoableAsync(cancellationToken).ConfigureAwait(false);
if (batch is null)
{
return new OperationPlan
{
Issues = [new PlanIssue(PlanIssueSeverity.Warning, "No rename batch to undo.")]
};
}
var plan = _planner.BuildUndo(batch, PathExists);
if (plan.HasErrors || plan.Operations.Count == 0)
{
if (!plan.HasErrors)
{
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
}
return plan;
}
await _ops.EnqueueRenameAsync(
plan.Operations.Select(op => (op.SourcePath, op.NewName ?? PathRules.GetFileName(op.DestinationPath!))).ToList(),
cancellationToken)
.ConfigureAwait(false);
await _store.RenameBatches.MarkUndoneAsync(batch.Id, cancellationToken).ConfigureAwait(false);
return plan;
}
}

View File

@@ -0,0 +1,108 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.FileOperations;
public sealed class ReorganizeService
{
private readonly ReorganizePlanner _planner;
private readonly FileOperationService _ops;
private readonly IVolumeService _volumes;
private readonly IFileSystemEnumerator _enumerator;
private readonly IHydrationGuard _hydration;
private readonly IGitStatusProvider _git;
private readonly UiPreferencesStore _preferences;
public ReorganizeService(
ReorganizePlanner planner,
FileOperationService ops,
IVolumeService volumes,
IFileSystemEnumerator enumerator,
IHydrationGuard hydration,
IGitStatusProvider git,
UiPreferencesStore preferences)
{
_planner = planner;
_ops = ops;
_volumes = volumes;
_enumerator = enumerator;
_hydration = hydration;
_git = git;
_preferences = preferences;
}
public OrganizeDestinations LoadDestinations()
{
var prefs = _preferences.Load();
return new OrganizeDestinations
{
Pictures = First(prefs.OrganizePictures, Known(Environment.SpecialFolder.MyPictures)),
Videos = First(prefs.OrganizeVideos, Known(Environment.SpecialFolder.MyVideos)),
Audio = First(prefs.OrganizeAudio, Known(Environment.SpecialFolder.MyMusic)),
Documents = First(prefs.OrganizeDocuments, Known(Environment.SpecialFolder.MyDocuments)),
Installers = prefs.OrganizeInstallers ?? "",
Archives = prefs.OrganizeArchives ?? "",
Development = prefs.OrganizeDevelopment ?? ""
};
}
public void SaveDestinations(OrganizeDestinations destinations)
{
var stored = _preferences.Load();
_preferences.Save(stored with
{
OrganizePictures = EmptyToNull(destinations.Pictures),
OrganizeVideos = EmptyToNull(destinations.Videos),
OrganizeAudio = EmptyToNull(destinations.Audio),
OrganizeDocuments = EmptyToNull(destinations.Documents),
OrganizeInstallers = EmptyToNull(destinations.Installers),
OrganizeArchives = EmptyToNull(destinations.Archives),
OrganizeDevelopment = EmptyToNull(destinations.Development)
});
}
public OperationPlan Preview(string sourceRoot, OrganizeDestinations destinations)
=> _planner.Build(
sourceRoot,
destinations,
_enumerator,
path => _volumes.IsPathReachable(path),
path => _git.IsRepoRoot(path),
item => _hydration.WouldHydrateOnRead(item),
RenameBatchService.PathExists);
public async Task<OperationPlan> EnqueueAsync(
string sourceRoot,
OrganizeDestinations destinations,
OperationPlan? plan = null,
CancellationToken cancellationToken = default)
{
plan ??= Preview(sourceRoot, destinations);
if (!plan.CanEnqueue)
{
return plan;
}
SaveDestinations(destinations);
foreach (var op in plan.Operations)
{
if (op.Op == TransferOp.Move && op.DestinationPath is not null)
{
await _ops.MoveAsync([op.SourcePath], PathRules.Parent(op.DestinationPath), cancellationToken)
.ConfigureAwait(false);
}
}
return plan;
}
private static string Known(Environment.SpecialFolder folder)
=> Environment.GetFolderPath(folder);
private static string First(string? stored, string fallback)
=> string.IsNullOrWhiteSpace(stored) ? fallback : stored.Trim();
private static string? EmptyToNull(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -7,9 +7,9 @@ namespace Explorer.FileOperations;
public sealed class TransferQueue : BackgroundService
{
private readonly IShellFileOperations _shell;
private readonly IFileSystemEnumerator _enumerator;
private readonly IOperationExecutor _executor;
private readonly IIndexStore _store;
private readonly IVolumeService _volumes;
private readonly ILogger<TransferQueue> _logger;
private readonly List<TransferJob> _jobs = [];
private readonly object _gate = new();
@@ -20,19 +20,20 @@ public sealed class TransferQueue : BackgroundService
private long? _holdJobId;
private CancellationTokenSource? _runningCts;
private DateTime _lastChangedUtc = DateTime.MinValue;
private int _restored;
public event EventHandler? Changed;
public event EventHandler<TransferJob>? JobFinished;
public TransferQueue(
IShellFileOperations shell,
IFileSystemEnumerator enumerator,
IOperationExecutor executor,
IIndexStore store,
IVolumeService volumes,
ILogger<TransferQueue> logger)
{
_shell = shell;
_enumerator = enumerator;
_executor = executor;
_store = store;
_volumes = volumes;
_logger = logger;
_pauseRequested = () => _haltPause;
}
@@ -92,6 +93,69 @@ public sealed class TransferQueue : BackgroundService
}, cancellationToken).ConfigureAwait(false);
}
public Task EnqueueRenameAsync(string path, string newName, CancellationToken cancellationToken = default)
{
var dest = Path.Combine(PathRules.Parent(path), newName);
return EnqueueAsync(new TransferJob
{
Op = TransferOp.Rename,
SourcePath = path,
DestinationPath = dest,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
}
public Task EnqueueEmptyRecycleBinAsync(CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.EmptyRecycleBin,
SourcePath = LocationRoots.RecycleBin,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueExtractAsync(string archivePath, string destinationDirectory, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Extract,
SourcePath = archivePath,
DestinationPath = destinationDirectory,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public Task EnqueueCompressAsync(IReadOnlyList<string> sources, string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.Compress,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueAddToArchiveAsync(string archivePath, IReadOnlyList<string> sources, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.AddToArchive,
SourcePath = string.Join("|", sources),
DestinationPath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow,
AdditionalSources = sources.ToList()
}, cancellationToken);
public Task EnqueueVerifyArchiveAsync(string archivePath, CancellationToken cancellationToken = default)
=> EnqueueAsync(new TransferJob
{
Op = TransferOp.VerifyArchive,
SourcePath = archivePath,
Status = TransferStatus.Queued,
CreatedUtc = DateTimeOffset.UtcNow
}, cancellationToken);
public void PauseAll()
{
_queuePaused = true;
@@ -110,6 +174,7 @@ public sealed class TransferQueue : BackgroundService
&& (j.StartedUtc is not null || j.CurrentPath is not null)))
{
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
}
@@ -130,6 +195,7 @@ public sealed class TransferQueue : BackgroundService
if (job.Status == TransferStatus.Queued)
{
job.Status = TransferStatus.Paused;
QueuePersist(job);
}
else if (job.Status == TransferStatus.Running)
{
@@ -152,6 +218,7 @@ public sealed class TransferQueue : BackgroundService
}
job.Status = TransferStatus.Queued;
QueuePersist(job);
}
_queuePaused = false;
@@ -165,6 +232,27 @@ public sealed class TransferQueue : BackgroundService
RaiseChanged();
}
public void Retry(long jobId)
{
lock (_gate)
{
var job = Find(jobId);
if (job is null || job.Status != TransferStatus.Failed)
{
return;
}
job.RetryCount++;
job.Status = TransferStatus.Queued;
job.Error = null;
job.WaitReason = null;
QueuePersist(job);
}
Pulse();
RaiseChanged();
}
public void Cancel(long jobId)
{
CancellationTokenSource? running = null;
@@ -176,9 +264,10 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Paused)
if (job.Status is TransferStatus.Queued or TransferStatus.Paused or TransferStatus.Waiting)
{
job.Status = TransferStatus.Cancelled;
QueuePersist(job);
_jobs.Remove(job);
if (_holdJobId == jobId)
{
@@ -195,6 +284,8 @@ public sealed class TransferQueue : BackgroundService
}
else
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
}
@@ -207,7 +298,15 @@ public sealed class TransferQueue : BackgroundService
{
lock (_gate)
{
_jobs.RemoveAll(j => j.Id == jobId && j.Status is TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed);
var job = Find(jobId);
if (job is null || job.Status is not (TransferStatus.Done or TransferStatus.Cancelled or TransferStatus.Failed))
{
return;
}
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
}
RaiseChanged();
@@ -218,7 +317,13 @@ public sealed class TransferQueue : BackgroundService
var removed = false;
lock (_gate)
{
removed = _jobs.RemoveAll(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled) > 0;
foreach (var job in _jobs.Where(j => j.Status is TransferStatus.Done or TransferStatus.Cancelled).ToList())
{
job.Dismissed = true;
QueuePersist(job);
_jobs.Remove(job);
removed = true;
}
}
if (removed)
@@ -255,6 +360,7 @@ public sealed class TransferQueue : BackgroundService
_jobs.RemoveAt(index);
_jobs.Insert(target, job);
PersistOrder();
}
Pulse();
@@ -262,12 +368,47 @@ public sealed class TransferQueue : BackgroundService
return true;
}
public void NotifyAvailability()
{
var resumed = false;
lock (_gate)
{
foreach (var job in _jobs.Where(j => j.Status == TransferStatus.Waiting))
{
if (!OperationAvailability.IsReady(_volumes, job))
{
continue;
}
job.Status = TransferStatus.Queued;
job.WaitReason = null;
QueuePersist(job);
resumed = true;
}
}
if (resumed)
{
Pulse();
RaiseChanged();
}
}
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
{
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
}
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
lock (_gate)
{
_jobs.Add(job);
if (_jobs.All(j => j.Id != job.Id))
{
_jobs.Add(job);
}
}
Pulse();
@@ -276,6 +417,7 @@ public sealed class TransferQueue : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RestoreAsync(stoppingToken).ConfigureAwait(false);
while (!stoppingToken.IsCancellationRequested)
{
TransferJob? job;
@@ -298,10 +440,71 @@ public sealed class TransferQueue : BackgroundService
continue;
}
if (!OperationAvailability.IsReady(_volumes, job))
{
job.Status = TransferStatus.Waiting;
job.WaitReason = FileOperationErrors.DestinationUnavailable;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
continue;
}
await RunAsync(job, stoppingToken).ConfigureAwait(false);
}
}
private async Task RestoreAsync(CancellationToken cancellationToken)
{
if (Interlocked.Exchange(ref _restored, 1) == 1)
{
return;
}
IReadOnlyList<TransferJob> incomplete;
try
{
incomplete = await _store.Transfers.GetIncompleteAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to restore file operations queue");
return;
}
var interrupted = new List<TransferJob>();
lock (_gate)
{
var known = _jobs.Select(j => j.Id).ToHashSet();
foreach (var job in incomplete)
{
if (!known.Add(job.Id))
{
continue;
}
if (job.Status is TransferStatus.Running or TransferStatus.Cancelling)
{
job.Status = TransferStatus.Paused;
job.Error = null;
interrupted.Add(job);
}
_jobs.Add(job);
}
}
foreach (var job in interrupted)
{
await Persist(job).ConfigureAwait(false);
}
if (incomplete.Count > 0)
{
Pulse();
RaiseChanged();
}
}
private bool CanStartNext()
{
if (_queuePaused)
@@ -324,20 +527,17 @@ public sealed class TransferQueue : BackgroundService
job.Status = TransferStatus.Running;
job.StartedUtc ??= DateTimeOffset.UtcNow;
job.Error = null;
job.WaitReason = null;
await Persist(job).ConfigureAwait(false);
RaiseChanged();
try
{
switch (job.Op)
await _executor.ExecuteAsync(job, _pauseRequested, () => RaiseChanged(throttled: true), linked.Token)
.ConfigureAwait(false);
if (_haltPause && job.Status == TransferStatus.Paused)
{
case TransferOp.Copy:
await CopyOrMove(job, move: false, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Move:
await CopyOrMove(job, move: true, linked.Token).ConfigureAwait(false);
break;
case TransferOp.Delete:
Delete(job);
break;
_haltPause = false;
}
if (job.Status == TransferStatus.Paused)
@@ -347,6 +547,13 @@ public sealed class TransferQueue : BackgroundService
return;
}
if (job.Status == TransferStatus.Waiting)
{
await Persist(job).ConfigureAwait(false);
RaiseChanged();
return;
}
if (job.Status == TransferStatus.Cancelling)
{
job.Status = TransferStatus.Cancelled;
@@ -369,7 +576,7 @@ public sealed class TransferQueue : BackgroundService
{
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
job.Status = TransferStatus.Failed;
job.Error = ex.Message;
job.Error = FileOperationErrors.IsLock(ex.Message) ? FileOperationErrors.FileInUse : ex.Message;
}
finally
{
@@ -389,166 +596,6 @@ public sealed class TransferQueue : BackgroundService
JobFinished?.Invoke(this, job);
}
private async Task CopyOrMove(TransferJob job, bool move, CancellationToken stoppingToken)
{
var src = job.SourcePath;
var dst = job.DestinationPath ?? throw new InvalidOperationException("Missing destination");
var item = _enumerator.GetItem(src);
if (item is null)
{
job.Status = TransferStatus.Failed;
job.Error = "Source not found";
return;
}
if (item.IsDirectory)
{
await CopyDirectory(src, dst, move, job, stoppingToken).ConfigureAwait(false);
if (move && job.Status is not TransferStatus.Failed and not TransferStatus.Cancelling and not TransferStatus.Paused)
{
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
}
return;
}
job.FilesTotal = Math.Max(job.FilesTotal, 1);
job.CurrentPath = src;
var resume = File.Exists(PathRules.ToExtended(dst));
if (!TransferFile(src, dst, move, resume, job, committed: 0, stoppingToken, out var error))
{
ApplyHalt(job, src, error);
return;
}
job.BytesDone = job.BytesTotal ?? job.BytesDone;
job.FilesDone = 1;
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
{
Directory.CreateDirectory(PathRules.ToExtended(dst));
job.FilesDone = 0;
job.FilesTotal = 0;
job.BytesDone = 0;
job.BytesTotal = 0;
var stack = new Stack<(string From, string To)>();
stack.Push((src, dst));
while (stack.Count > 0)
{
stoppingToken.ThrowIfCancellationRequested();
if (job.Status == TransferStatus.Cancelling || _haltPause)
{
ApplyHalt(job, job.CurrentPath, _haltPause ? "Paused" : "Cancelled");
return;
}
var (from, to) = stack.Pop();
var children = _enumerator.EnumerateChildrenSafe(from, out var error);
if (error is not null)
{
job.Error = error;
continue;
}
foreach (var child in children)
{
var childDest = Path.Combine(to, child.Name);
if (child.IsDirectory)
{
Directory.CreateDirectory(PathRules.ToExtended(childDest));
stack.Push((child.FullPath, childDest));
continue;
}
job.FilesTotal++;
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
var destExists = File.Exists(PathRules.ToExtended(childDest));
var destLen = destExists ? new FileInfo(PathRules.ToExtended(childDest)).Length : 0;
if (destExists && destLen == child.SizeBytes)
{
job.BytesDone += child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
continue;
}
job.CurrentPath = child.FullPath;
var committed = job.BytesDone;
if (!TransferFile(child.FullPath, childDest, move, destExists, job, committed, stoppingToken, out var err))
{
ApplyHalt(job, child.FullPath, err);
return;
}
job.BytesDone = committed + child.SizeBytes;
job.FilesDone++;
RaiseChanged(throttled: true);
}
}
job.CurrentPath = null;
await Task.CompletedTask.ConfigureAwait(false);
}
private bool TransferFile(
string src,
string dst,
bool move,
bool resumePartial,
TransferJob job,
long committed,
CancellationToken stoppingToken,
out string? error)
{
var progress = new Progress<long>(b =>
{
job.BytesDone = committed + b;
RaiseChanged(throttled: true);
});
var ok = move
? _shell.MoveFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested)
: _shell.CopyFileWithProgress(src, dst, resumePartial, progress, stoppingToken, out error, _pauseRequested);
return ok;
}
private void ApplyHalt(TransferJob job, string? path, string? error)
{
job.CurrentPath = path;
if (job.Status == TransferStatus.Cancelling || error == "Cancelled")
{
job.Status = TransferStatus.Cancelling;
job.Error = error == "Cancelled" ? null : error;
return;
}
if (_haltPause || error == "Paused")
{
job.Status = TransferStatus.Paused;
job.Error = null;
_haltPause = false;
return;
}
job.Status = TransferStatus.Failed;
job.Error = error;
}
private void Delete(TransferJob job)
{
var paths = job.AdditionalSources.Count > 0
? job.AdditionalSources
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
var recycle = !string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal);
if (!_shell.Delete(paths, recycle, out var error))
{
job.Status = TransferStatus.Failed;
job.Error = error;
}
}
private TransferJob? Find(long jobId) => _jobs.FirstOrDefault(j => j.Id == jobId);
private void Pulse()
@@ -575,5 +622,28 @@ public sealed class TransferQueue : BackgroundService
Changed?.Invoke(this, EventArgs.Empty);
}
private void PersistOrder()
{
for (var i = 0; i < _jobs.Count; i++)
{
_jobs[i].SortOrder = i;
QueuePersist(_jobs[i]);
}
}
private void QueuePersist(TransferJob job) => _ = PersistSafe(job);
private async Task PersistSafe(TransferJob job)
{
try
{
await Persist(job).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist transfer job {Id}", job.Id);
}
}
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
}

View File

@@ -0,0 +1,10 @@
using Explorer.Presentation.ViewModels;
namespace Explorer.Presentation;
public interface IThumbnailService
{
void OnViewportChanged(ExplorerPaneViewModel pane, IReadOnlyList<FolderItemViewModel> visible);
void OnSessionChanged(ExplorerPaneViewModel pane, int generation);
void OnPreviewEnabledChanged(ExplorerPaneViewModel pane);
}

View File

@@ -0,0 +1,43 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace Explorer.Presentation;
public sealed class RangeObservableCollection<T> : ObservableCollection<T>
{
public void AddRange(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
var list = items as IList<T> ?? items.ToList();
if (list.Count == 0)
{
return;
}
CheckReentrancy();
foreach (var item in list)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public void ReplaceAll(IList<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}

View File

@@ -0,0 +1,123 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Application;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class BatchRenameViewModel : ObservableObject
{
private readonly RenamePlanner _planner;
private readonly RenameBatchService _batches;
private readonly IReadOnlyList<RenameSubject> _subjects;
[ObservableProperty] private string _search = "";
[ObservableProperty] private string _replace = "";
[ObservableProperty] private bool _useRegex;
[ObservableProperty] private bool _matchCase;
[ObservableProperty] private bool _includeExtensionInSearch;
[ObservableProperty] private string _prefix = "";
[ObservableProperty] private string _suffix = "";
[ObservableProperty] private bool _useCounter;
[ObservableProperty] private int _counterStart = 1;
[ObservableProperty] private int _counterStep = 1;
[ObservableProperty] private int _counterPadding;
[ObservableProperty] private RenameCaseMode _caseMode = RenameCaseMode.Unchanged;
[ObservableProperty] private bool _changeExtension;
[ObservableProperty] private string _newExtension = "";
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _canQueue;
public BatchRenameViewModel(
IReadOnlyList<RenameSubject> subjects,
RenamePlanner planner,
RenameBatchService batches)
{
_subjects = subjects;
_planner = planner;
_batches = batches;
Rows = [];
Rebuild();
}
public ObservableCollection<RenamePreviewRow> Rows { get; }
public IReadOnlyList<RenameCaseOption> CaseOptions { get; } =
[
new("Leave case", RenameCaseMode.Unchanged),
new("lowercase", RenameCaseMode.Lower),
new("UPPERCASE", RenameCaseMode.Upper),
new("Title Case", RenameCaseMode.Title)
];
public event EventHandler? CloseRequested;
public RenameRuleSet Rules => new()
{
Search = Search,
Replace = Replace,
UseRegex = UseRegex,
MatchCase = MatchCase,
IncludeExtensionInSearch = IncludeExtensionInSearch,
Prefix = Prefix,
Suffix = Suffix,
UseCounter = UseCounter,
CounterStart = CounterStart,
CounterStep = Math.Max(1, CounterStep),
CounterPadding = Math.Max(0, CounterPadding),
CaseMode = CaseMode,
ChangeExtension = ChangeExtension,
NewExtension = NewExtension
};
[RelayCommand]
public async Task QueueAsync()
{
var plan = _batches.Preview(_subjects, Rules);
if (!plan.CanEnqueue)
{
Status = plan.Issues.FirstOrDefault()?.Message ?? "Nothing to rename.";
return;
}
await _batches.EnqueueAsync(plan).ConfigureAwait(true);
CloseRequested?.Invoke(this, EventArgs.Empty);
}
partial void OnSearchChanged(string value) => Rebuild();
partial void OnReplaceChanged(string value) => Rebuild();
partial void OnUseRegexChanged(bool value) => Rebuild();
partial void OnMatchCaseChanged(bool value) => Rebuild();
partial void OnIncludeExtensionInSearchChanged(bool value) => Rebuild();
partial void OnPrefixChanged(string value) => Rebuild();
partial void OnSuffixChanged(string value) => Rebuild();
partial void OnUseCounterChanged(bool value) => Rebuild();
partial void OnCounterStartChanged(int value) => Rebuild();
partial void OnCounterStepChanged(int value) => Rebuild();
partial void OnCounterPaddingChanged(int value) => Rebuild();
partial void OnCaseModeChanged(RenameCaseMode value) => Rebuild();
partial void OnChangeExtensionChanged(bool value) => Rebuild();
partial void OnNewExtensionChanged(string value) => Rebuild();
private void Rebuild()
{
var plan = _planner.Build(_subjects, Rules, RenameBatchService.PathExists);
Rows.Clear();
foreach (var row in plan.Preview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var unchanged = plan.Preview.Count(r => r.Unchanged);
Status = errors > 0
? $"{_subjects.Count} items · {errors} errors"
: plan.Operations.Count == 0
? $"{_subjects.Count} items · nothing to rename"
: $"{plan.Operations.Count} will be queued · {unchanged} unchanged";
}
}
public sealed record RenameCaseOption(string Label, RenameCaseMode Mode);

View File

@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
@@ -11,19 +12,32 @@ public sealed partial class DuplicateViewModel : ObservableObject
{
private readonly IIndexStore _store;
private readonly SourceManager _sources;
private readonly AnalysisService _analysis;
[ObservableProperty] private bool _isOpen;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _status = "";
[ObservableProperty] private bool _showIntentional;
[ObservableProperty] private bool _showHardlinks;
public DuplicateViewModel(IIndexStore store, SourceManager sources)
public DuplicateViewModel(IIndexStore store, SourceManager sources, AnalysisService analysis)
{
_store = store;
_sources = sources;
_analysis = analysis;
Groups = [];
}
public ObservableCollection<string> Groups { get; }
public ObservableCollection<DuplicateGroupViewModel> Groups { get; }
public event EventHandler<string>? RevealPath;
[RelayCommand]
public Task CloseAsync()
{
IsOpen = false;
return Task.CompletedTask;
}
[RelayCommand]
public async Task OpenAsync()
@@ -34,38 +48,41 @@ public sealed partial class DuplicateViewModel : ObservableObject
Groups.Clear();
try
{
var lines = await Task.Run(async () =>
var groups = await Task.Run(async () =>
{
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 200).ConfigureAwait(false);
var classified = await _analysis.GetClassifiedDuplicatesAsync(200).ConfigureAwait(false);
var sources = (await _sources.RefreshOnlineStateAsync().ConfigureAwait(false)).ToDictionary(s => s.Id);
var result = new List<string>();
foreach (var g in groups)
{
if (g.SameFileId)
{
continue;
}
var paths = string.Join(" | ", g.Entries.Select(e =>
{
sources.TryGetValue(e.SourceId, out var s);
return PathRules.Combine(s?.LastRootPath ?? s?.DisplayName ?? "", e.PathRel);
}));
result.Add($"{Formatters.Size(g.SizeBytes)} · {g.Entries.Count} files · {paths}");
}
return result;
return classified
.Select(g => DuplicateGroupViewModel.From(g, sources))
.ToList();
}).ConfigureAwait(true);
foreach (var line in lines)
var hiddenIntentional = 0;
var hiddenHardlinks = 0;
foreach (var group in groups)
{
Groups.Add(line);
if (group.Classification == DuplicateClass.Hardlink)
{
if (!ShowHardlinks)
{
hiddenHardlinks++;
continue;
}
}
else if (DuplicateClassifier.IsIntentional(group.Classification))
{
if (!ShowIntentional)
{
hiddenIntentional++;
continue;
}
}
Groups.Add(group);
}
Status = Groups.Count == 0
? "No confirmed duplicates yet. Hashing continues in the background."
: $"{Groups.Count} duplicate groups";
Status = BuildStatus(Groups.Count, hiddenIntentional, hiddenHardlinks);
}
catch (Exception)
{
@@ -76,4 +93,132 @@ public sealed partial class DuplicateViewModel : ObservableObject
IsBusy = false;
}
}
[RelayCommand]
public async Task MarkIntentionalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.IntentionalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public async Task MarkAccidentalAsync(DuplicateGroupViewModel? group)
{
if (group is null)
{
return;
}
await _analysis.MarkDuplicateGroupAsync(group.Entries, FileRelationKind.AccidentalDuplicate)
.ConfigureAwait(true);
await OpenAsync().ConfigureAwait(true);
}
[RelayCommand]
public void Reveal(DuplicateFileViewModel? file)
{
if (file is null || string.IsNullOrWhiteSpace(file.FullPath))
{
return;
}
RevealPath?.Invoke(this, file.FullPath);
}
partial void OnShowIntentionalChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
partial void OnShowHardlinksChanged(bool value)
{
if (IsOpen && !IsBusy)
{
_ = OpenAsync();
}
}
private static string BuildStatus(int visible, int hiddenIntentional, int hiddenHardlinks)
{
if (visible > 0)
{
var extra = hiddenIntentional + hiddenHardlinks;
return extra == 0
? $"{visible} duplicate groups"
: $"{visible} duplicate groups · {extra} hidden as intentional or hard links";
}
if (hiddenIntentional + hiddenHardlinks > 0)
{
return "No accidental duplicates. Turn on intentional or hard links to review those.";
}
return "No confirmed duplicates yet. Hashing continues in the background.";
}
}
public sealed class DuplicateGroupViewModel
{
public required IReadOnlyList<IndexEntry> Entries { get; init; }
public DuplicateClass Classification { get; init; }
public required string ClassLabel { get; init; }
public required string SizeLabel { get; init; }
public required string Summary { get; init; }
public required string WastedLabel { get; init; }
public bool CanMarkIntentional { get; init; }
public bool CanMarkAccidental { get; init; }
public IReadOnlyList<DuplicateFileViewModel> Files { get; init; } = [];
public static DuplicateGroupViewModel From(
ClassifiedDuplicateGroup classified,
IReadOnlyDictionary<long, Source> sources)
{
var files = classified.Group.Entries.Select(entry =>
{
sources.TryGetValue(entry.SourceId, out var source);
var root = source?.LastRootPath ?? source?.DisplayName ?? "";
var full = PathRules.Combine(root, entry.PathRel);
var hardlink = classified.Group.Entries.Count(e =>
e.Id != entry.Id && e.SourceId == entry.SourceId && e.FileId is > 0 && e.FileId == entry.FileId) > 0;
return new DuplicateFileViewModel
{
Name = entry.Name,
FullPath = full,
LocationLabel = hardlink ? $"{full} (hard link)" : full
};
}).ToList();
return new DuplicateGroupViewModel
{
Entries = classified.Group.Entries,
Classification = classified.Classification,
ClassLabel = DuplicateClassifier.Label(classified.Classification),
SizeLabel = Formatters.Size(classified.Group.SizeBytes),
Summary = $"{classified.UniqueFileCount} copies · {classified.Group.Entries.Count} names",
WastedLabel = classified.WastedBytes > 0
? Formatters.Size(classified.WastedBytes) + " wasted"
: "No extra space",
CanMarkIntentional = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Intentional,
CanMarkAccidental = classified.Classification != DuplicateClass.Hardlink
&& classified.Classification != DuplicateClass.Accidental,
Files = files
};
}
}
public sealed class DuplicateFileViewModel
{
public required string Name { get; init; }
public required string FullPath { get; init; }
public required string LocationLabel { get; init; }
}

View File

@@ -14,8 +14,16 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
private readonly FileOperationService _ops;
private readonly IndexingCoordinator _indexing;
private readonly SourceManager _sources;
private readonly IGitStatusProvider _git;
private readonly IThumbnailService? _thumbnails;
private readonly NavigationHistory _history = new();
private CancellationTokenSource? _loadCts;
private BrowseViewport? _viewport;
private bool _userChoseSort;
private bool _awaitingSizeSort;
private bool _didAutoSort;
private int _browseGeneration;
private Dictionary<string, FolderItemViewModel>? _rows;
[ObservableProperty] private string _currentPath = "This PC";
[ObservableProperty] private bool _isOffline;
@@ -28,34 +36,53 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[ObservableProperty] private string _sortProperty = "Name";
[ObservableProperty] private bool _sortDescending;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string _gitBadge = "";
[ObservableProperty] private bool _hasGitRepo;
public bool HasGitBadge => !string.IsNullOrEmpty(GitBadge);
public ExplorerPaneViewModel(
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
_indexing = indexing;
_sources = sources;
Items = [];
_git = git;
_thumbnails = thumbnails;
Items = new RangeObservableCollection<FolderItemViewModel>();
SelectedItems = [];
}
public ObservableCollection<FolderItemViewModel> Items { get; }
partial void OnGitBadgeChanged(string value) => OnPropertyChanged(nameof(HasGitBadge));
public RangeObservableCollection<FolderItemViewModel> Items { get; }
public ObservableCollection<FolderItemViewModel> SelectedItems { get; }
public IReadOnlyList<BreadcrumbSegment> Breadcrumb => BuildBreadcrumb(CurrentPath);
public bool CanGoBack => _history.CanGoBack;
public bool CanGoForward => _history.CanGoForward;
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath) || CurrentPath is LocationRoots.Network or LocationRoots.Cloud;
public bool CanGoUp => !LocationRoots.IsVirtual(CurrentPath)
|| CurrentPath is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin;
public async Task NavigateAsync(string path, bool addHistory = true)
{
_loadCts?.Cancel();
_loadCts?.Dispose();
_loadCts = new CancellationTokenSource();
var ct = _loadCts.Token;
var generation = Interlocked.Increment(ref _browseGeneration);
_thumbnails?.OnSessionChanged(this, generation);
_userChoseSort = false;
_awaitingSizeSort = false;
_didAutoSort = false;
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
_viewport = new BrowseViewport();
IsBusy = true;
StatusMessage = null;
try
{
CurrentPath = path;
@@ -71,39 +98,86 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
if (LocationRoots.IsVirtual(path))
{
GitBadge = "";
HasGitRepo = false;
await LoadVirtualRootAsync(path, ct).ConfigureAwait(true);
return;
}
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
IsOffline = listing.IsOffline;
StatusMessage = listing.Error;
Items.Clear();
CurrentSource = await _sources.FindByPathAsync(path, ct).ConfigureAwait(true);
ShowIndexBanner = CurrentSource is { IsIndexed: false, Status: SourceStatus.Online };
IndexBannerText = ShowIndexBanner
? "Build an index for this location to enable instant search and folder sizes."
: null;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
var sizeFromIndex = CurrentSource is { IsIndexed: true };
Items.Clear();
foreach (var item in listing.Items)
var published = false;
await foreach (var delta in _browse.ListProgressiveAsync(path, _viewport, ct).ConfigureAwait(true))
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
if (generation != _browseGeneration || path != CurrentPath)
{
return;
}
IsOffline = delta.IsOffline;
if (delta.Error is not null)
{
StatusMessage = delta.Error;
}
if (delta.Added.Count > 0)
{
var rows = delta.Added
.Select(item => new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory))
.ToList();
foreach (var row in rows)
{
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
published = true;
IsBusy = false;
}
if (delta.Updated.Count > 0)
{
ApplyUpdates(delta.Updated, sizeFromIndex);
}
if (delta.EnumerationComplete)
{
IsBusy = false;
if (CurrentSource is { Status: SourceStatus.Stale })
{
StatusMessage = string.IsNullOrEmpty(StatusMessage)
? "Index may be out of date."
: StatusMessage;
}
TryAutoSort(sizeMetadataReady: CurrentSource is not { IsIndexed: true });
_ = ApplyGitAsync(path, ct);
KickThumbnails();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
}
}
if ((delta.CompletedStages & ItemHydrationFlags.Index) != 0
|| delta.HydrationComplete)
{
TryAutoSort(sizeMetadataReady: true);
}
}
ApplyCurrentSort();
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src
&& Directory.Exists(path))
if (!published)
{
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
_indexing.EnqueueReconcile(src.Id, rel);
IsBusy = false;
}
}
catch (OperationCanceledException)
@@ -112,30 +186,172 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
}
finally
{
IsBusy = false;
if (generation == _browseGeneration)
{
IsBusy = false;
}
}
}
public int BrowseGeneration => _browseGeneration;
public void NotifyViewport(IReadOnlyList<string> visiblePaths)
{
_viewport?.SetVisible(visiblePaths);
if (_thumbnails is null || ViewMode != FolderViewMode.Preview || _rows is null)
{
return;
}
var visible = new List<FolderItemViewModel>(visiblePaths.Count);
foreach (var path in visiblePaths)
{
if (!string.IsNullOrEmpty(path) && _rows.TryGetValue(path, out var item))
{
visible.Add(item);
}
}
_thumbnails.OnViewportChanged(this, visible);
}
public IReadOnlyList<FolderItemViewModel> SnapshotItems() => Items.ToList();
private void KickThumbnails()
{
if (ViewMode != FolderViewMode.Preview || _thumbnails is null || Items.Count == 0)
{
return;
}
_thumbnails.OnViewportChanged(this, Items.Take(40).ToList());
}
partial void OnViewModeChanged(FolderViewMode value)
=> _thumbnails?.OnPreviewEnabledChanged(this);
private void ApplyUpdates(IReadOnlyList<FileSystemItem> updated, bool sizeFromIndex)
{
if (updated.Count == 0 || _rows is null)
{
return;
}
foreach (var item in updated)
{
if (_rows.TryGetValue(item.FullPath, out var vm))
{
vm.Apply(item, sizeFromIndex && item.IsDirectory);
}
}
}
private void TryAutoSort(bool sizeMetadataReady)
{
if (_userChoseSort)
{
return;
}
if (FolderListingSort.ShouldDeferAutoSort(SortProperty, enumerationComplete: true, sizeMetadataReady))
{
_awaitingSizeSort = SortProperty == "Size";
return;
}
if (_didAutoSort && !_awaitingSizeSort)
{
return;
}
_awaitingSizeSort = false;
_didAutoSort = true;
ApplyCurrentSort();
}
private async Task LoadVirtualRootAsync(string path, CancellationToken cancellationToken)
{
IsOffline = false;
ShowIndexBanner = false;
CurrentSource = null;
Items.Clear();
_rows = new Dictionary<string, FolderItemViewModel>(StringComparer.OrdinalIgnoreCase);
var listing = path switch
{
LocationRoots.Network => await _browse.ListNetworkAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.Cloud => await _browse.ListCloudAsync(cancellationToken).ConfigureAwait(true),
LocationRoots.RecycleBin => _browse.ListRecycleBin(),
_ => await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true)
};
foreach (var item in listing.Items)
StatusMessage = listing.Error;
var rows = listing.Items.Select(item => new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0)).ToList();
foreach (var row in rows)
{
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
_rows[row.FullPath] = row;
}
Items.AddRange(rows);
ApplyCurrentSort();
}
private async Task ApplyGitAsync(string path, CancellationToken cancellationToken)
{
GitBadge = "";
HasGitRepo = false;
foreach (var item in Items)
{
item.SetGit(null);
}
if (LocationRoots.IsVirtual(path))
{
return;
}
var dirs = Items.Where(i => i.IsDirectory).Select(i => i.FullPath).Take(24).ToList();
try
{
var (folder, root, child) = await Task.Run(
async () =>
{
var status = await _git.GetStatusAsync(path, cancellationToken).ConfigureAwait(false);
var found = _git.FindRepoRoot(path);
var overlays = new List<(string Path, GitStatus? Status)>();
foreach (var dir in dirs)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_git.IsRepoRoot(dir))
{
continue;
}
overlays.Add((dir, await _git.GetStatusAsync(dir, cancellationToken).ConfigureAwait(false)));
}
return (status, found, overlays);
},
cancellationToken).ConfigureAwait(true);
if (cancellationToken.IsCancellationRequested || path != CurrentPath)
{
return;
}
GitBadge = folder?.Badge ?? "";
HasGitRepo = folder is not null || root is not null;
foreach (var (full, status) in child)
{
var vm = Items.FirstOrDefault(i =>
string.Equals(i.FullPath, full, StringComparison.OrdinalIgnoreCase));
vm?.SetGit(status);
}
}
catch (OperationCanceledException)
{
// superseded
}
}
[RelayCommand]
public Task BackAsync()
{
@@ -176,15 +392,23 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
[RelayCommand]
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
public Task OpenItemAsync(FolderItemViewModel item)
public async Task OpenItemAsync(FolderItemViewModel item)
{
if (item.Item.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(item.FullPath).ConfigureAwait(true);
var path = source?.LastRootPath ?? item.FullPath;
await NavigateAsync(path).ConfigureAwait(true);
return;
}
if (item.IsDirectory || _browse.CanBrowseArchive(item.Item.Name))
{
return NavigateAsync(item.FullPath);
await NavigateAsync(item.FullPath).ConfigureAwait(true);
return;
}
_ops.Open([item.FullPath]);
return Task.CompletedTask;
}
public void BuildIndex()
@@ -223,17 +447,15 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
SortDescending = property is "Size" or "Free" or "Modified";
}
_userChoseSort = true;
_awaitingSizeSort = false;
ApplyCurrentSort();
}
public void ApplyCurrentSort()
{
var ordered = OrderItems(Items, SortProperty, SortDescending).ToList();
Items.Clear();
foreach (var item in ordered)
{
Items.Add(item);
}
Items.ReplaceAll(OrderItems(Items, SortProperty, SortDescending).ToList());
_rows = Items.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
}
public static IEnumerable<FolderItemViewModel> OrderItems(
@@ -241,25 +463,10 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
string property,
bool descending)
{
var names = StringComparer.CurrentCultureIgnoreCase;
return property switch
{
"Size" => descending
? items.OrderByDescending(i => i.Item.SizeBytes).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.SizeBytes).ThenBy(i => i.Name, names),
"Free" => descending
? items.OrderByDescending(i => i.Item.FreeSpaceBytes ?? -1).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.FreeSpaceBytes ?? long.MaxValue).ThenBy(i => i.Name, names),
"Modified" => descending
? items.OrderByDescending(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names)
: items.OrderBy(i => i.Item.ModifiedUtc).ThenBy(i => i.Name, names),
"Type" => descending
? items.OrderByDescending(i => i.TypeLabel, names).ThenByDescending(i => i.Name, names)
: items.OrderBy(i => i.TypeLabel, names).ThenBy(i => i.Name, names),
_ => descending
? items.OrderBy(i => i.IsDirectory).ThenByDescending(i => i.Name, names)
: items.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, names)
};
var map = items as IList<FolderItemViewModel> ?? items.ToList();
var ordered = FolderListingSort.Order(map.Select(i => i.Item), property, descending);
var byPath = map.ToDictionary(i => i.FullPath, StringComparer.OrdinalIgnoreCase);
return ordered.Select(item => byPath[item.FullPath]);
}
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
@@ -269,7 +476,7 @@ public sealed partial class ExplorerPaneViewModel : ObservableObject
return [new BreadcrumbSegment(LocationRoots.ThisPc, LocationRoots.ThisPc, IsLast: true)];
}
if (path is LocationRoots.Network or LocationRoots.Cloud)
if (path is LocationRoots.Network or LocationRoots.Cloud or LocationRoots.RecycleBin)
{
return
[

View File

@@ -23,9 +23,11 @@ public sealed partial class ExplorerTabViewModel : ObservableObject
BrowseService browse,
FileOperationService ops,
IndexingCoordinator indexing,
SourceManager sources)
SourceManager sources,
IGitStatusProvider git,
IThumbnailService? thumbnails = null)
{
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources, git, thumbnails);
Left = _paneFactory();
Right = _paneFactory();
_activePane = Left;

View File

@@ -29,14 +29,27 @@ public sealed partial class FolderItemViewModel : ObservableObject
EditName = Item.Name;
}
public FileSystemItem Item { get; }
public bool SizeFromIndex { get; }
public FileSystemItem Item { get; private set; }
public bool SizeFromIndex { get; private set; }
public void Apply(FileSystemItem item, bool? sizeFromIndex = null)
{
Item = item;
if (sizeFromIndex is bool value)
{
SizeFromIndex = value;
}
OnPropertyChanged(string.Empty);
}
public string Name => Item.DisplayName ?? Item.Name;
public string FullPath => Item.FullPath;
public bool IsDirectory => Item.IsDirectory;
public string TypeLabel => Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string TypeLabel => Item.AvailableToImport
? "Available in Windows"
: Item.Location.IsRecycleBin
? "Recycle Bin"
: Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
public string SizeLabel => Item.SizeKnowledge switch
{
SizeKnowledge.Unknown when Item.Location.AccessDenied => "Access denied",
@@ -62,6 +75,22 @@ public sealed partial class FolderItemViewModel : ObservableObject
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
public string CloudStatus => Item.Cloud?.StatusText ?? "";
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
[ObservableProperty] private string _gitLabel = "";
public bool HasGitLabel => !string.IsNullOrEmpty(GitLabel);
[ObservableProperty] private object? _thumbnail;
public bool HasThumbnail => Thumbnail is not null;
public void SetGit(GitStatus? status)
{
GitLabel = status?.Badge ?? "";
}
public void SetThumbnail(object? image) => Thumbnail = image;
partial void OnGitLabelChanged(string value) => OnPropertyChanged(nameof(HasGitLabel));
partial void OnThumbnailChanged(object? value) => OnPropertyChanged(nameof(HasThumbnail));
public string SizeTooltip
{
get

View File

@@ -0,0 +1,228 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class FolderSyncViewModel : ObservableObject
{
private readonly FolderSyncService _sync;
private OperationPlan? _plan;
[ObservableProperty] private SyncProfile? _selected;
[ObservableProperty] private string _name = "Photos backup";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private SyncMode _mode = SyncMode.CopyUpdate;
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public FolderSyncViewModel(FolderSyncService sync)
{
_sync = sync;
Profiles = [];
Rows = [];
Modes =
[
new SyncModeOption("Copy / Update", SyncMode.CopyUpdate),
new SyncModeOption("Mirror", SyncMode.Mirror)
];
}
public ObservableCollection<SyncProfile> Profiles { get; }
public ObservableCollection<SyncPreviewRow> Rows { get; }
public IReadOnlyList<SyncModeOption> Modes { get; }
public bool AutoRunEnabled => Mode == SyncMode.CopyUpdate;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _sync.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
partial void OnSelectedChanged(SyncProfile? value)
{
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
Mode = value.Mode;
Excludes = value.Excludes;
AutoRun = value.AutoRun && value.Mode == SyncMode.CopyUpdate;
ClearPlan("Profile loaded. Preview to see what would change.");
}
partial void OnModeChanged(SyncMode value)
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (value != SyncMode.CopyUpdate)
{
AutoRun = false;
}
}
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New sync";
SourcePath = "";
DestPath = "";
Mode = SyncMode.CopyUpdate;
Excludes = "";
AutoRun = false;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(SourcePath) || string.IsNullOrWhiteSpace(DestPath))
{
Status = "Choose a source folder and a destination folder.";
return;
}
var profile = CurrentProfile();
profile.Id = await _sync.SaveAsync(profile).ConfigureAwait(true);
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _sync.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _sync.PreviewAsync(profile)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _sync.EnqueueAsync(profile, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public SyncProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Sync" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
Mode = Mode,
Excludes = Excludes ?? "",
AutoRun = AutoRun && Mode == SyncMode.CopyUpdate,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.SyncPreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Folders already match."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
}
public sealed record SyncModeOption(string Label, SyncMode Mode);

View File

@@ -22,6 +22,14 @@ public sealed partial class MainViewModel : ObservableObject
private readonly StorageProviderRegistry _providers;
private readonly CloudPlaceStore _cloudPlaces;
private readonly UiPreferencesStore _preferences;
private readonly RenamePlanner _renamePlanner;
private readonly RenameBatchService _renameBatches;
private readonly FolderSyncService _folderSync;
private readonly OperationProfileService _operationProfiles;
private readonly ReorganizeService _reorganize;
private readonly IGitStatusProvider _git;
private readonly IWorkspaceLauncher _workspace;
private readonly IThumbnailService? _thumbnails;
private List<string> _clipboard = [];
private bool _clipboardIsCut;
@@ -33,6 +41,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private bool _showCloudPin;
[ObservableProperty] private bool _showCloudDehydrate;
[ObservableProperty] private bool _showForgetSource;
[ObservableProperty] private bool _showEmptyRecycleBin;
[ObservableProperty] private bool _showImportWindowsLocation;
[ObservableProperty] private bool _showBatchRename;
[ObservableProperty] private bool _showRunProfile;
[ObservableProperty] private bool _showOrganizeFolder;
[ObservableProperty] private bool _canUndoRenameBatch;
[ObservableProperty] private bool _showExtractArchive;
[ObservableProperty] private bool _showCompress;
[ObservableProperty] private bool _showAddToArchive;
[ObservableProperty] private bool _showVerifyArchive;
[ObservableProperty] private bool _showOpenTerminal;
[ObservableProperty] private bool _showOpenInCursor;
[ObservableProperty] private bool _showGitActions;
private readonly IOsClipboard Clipboard;
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
@@ -51,7 +72,15 @@ public sealed partial class MainViewModel : ObservableObject
StorageProviderRegistry providers,
CloudPlaceStore cloudPlaces,
UiPreferencesStore preferences,
IVolumeService volumes)
IVolumeService volumes,
RenamePlanner renamePlanner,
RenameBatchService renameBatches,
FolderSyncService folderSync,
OperationProfileService operationProfiles,
ReorganizeService reorganize,
IGitStatusProvider git,
IWorkspaceLauncher workspace,
IThumbnailService? thumbnails = null)
{
_browse = browse;
_ops = ops;
@@ -61,13 +90,22 @@ public sealed partial class MainViewModel : ObservableObject
_providers = providers;
_cloudPlaces = cloudPlaces;
_preferences = preferences;
_renamePlanner = renamePlanner;
_renameBatches = renameBatches;
_folderSync = folderSync;
_operationProfiles = operationProfiles;
_reorganize = reorganize;
_git = git;
_workspace = workspace;
_thumbnails = thumbnails;
var prefs = preferences.Load();
Theme = prefs.Theme;
PathHistory = [];
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces, preferences);
Search = new SearchViewModel(search, sources, volumes);
Analysis = new AnalysisViewModel(analysis);
Duplicates = new DuplicateViewModel(store, sources);
Duplicates = new DuplicateViewModel(store, sources, analysis);
Duplicates.RevealPath += (_, path) => _ = RevealDuplicateAsync(path);
Transfers = new TransferQueueViewModel(transfers, preferences);
Tabs = [];
Clipboard = clipboard;
@@ -133,7 +171,7 @@ public sealed partial class MainViewModel : ObservableObject
[RelayCommand]
public async Task NewTabAsync()
{
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources, _git, _thumbnails);
WireTab(tab);
Tabs.Add(tab);
ActiveTab = tab;
@@ -170,6 +208,24 @@ public sealed partial class MainViewModel : ObservableObject
PathText = ActivePane.CurrentPath;
}
public async Task RevealDuplicateAsync(string path)
{
if (string.IsNullOrWhiteSpace(path) || LocationRoots.IsVirtual(path))
{
return;
}
var folder = Directory.Exists(path) ? path : PathRules.Parent(path);
if (string.IsNullOrWhiteSpace(folder) || LocationRoots.IsVirtual(folder))
{
return;
}
await ActivePane.NavigateAsync(folder).ConfigureAwait(true);
PathText = ActivePane.CurrentPath;
Footer = folder;
}
[RelayCommand]
public void SetView(string? mode)
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
@@ -232,6 +288,17 @@ public sealed partial class MainViewModel : ObservableObject
}
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
if (node.AvailableToImport)
{
var source = await _sources.EnsureForPathAsync(node.Path).ConfigureAwait(true);
var path = source?.LastRootPath ?? node.Path;
await Tree.ReloadAsync(path).ConfigureAwait(true);
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
PathText = path;
Footer = "Added Windows location. Indexing is optional.";
return;
}
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
{
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
@@ -363,6 +430,86 @@ public sealed partial class MainViewModel : ObservableObject
_ = RefreshFolderViewsAsync(ActivePane.CurrentPath);
}
public BatchRenameViewModel? CreateBatchRenameViewModel()
{
var items = ActivePane.SelectedItems
.Where(i => !LocationRoots.IsVirtual(i.FullPath))
.Select(i => new RenameSubject(i.FullPath, i.Item.Name, i.IsDirectory))
.ToList();
if (items.Count == 0)
{
Footer = "Select files or folders to rename.";
return null;
}
return new BatchRenameViewModel(items, _renamePlanner, _renameBatches);
}
public async Task RefreshUndoRenameAsync()
=> CanUndoRenameBatch = await _renameBatches.GetUndoableAsync().ConfigureAwait(true) is not null;
public FolderSyncViewModel CreateFolderSyncViewModel()
=> new(_folderSync);
public OperationProfilesViewModel CreateOperationProfilesViewModel()
=> new(_operationProfiles);
public ReorganizeViewModel CreateReorganizeViewModel()
=> new(_reorganize, OrganizeSourcePath());
public string? OrganizeSourcePath() => WorkspaceDirectory();
public Task<IReadOnlyList<OperationProfile>> ListOperationProfilesAsync()
=> _operationProfiles.ListAsync();
public IReadOnlyList<string> SelectedRealPaths()
=> RealSelected().Select(i => i.FullPath).ToList();
[RelayCommand]
public void OpenTerminal()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open a terminal.";
return;
}
_workspace.OpenTerminal(directory);
}
[RelayCommand]
public void OpenInCursor()
{
var directory = WorkspaceDirectory();
if (directory is null)
{
Footer = "Select a folder to open in Cursor.";
return;
}
if (!_workspace.TryOpenInCursor(directory))
{
Footer = "Cursor is not installed.";
}
}
[RelayCommand]
public async Task UndoRenameBatchAsync()
{
var plan = await _renameBatches.UndoLastAsync().ConfigureAwait(true);
await RefreshUndoRenameAsync().ConfigureAwait(true);
if (plan.HasErrors)
{
Footer = plan.Issues[0].Message;
return;
}
Footer = plan.Operations.Count == 0
? plan.Issues.FirstOrDefault()?.Message ?? "Nothing to undo."
: $"Undo rename queued ({plan.Operations.Count}).";
}
[RelayCommand]
public async Task BuildIndexAsync()
{
@@ -401,6 +548,23 @@ public sealed partial class MainViewModel : ObservableObject
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
ShowEmptyRecycleBin = ActivePane.CurrentPath == LocationRoots.RecycleBin
|| ActivePane.SelectedItems.Any(i => i.FullPath == LocationRoots.RecycleBin);
ShowImportWindowsLocation = ActivePane.SelectedItems.Count == 1
&& ActivePane.SelectedItems[0].Item.AvailableToImport;
ShowBatchRename = ActivePane.SelectedItems.Count > 0
&& ActivePane.SelectedItems.All(i => !LocationRoots.IsVirtual(i.FullPath));
ShowRunProfile = ShowBatchRename;
ShowOrganizeFolder = OrganizeSourcePath() is not null;
var real = ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
ShowExtractArchive = real.Count > 0 && real.All(i => !i.IsDirectory && ArchiveFormats.IsArchive(i.Item.Name));
ShowCompress = real.Count > 0;
ShowAddToArchive = real.Any(i => i.IsDirectory || !ArchiveFormats.IsArchive(i.Item.Name));
ShowVerifyArchive = ShowExtractArchive;
var target = WorkspaceDirectory();
ShowOpenTerminal = target is not null;
ShowOpenInCursor = target is not null;
ShowGitActions = ActivePane.HasGitRepo || !string.IsNullOrEmpty(ActivePane.GitBadge);
_ = RefreshForgetActionAsync();
}
@@ -411,6 +575,117 @@ public sealed partial class MainViewModel : ObservableObject
&& await _sources.CanForgetPathAsync(ActivePane.SelectedItems[0].FullPath).ConfigureAwait(true);
}
public async Task ExtractSelectedAsync(string? destinationDirectory)
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to extract.";
return;
}
var root = destinationDirectory;
if (string.IsNullOrWhiteSpace(root))
{
root = ActivePane.CurrentPath;
}
if (string.IsNullOrWhiteSpace(root) || LocationRoots.IsVirtual(root))
{
Footer = "Choose a folder to extract to.";
return;
}
foreach (var archive in archives)
{
var dest = Path.Combine(root, ArchiveFormats.Stem(archive.Item.Name));
await _ops.ExtractAsync(archive.FullPath, dest).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Extract queued." : $"{archives.Count} extracts queued.";
}
public async Task CompressSelectedAsync(ArchiveFormat format)
{
var items = RealSelected();
if (items.Count == 0)
{
Footer = "Select files or folders to compress.";
return;
}
var folder = ActivePane.CurrentPath;
if (LocationRoots.IsVirtual(folder))
{
folder = PathRules.Parent(items[0].FullPath);
}
var stem = items.Count == 1
? ArchiveFormats.Stem(items[0].Item.Name)
: (string.IsNullOrWhiteSpace(PathRules.GetFileName(folder)) ? "Archive" : PathRules.GetFileName(folder));
var ext = format == ArchiveFormat.SevenZip ? "7z" : "zip";
var archive = FileOperationService.UniqueArchivePath(folder, stem, ext);
await _ops.CompressAsync(items.Select(i => i.FullPath).ToList(), archive).ConfigureAwait(true);
Footer = $"Compress queued → {PathRules.GetFileName(archive)}.";
}
public async Task AddSelectedToArchiveAsync(string archivePath)
{
var items = RealSelected().Where(i => !ArchiveFormats.IsArchive(i.Item.Name) || i.IsDirectory).ToList();
if (items.Count == 0 || string.IsNullOrWhiteSpace(archivePath))
{
Footer = "Select files to add to an archive.";
return;
}
await _ops.AddToArchiveAsync(archivePath, items.Select(i => i.FullPath).ToList()).ConfigureAwait(true);
Footer = "Add to archive queued.";
}
public async Task VerifySelectedAsync()
{
var archives = RealSelected().Where(i => ArchiveFormats.IsArchive(i.Item.Name)).ToList();
if (archives.Count == 0)
{
Footer = "Select an archive to verify.";
return;
}
foreach (var archive in archives)
{
await _ops.VerifyArchiveAsync(archive.FullPath).ConfigureAwait(true);
}
Footer = archives.Count == 1 ? "Verify queued." : $"{archives.Count} verifies queued.";
}
private List<FolderItemViewModel> RealSelected()
=> ActivePane.SelectedItems.Where(IsRealFileSystemItem).ToList();
private static bool IsRealFileSystemItem(FolderItemViewModel item)
{
if (LocationRoots.IsVirtual(item.FullPath))
{
return false;
}
return item.IsDirectory
? Directory.Exists(item.FullPath)
: File.Exists(item.FullPath);
}
private string? WorkspaceDirectory()
{
if (ActivePane.SelectedItems.Count == 1 && ActivePane.SelectedItems[0].IsDirectory
&& IsRealFileSystemItem(ActivePane.SelectedItems[0]))
{
return ActivePane.SelectedItems[0].FullPath;
}
var path = ActivePane.CurrentPath;
return LocationRoots.IsVirtual(path) || !Directory.Exists(path) ? null : path;
}
public async Task<bool> ForgetSourceAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -456,6 +731,12 @@ public sealed partial class MainViewModel : ObservableObject
return true;
}
public Task EmptyRecycleBinAsync()
{
Footer = "Empty Recycle Bin queued.";
return _ops.EmptyRecycleBinAsync();
}
[RelayCommand]
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
@@ -752,6 +1033,11 @@ public sealed partial class MainViewModel : ObservableObject
RefreshCloudActions();
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
}
else if (propertyName is nameof(ExplorerPaneViewModel.GitBadge)
or nameof(ExplorerPaneViewModel.HasGitRepo))
{
RefreshCloudActions();
}
}
private async Task OnTransferFinishedAsync(TransferJob job)
@@ -774,7 +1060,7 @@ public sealed partial class MainViewModel : ObservableObject
}
Add(job.SourcePath);
if (job.Op != TransferOp.Delete)
if (job.Op != TransferOp.Delete && job.Op != TransferOp.EmptyRecycleBin)
{
Add(job.DestinationPath);
}
@@ -784,6 +1070,8 @@ public sealed partial class MainViewModel : ObservableObject
Add(extra);
}
_ = _folderSync.TryMarkRelationAsync(job);
foreach (var dir in dirs)
{
EnqueueReconcile(dir);
@@ -793,6 +1081,10 @@ public sealed partial class MainViewModel : ObservableObject
{
Footer = job.Error ?? "Delete failed.";
}
else if (job.Op == TransferOp.EmptyRecycleBin)
{
Footer = "Recycle Bin emptied.";
}
else if (job.Op == TransferOp.Delete)
{
Footer = string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)

View File

@@ -21,6 +21,7 @@ public sealed partial class NavNodeViewModel : ObservableObject
public string Glyph { get; init; } = "\uE8B7";
public bool IsPlaceholder { get; init; }
public bool IsGroup { get; init; }
public bool AvailableToImport { get; init; }
public long? SourceId { get; init; }
public bool CanRemove { get; init; }
}
@@ -83,11 +84,22 @@ public sealed class NavigationTreeViewModel
thisPc.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
thisPc.Children.Add(new NavNodeViewModel
{
Label = LocationRoots.RecycleBin,
Path = LocationRoots.RecycleBin,
Glyph = "\uE74D",
ChildrenLoaded = true
});
var untracked = (await _sources.ListUntrackedOnlineVolumesAsync(cancellationToken).ConfigureAwait(true))
.Where(fp => fp.Kind.IsNetwork())
.ToList();
var network = sources.Where(s => s.Kind.IsNetwork())
.OrderBy(s => PathRules.DriveLetterSortKey(s.LastRootPath))
.ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase)
.ToList();
if (prefs.GroupNetworkPlaces && network.Count > 0)
if (prefs.GroupNetworkPlaces && (network.Count > 0 || untracked.Count > 0))
{
var group = new NavNodeViewModel
{
@@ -103,6 +115,11 @@ public sealed class NavigationTreeViewModel
group.Children.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
group.Children.Add(CreateImportNode(fp));
}
Roots.Add(group);
}
else
@@ -111,6 +128,11 @@ public sealed class NavigationTreeViewModel
{
Roots.Add(CreateSourceNode(source, _sources.CanForget(source)));
}
foreach (var fp in untracked)
{
Roots.Add(CreateImportNode(fp));
}
}
var places = CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
@@ -404,6 +426,17 @@ public sealed class NavigationTreeViewModel
return node;
}
private static NavNodeViewModel CreateImportNode(VolumeFingerprint fingerprint)
=> new()
{
Label = (fingerprint.DisplayName ?? fingerprint.RootPath) + " (Windows)",
Path = fingerprint.RootPath,
Status = "Available in Windows",
Glyph = "\uE968",
ChildrenLoaded = true,
AvailableToImport = true
};
private static int ProviderOrder(string providerId) => providerId switch
{
"onedrive" => 0,

View File

@@ -0,0 +1,320 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class OperationProfilesViewModel : ObservableObject
{
private readonly OperationProfileService _profiles;
private OperationPlan? _plan;
private IReadOnlyList<string>? _sourceOverride;
[ObservableProperty] private OperationProfile? _selected;
[ObservableProperty] private string _name = "New profile";
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _destPath = "";
[ObservableProperty] private bool _requireGitClean;
[ObservableProperty] private bool _doCompress;
[ObservableProperty] private ArchiveFormat _archiveFormat = ArchiveFormat.SevenZip;
[ObservableProperty] private bool _doCopy = true;
[ObservableProperty] private bool _doRename;
[ObservableProperty] private string _renamePrefix = "";
[ObservableProperty] private string _renameSuffix = "";
[ObservableProperty] private string _renameSearch = "";
[ObservableProperty] private string _renameReplace = "";
[ObservableProperty] private string _excludes = "";
[ObservableProperty] private bool _autoRun;
[ObservableProperty] private string _status = "Save a profile, then Preview.";
[ObservableProperty] private bool _canQueue;
public OperationProfilesViewModel(OperationProfileService profiles)
{
_profiles = profiles;
Profiles = [];
Rows = [];
Formats =
[
new ArchiveFormatOption("7-Zip (.7z)", ArchiveFormat.SevenZip),
new ArchiveFormatOption("ZIP", ArchiveFormat.Zip)
];
}
public ObservableCollection<OperationProfile> Profiles { get; }
public ObservableCollection<ProfilePreviewRow> Rows { get; }
public IReadOnlyList<ArchiveFormatOption> Formats { get; }
public bool AutoRunEnabled => DoCopy && !DoCompress && !HasRenameText;
public bool CompressOptionsEnabled => DoCompress;
public async Task LoadAsync()
{
Profiles.Clear();
foreach (var profile in await _profiles.ListAsync().ConfigureAwait(true))
{
Profiles.Add(profile);
}
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
}
public async Task RunOnAsync(long profileId, IReadOnlyList<string> sources)
{
var match = Profiles.FirstOrDefault(p => p.Id == profileId);
if (match is null)
{
Status = "That profile is no longer available.";
return;
}
Selected = match;
_sourceOverride = sources;
await AnalyzeAsync().ConfigureAwait(true);
}
partial void OnSelectedChanged(OperationProfile? value)
{
_sourceOverride = null;
if (value is null)
{
return;
}
Name = value.Name;
SourcePath = value.SourcePath;
DestPath = value.DestPath;
RequireGitClean = value.RequireGitClean;
DoCompress = value.DoCompress;
ArchiveFormat = value.ArchiveFormat;
DoCopy = value.DoCopy;
DoRename = value.DoRename;
RenamePrefix = value.RenamePrefix;
RenameSuffix = value.RenameSuffix;
RenameSearch = value.RenameSearch;
RenameReplace = value.RenameReplace;
Excludes = value.Excludes;
AutoRun = value.CanAutoRun;
ClearPlan("Profile loaded. Preview to see what would run.");
}
partial void OnDoCopyChanged(bool value) => RefreshAutoRun();
partial void OnDoCompressChanged(bool value)
{
OnPropertyChanged(nameof(CompressOptionsEnabled));
RefreshAutoRun();
}
partial void OnDoRenameChanged(bool value) => RefreshAutoRun();
partial void OnRenamePrefixChanged(string value) => RefreshAutoRun();
partial void OnRenameSuffixChanged(string value) => RefreshAutoRun();
partial void OnRenameSearchChanged(string value) => RefreshAutoRun();
[RelayCommand]
public void NewProfile()
{
Selected = null;
Name = "New profile";
SourcePath = "";
DestPath = "";
RequireGitClean = false;
DoCompress = false;
ArchiveFormat = ArchiveFormat.SevenZip;
DoCopy = true;
DoRename = false;
RenamePrefix = "";
RenameSuffix = "";
RenameSearch = "";
RenameReplace = "";
Excludes = "";
AutoRun = false;
_sourceOverride = null;
ClearPlan("New profile. Choose folders and Save.");
}
[RelayCommand]
public async Task SaveAsync()
{
var profile = CurrentProfile();
profile.Id = await _profiles.SaveAsync(profile).ConfigureAwait(true);
ReplaceInList(profile);
Selected = profile;
Status = "Profile saved.";
}
[RelayCommand]
public async Task DeleteAsync()
{
if (Selected is null || Selected.Id <= 0)
{
NewProfile();
return;
}
await _profiles.DeleteAsync(Selected.Id).ConfigureAwait(true);
Profiles.Remove(Selected);
if (Profiles.Count > 0)
{
Selected = Profiles[0];
}
else
{
NewProfile();
}
Status = "Profile removed.";
}
[RelayCommand]
public async Task DuplicateAsync()
{
var profile = CurrentProfile();
if (profile.Id <= 0)
{
Status = "Save the profile first.";
return;
}
var copy = await _profiles.DuplicateAsync(profile).ConfigureAwait(true);
Profiles.Add(copy);
Selected = copy;
Status = "Duplicated.";
}
[RelayCommand]
public async Task AnalyzeAsync()
{
var profile = CurrentProfile();
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _profiles.PreviewAsync(profile, _sourceOverride)).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var profile = CurrentProfile();
if (profile.Id <= 0)
{
await SaveAsync().ConfigureAwait(true);
profile = CurrentProfile();
}
var plan = await _profiles.EnqueueAsync(profile, _plan, _sourceOverride).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
ReplaceInList(profile);
Status = profile.LastStatus ?? "Queued.";
CanQueue = false;
}
}
public OperationProfile CurrentProfile()
=> new()
{
Id = Selected?.Id ?? 0,
Name = string.IsNullOrWhiteSpace(Name) ? "Profile" : Name.Trim(),
SourcePath = SourcePath.Trim(),
DestPath = DestPath.Trim(),
RequireGitClean = RequireGitClean,
DoCompress = DoCompress,
ArchiveFormat = ArchiveFormat,
DoCopy = DoCopy,
DoRename = DoRename,
RenamePrefix = RenamePrefix ?? "",
RenameSuffix = RenameSuffix ?? "",
RenameSearch = RenameSearch ?? "",
RenameReplace = RenameReplace ?? "",
Excludes = Excludes ?? "",
AutoRun = AutoRun && AutoRunEnabled,
SourceVolumeGuid = Selected?.SourceVolumeGuid,
DestVolumeGuid = Selected?.DestVolumeGuid,
IsBuiltIn = Selected?.IsBuiltIn ?? false,
CreatedUtc = Selected?.CreatedUtc ?? DateTimeOffset.UtcNow,
LastRunUtc = Selected?.LastRunUtc,
LastStatus = Selected?.LastStatus
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.ProfilePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
if (_sourceOverride is { Count: > 0 } sources)
{
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: $"{plan.Operations.Count} operations on {sources.Count} item(s)"
+ (warnings > 0 ? $" · {warnings} skipped" : "");
return;
}
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: plan.Operations.Count == 0
? warnings > 0
? $"Nothing to queue · {warnings} skipped"
: "Nothing to queue."
: $"{plan.Operations.Count} operations · {warnings} skipped";
}
private void ClearPlan(string status)
{
_plan = null;
Rows.Clear();
CanQueue = false;
Status = status;
}
private void ReplaceInList(OperationProfile profile)
{
var existing = Profiles.FirstOrDefault(p => p.Id == profile.Id);
if (existing is not null)
{
var index = Profiles.IndexOf(existing);
Profiles[index] = profile;
}
else
{
Profiles.Add(profile);
}
}
private bool HasRenameText
=> DoRename && (!string.IsNullOrWhiteSpace(RenamePrefix)
|| !string.IsNullOrWhiteSpace(RenameSuffix)
|| !string.IsNullOrWhiteSpace(RenameSearch));
private void RefreshAutoRun()
{
OnPropertyChanged(nameof(AutoRunEnabled));
if (!AutoRunEnabled)
{
AutoRun = false;
}
}
}
public sealed record ArchiveFormatOption(string Label, ArchiveFormat Format);

View File

@@ -0,0 +1,118 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Explorer.Domain;
using Explorer.FileOperations;
namespace Explorer.Presentation.ViewModels;
public sealed partial class ReorganizeViewModel : ObservableObject
{
private readonly ReorganizeService _organize;
private OperationPlan? _plan;
[ObservableProperty] private string _sourcePath = "";
[ObservableProperty] private string _picturesPath = "";
[ObservableProperty] private string _videosPath = "";
[ObservableProperty] private string _audioPath = "";
[ObservableProperty] private string _documentsPath = "";
[ObservableProperty] private string _installersPath = "";
[ObservableProperty] private string _archivesPath = "";
[ObservableProperty] private string _developmentPath = "";
[ObservableProperty] private string _status = "Preview to see proposed moves. Nothing is moved until you Queue.";
[ObservableProperty] private bool _canQueue;
public ReorganizeViewModel(ReorganizeService organize, string? sourcePath)
{
_organize = organize;
var dest = organize.LoadDestinations();
SourcePath = string.IsNullOrWhiteSpace(sourcePath) ? SuggestDownloads() : sourcePath;
PicturesPath = dest.Pictures;
VideosPath = dest.Videos;
AudioPath = dest.Audio;
DocumentsPath = dest.Documents;
InstallersPath = dest.Installers;
ArchivesPath = dest.Archives;
DevelopmentPath = dest.Development;
Rows = [];
}
public ObservableCollection<OrganizePreviewRow> Rows { get; }
[RelayCommand]
public async Task AnalyzeAsync()
{
Status = "Analyzing…";
CanQueue = false;
var plan = await Task.Run(() => _organize.Preview(SourcePath.Trim(), CurrentDestinations())).ConfigureAwait(true);
ApplyPlan(plan);
}
[RelayCommand]
public async Task QueueAsync()
{
if (_plan is null || !_plan.CanEnqueue)
{
Status = "Preview first. Nothing to queue.";
return;
}
var dest = CurrentDestinations();
var plan = await _organize.EnqueueAsync(SourcePath.Trim(), dest, _plan).ConfigureAwait(true);
ApplyPlan(plan);
if (plan.CanEnqueue)
{
Status = $"Queued {plan.Operations.Count} move(s).";
CanQueue = false;
}
}
[RelayCommand]
public void SaveDestinations()
{
_organize.SaveDestinations(CurrentDestinations());
Status = "Destinations saved.";
}
public OrganizeDestinations CurrentDestinations()
=> new()
{
Pictures = PicturesPath.Trim(),
Videos = VideosPath.Trim(),
Audio = AudioPath.Trim(),
Documents = DocumentsPath.Trim(),
Installers = InstallersPath.Trim(),
Archives = ArchivesPath.Trim(),
Development = DevelopmentPath.Trim()
};
private void ApplyPlan(OperationPlan plan)
{
_plan = plan;
Rows.Clear();
foreach (var row in plan.OrganizePreview)
{
Rows.Add(row);
}
CanQueue = plan.CanEnqueue;
var errors = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Error);
var warnings = plan.Issues.Count(i => i.Severity == PlanIssueSeverity.Warning);
var moves = plan.Operations.Count;
var skipped = plan.OrganizePreview.Count(r => r.Action == "Skip");
Status = errors > 0
? plan.Issues.First(i => i.Severity == PlanIssueSeverity.Error).Message
: moves == 0
? skipped > 0
? $"Nothing to queue · {skipped} left in place"
: "Nothing to queue."
: $"{moves} move(s) · {skipped} left in place"
+ (warnings > 0 ? $" · {warnings} warning(s)" : "");
}
private static string SuggestDownloads()
{
var downloads = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
return Directory.Exists(downloads) ? downloads : "";
}
}

View File

@@ -22,6 +22,7 @@ public sealed partial class TransferJobViewModel : ObservableObject
[ObservableProperty] private bool _hasProgress;
[ObservableProperty] private bool _canPause;
[ObservableProperty] private bool _canResume;
[ObservableProperty] private bool _canRetry;
[ObservableProperty] private bool _canRemove;
[ObservableProperty] private bool _canMoveUp;
[ObservableProperty] private bool _canMoveDown;
@@ -54,10 +55,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
HasProgress = job.BytesTotal is > 0 && job.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
CanPause = job.Status is TransferStatus.Queued or TransferStatus.Running;
CanResume = job.Status == TransferStatus.Paused;
CanRetry = job.Status == TransferStatus.Failed;
CanRemove = job.Status is not TransferStatus.Cancelling;
CanMoveUp = canMoveUp && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
CanMoveDown = canMoveDown && job.Status is not TransferStatus.Running and not TransferStatus.Cancelling;
IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling;
IsActive = job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling;
IsFailed = job.Status == TransferStatus.Failed;
}
@@ -71,6 +74,16 @@ public sealed partial class TransferJobViewModel : ObservableObject
return count <= 1 ? FileName(job.SourcePath) : $"{count} items";
}
if (job.Op == TransferOp.EmptyRecycleBin)
{
return LocationRoots.RecycleBin;
}
if (job.Op is TransferOp.Compress or TransferOp.AddToArchive)
{
return FileName(job.DestinationPath);
}
return FileName(job.SourcePath);
}
@@ -79,6 +92,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
{
TransferOp.Copy => $"Copy to {FolderName(job.DestinationPath)}",
TransferOp.Move => $"Move to {FolderName(job.DestinationPath)}",
TransferOp.Rename => $"Rename to {FileName(job.DestinationPath)}",
TransferOp.Extract => $"Extract to {FileName(job.DestinationPath)}",
TransferOp.Compress => $"Compress to {FileName(job.DestinationPath)}",
TransferOp.AddToArchive => $"Add to {FileName(job.DestinationPath)}",
TransferOp.VerifyArchive => "Verify archive",
TransferOp.EmptyRecycleBin => "Empty Recycle Bin",
TransferOp.Delete => string.Equals(job.DestinationPath, "permanent", StringComparison.Ordinal)
? "Delete permanently"
: "Move to Recycle Bin",
@@ -93,6 +112,9 @@ public sealed partial class TransferJobViewModel : ObservableObject
? $"{OpWord(job.Op)} {job.FilesDone:N0} of {job.FilesTotal:N0}"
: "Working…",
TransferStatus.Paused => "Paused",
TransferStatus.Waiting => string.IsNullOrWhiteSpace(job.WaitReason)
? "Waiting for destination"
: job.WaitReason,
TransferStatus.Cancelling => "Cancelling…",
TransferStatus.Cancelled => "Cancelled",
TransferStatus.Failed => string.IsNullOrWhiteSpace(job.Error) ? "Failed" : job.Error,
@@ -152,6 +174,12 @@ public sealed partial class TransferJobViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => "Working"
};
}
@@ -229,6 +257,15 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
[RelayCommand]
public void Retry(TransferJobViewModel? job)
{
if (job is not null)
{
_queue.Retry(job.Id);
}
}
[RelayCommand]
public void Remove(TransferJobViewModel? job)
{
@@ -237,7 +274,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return;
}
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -270,7 +308,8 @@ public sealed partial class TransferQueueViewModel : ObservableObject
public void Cancel(TransferJob job)
{
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling)
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling)
{
_queue.Cancel(job.Id);
}
@@ -336,8 +375,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
}
}
var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused or TransferStatus.Cancelling).ToList();
var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused)
var active = snapshot.Where(j => j.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Waiting or TransferStatus.Cancelling).ToList();
var currentJob = snapshot.FirstOrDefault(j => j.Status is TransferStatus.Running or TransferStatus.Paused or TransferStatus.Waiting)
?? active.FirstOrDefault();
HasJobs = Jobs.Count > 0;
HasActiveJobs = active.Count > 0;
@@ -397,6 +437,7 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return "";
}
var waitingDest = active.Count(j => j.Status == TransferStatus.Waiting);
var waiting = active.Count(j => j.Status == TransferStatus.Queued);
var name = current is null ? $"{active.Count} transfers" : TransferJobViewModel.FileName(current.SourcePath);
if (current?.Status == TransferStatus.Paused)
@@ -404,6 +445,13 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting > 0 ? $"Paused · {name} · {waiting} waiting" : $"Paused · {name}";
}
if (current?.Status == TransferStatus.Waiting)
{
return waitingDest > 1
? $"Waiting for destination · {waitingDest} items"
: $"Waiting for destination · {name}";
}
if (current?.Status == TransferStatus.Running)
{
return waiting > 0 ? $"{OpVerb(current.Op)} {name} · {waiting} waiting" : $"{OpVerb(current.Op)} {name}";
@@ -411,7 +459,9 @@ public sealed partial class TransferQueueViewModel : ObservableObject
return waiting == active.Count
? $"{active.Count} queued"
: $"{active.Count} transfers";
: waitingDest == active.Count
? "Waiting for destination"
: $"{active.Count} transfers";
}
private static string OpVerb(TransferOp op)
@@ -420,11 +470,17 @@ public sealed partial class TransferQueueViewModel : ObservableObject
TransferOp.Copy => "Copying",
TransferOp.Move => "Moving",
TransferOp.Delete => "Deleting",
TransferOp.Rename => "Renaming",
TransferOp.Extract => "Extracting",
TransferOp.Compress => "Compressing",
TransferOp.AddToArchive => "Adding",
TransferOp.VerifyArchive => "Verifying",
TransferOp.EmptyRecycleBin => "Emptying Recycle Bin",
_ => op.ToString()
};
private static bool IsVisible(TransferJob job)
=> job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Paused
or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Waiting or TransferStatus.Cancelling or TransferStatus.Failed or TransferStatus.Done
or TransferStatus.Cancelled;
}

View File

@@ -320,13 +320,12 @@ internal sealed class HashStore : IHashStore
.Select(g =>
{
var list = g.ToList();
var fileIds = list.Select(e => e.FileId).Where(id => id is > 0).Distinct().ToList();
return new DuplicateGroup
{
SizeBytes = list[0].SizeBytes,
Hash = list[0].ContentHash,
Entries = list,
SameFileId = fileIds.Count == 1 && list.All(e => e.FileId == fileIds[0])
SameFileId = DuplicateClassifier.IsHardlinkOnly(list)
};
})
.ToList();

View File

@@ -0,0 +1,94 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class FileRelationStore : IFileRelationStore
{
private readonly SqliteIndexStore _store;
public FileRelationStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<FileRelation>> GetAmongAsync(
IReadOnlyList<long> entryIds,
CancellationToken cancellationToken = default)
{
if (entryIds.Count == 0)
{
return [];
}
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<RelationRow>("""
SELECT id, left_entry_id, right_entry_id, kind, origin, created_utc
FROM file_relations
WHERE left_entry_id IN @ids AND right_entry_id IN @ids
""", new { ids = entryIds }).ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public Task UpsertAsync(FileRelation relation, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn =>
{
var left = Math.Min(relation.LeftEntryId, relation.RightEntryId);
var right = Math.Max(relation.LeftEntryId, relation.RightEntryId);
if (left == right)
{
return Task.CompletedTask;
}
return conn.ExecuteAsync("""
INSERT INTO file_relations (left_entry_id, right_entry_id, kind, origin, created_utc)
VALUES (@left, @right, @kind, @origin, @utc)
ON CONFLICT(left_entry_id, right_entry_id, kind) DO UPDATE SET origin = excluded.origin
""", new
{
left,
right,
kind = relation.Kind.ToString(),
origin = relation.Origin.ToString(),
utc = relation.CreatedUtc == default
? DateTimeOffset.UtcNow.ToString("O")
: relation.CreatedUtc.ToString("O")
});
}, cancellationToken);
public Task DeleteAmongAsync(
IReadOnlyList<long> entryIds,
IReadOnlyList<FileRelationKind> kinds,
CancellationToken cancellationToken = default)
{
if (entryIds.Count == 0 || kinds.Count == 0)
{
return Task.CompletedTask;
}
return _store.WriteAsync(conn => conn.ExecuteAsync("""
DELETE FROM file_relations
WHERE left_entry_id IN @ids AND right_entry_id IN @ids
AND kind IN @kinds
""", new { ids = entryIds, kinds = kinds.Select(k => k.ToString()).ToList() }), cancellationToken);
}
private static FileRelation ToModel(RelationRow row)
=> new()
{
Id = row.id,
LeftEntryId = row.left_entry_id,
RightEntryId = row.right_entry_id,
Kind = Enum.Parse<FileRelationKind>(row.kind),
Origin = Enum.Parse<FileRelationOrigin>(row.origin),
CreatedUtc = DateTimeOffset.Parse(row.created_utc)
};
private sealed class RelationRow
{
public long id { get; set; }
public long left_entry_id { get; set; }
public long right_entry_id { get; set; }
public string kind { get; set; } = "";
public string origin { get; set; } = "";
public string created_utc { get; set; } = "";
}
}

View File

@@ -169,37 +169,129 @@ internal sealed class TransferStore : ITransferStore
private readonly SqliteIndexStore _store;
public TransferStore(SqliteIndexStore store) => _store = store;
private const string SelectSql = """
SELECT id AS Id, op AS Op, src AS Src, dst AS Dst, status AS Status,
bytes_total AS BytesTotal, bytes_done AS BytesDone, files_done AS FilesDone,
files_total AS FilesTotal, current_path AS CurrentPath, created_utc AS CreatedUtc,
started_utc AS StartedUtc, error AS Error, retry_count AS RetryCount,
wait_reason AS WaitReason, sort_order AS SortOrder, dismissed AS Dismissed
FROM transfer_jobs
""";
public Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var nextOrder = await conn.ExecuteScalarAsync<long>(
"SELECT IFNULL(MAX(sort_order), 0) + 1 FROM transfer_jobs").ConfigureAwait(false);
job.SortOrder = (int)nextOrder;
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO transfer_jobs (op, src, dst, status, bytes_total, bytes_done, created_utc, error)
VALUES (@Op, @Src, @Dst, @Status, @BytesTotal, @BytesDone, @CreatedUtc, @Error);
""", new
{
Op = job.Op.ToString(),
Src = job.SourcePath,
Dst = job.DestinationPath,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
CreatedUtc = job.CreatedUtc.ToString("O"),
job.Error
}).ConfigureAwait(false);
INSERT INTO transfer_jobs (op, src, dst, status, bytes_total, bytes_done, files_done, files_total,
current_path, created_utc, started_utc, error, retry_count, wait_reason, sort_order, dismissed)
VALUES (@Op, @Src, @Dst, @Status, @BytesTotal, @BytesDone, @FilesDone, @FilesTotal,
@CurrentPath, @CreatedUtc, @StartedUtc, @Error, @RetryCount, @WaitReason, @SortOrder, @Dismissed);
""", Args(job)).ConfigureAwait(false);
job.Id = id;
return id;
}, cancellationToken);
public Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
UPDATE transfer_jobs SET status=@Status, bytes_total=@BytesTotal, bytes_done=@BytesDone, error=@Error
UPDATE transfer_jobs SET op=@Op, src=@Src, dst=@Dst, status=@Status, bytes_total=@BytesTotal,
bytes_done=@BytesDone, files_done=@FilesDone, files_total=@FilesTotal, current_path=@CurrentPath,
started_utc=@StartedUtc, error=@Error, retry_count=@RetryCount, wait_reason=@WaitReason,
sort_order=@SortOrder, dismissed=@Dismissed
WHERE id=@Id
""", new
""", Args(job)), cancellationToken);
public async Task<IReadOnlyList<TransferJob>> GetIncompleteAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<TransferJobRow>(SelectSql + """
WHERE dismissed = 0 AND status IN ('Queued','Running','Paused','Waiting','Cancelling','Failed')
ORDER BY sort_order, id
""").ConfigureAwait(false);
return rows.Select(Map).ToList();
}
public async Task<IReadOnlyList<TransferJob>> GetHistoryAsync(int take, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<TransferJobRow>(SelectSql + """
WHERE status IN ('Done','Cancelled') OR dismissed = 1
ORDER BY id DESC
LIMIT @take
""", new { take }).ConfigureAwait(false);
return rows.Select(Map).ToList();
}
private static object Args(TransferJob job) => new
{
job.Id,
Op = job.Op.ToString(),
Src = job.SourcePath,
Dst = job.DestinationPath,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
job.FilesDone,
job.FilesTotal,
job.CurrentPath,
CreatedUtc = job.CreatedUtc.ToString("O"),
StartedUtc = job.StartedUtc?.ToString("O"),
job.Error,
job.RetryCount,
job.WaitReason,
job.SortOrder,
Dismissed = job.Dismissed ? 1 : 0
};
private static TransferJob Map(TransferJobRow row)
{
var op = Enum.Parse<TransferOp>(row.Op, ignoreCase: true);
var sources = op is TransferOp.Delete or TransferOp.Compress or TransferOp.AddToArchive
? row.Src.Split('|', StringSplitOptions.RemoveEmptyEntries)
: [];
return new TransferJob
{
job.Id,
Status = job.Status.ToString(),
job.BytesTotal,
job.BytesDone,
job.Error
}), cancellationToken);
Id = row.Id,
Op = op,
SourcePath = row.Src,
DestinationPath = row.Dst,
Status = Enum.Parse<TransferStatus>(row.Status, ignoreCase: true),
BytesTotal = row.BytesTotal,
BytesDone = row.BytesDone,
FilesDone = row.FilesDone,
FilesTotal = row.FilesTotal,
CurrentPath = row.CurrentPath,
CreatedUtc = DateTimeOffset.Parse(row.CreatedUtc),
StartedUtc = string.IsNullOrWhiteSpace(row.StartedUtc) ? null : DateTimeOffset.Parse(row.StartedUtc),
Error = row.Error,
RetryCount = row.RetryCount,
WaitReason = row.WaitReason,
SortOrder = row.SortOrder,
Dismissed = row.Dismissed != 0,
AdditionalSources = sources
};
}
private sealed class TransferJobRow
{
public long Id { get; set; }
public string Op { get; set; } = "";
public string Src { get; set; } = "";
public string? Dst { get; set; }
public string Status { get; set; } = "";
public long? BytesTotal { get; set; }
public long BytesDone { get; set; }
public long FilesDone { get; set; }
public long FilesTotal { get; set; }
public string? CurrentPath { get; set; }
public string CreatedUtc { get; set; } = "";
public string? StartedUtc { get; set; }
public string? Error { get; set; }
public int RetryCount { get; set; }
public string? WaitReason { get; set; }
public int SortOrder { get; set; }
public int Dismissed { get; set; }
}
}

View File

@@ -0,0 +1,131 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class OperationProfileStore : IOperationProfileStore
{
private readonly SqliteIndexStore _store;
public OperationProfileStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<OperationProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<Row>("SELECT * FROM operation_profiles ORDER BY name COLLATE NOCASE, id")
.ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public async Task<OperationProfile?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QueryFirstOrDefaultAsync<Row>("SELECT * FROM operation_profiles WHERE id=@id", new { id })
.ConfigureAwait(false);
return row is null ? null : ToModel(row);
}
public Task<long> UpsertAsync(OperationProfile profile, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (profile.Id <= 0)
{
return await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO operation_profiles (name, source_path, dest_path, require_git_clean, do_compress,
archive_format, do_copy, do_rename, rename_prefix, rename_suffix, rename_search, rename_replace,
excludes, auto_run, source_volume_guid, dest_volume_guid, is_builtin, created_utc, last_run_utc,
last_status)
VALUES (@name, @src, @dst, @git, @compress, @fmt, @copy, @rename, @prefix, @suffix, @search, @replace,
@excludes, @auto, @sg, @dg, @builtin, @created, @run, @status);
""", Args(profile)).ConfigureAwait(false);
}
await conn.ExecuteAsync("""
UPDATE operation_profiles SET name=@name, source_path=@src, dest_path=@dst, require_git_clean=@git,
do_compress=@compress, archive_format=@fmt, do_copy=@copy, do_rename=@rename, rename_prefix=@prefix,
rename_suffix=@suffix, rename_search=@search, rename_replace=@replace, excludes=@excludes, auto_run=@auto,
source_volume_guid=@sg, dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
WHERE id=@id
""", Args(profile)).ConfigureAwait(false);
return profile.Id;
}, cancellationToken);
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM operation_profiles WHERE id=@id", new { id }), cancellationToken);
private static object Args(OperationProfile profile) => new
{
id = profile.Id,
name = profile.Name,
src = profile.SourcePath ?? "",
dst = profile.DestPath ?? "",
git = profile.RequireGitClean ? 1 : 0,
compress = profile.DoCompress ? 1 : 0,
fmt = profile.ArchiveFormat.ToString(),
copy = profile.DoCopy ? 1 : 0,
rename = profile.DoRename ? 1 : 0,
prefix = profile.RenamePrefix ?? "",
suffix = profile.RenameSuffix ?? "",
search = profile.RenameSearch ?? "",
replace = profile.RenameReplace ?? "",
excludes = profile.Excludes ?? "",
auto = profile.AutoRun ? 1 : 0,
sg = profile.SourceVolumeGuid,
dg = profile.DestVolumeGuid,
builtin = profile.IsBuiltIn ? 1 : 0,
created = (profile.CreatedUtc == default ? DateTimeOffset.UtcNow : profile.CreatedUtc).ToString("O"),
run = profile.LastRunUtc?.ToString("O"),
status = profile.LastStatus
};
private static OperationProfile ToModel(Row row) => new()
{
Id = row.id,
Name = row.name,
SourcePath = row.source_path ?? "",
DestPath = row.dest_path ?? "",
RequireGitClean = row.require_git_clean != 0,
DoCompress = row.do_compress != 0,
ArchiveFormat = Enum.TryParse<ArchiveFormat>(row.archive_format, true, out var fmt) ? fmt : ArchiveFormat.SevenZip,
DoCopy = row.do_copy != 0,
DoRename = row.do_rename != 0,
RenamePrefix = row.rename_prefix ?? "",
RenameSuffix = row.rename_suffix ?? "",
RenameSearch = row.rename_search ?? "",
RenameReplace = row.rename_replace ?? "",
Excludes = row.excludes ?? "",
AutoRun = row.auto_run != 0,
SourceVolumeGuid = row.source_volume_guid,
DestVolumeGuid = row.dest_volume_guid,
IsBuiltIn = row.is_builtin != 0,
CreatedUtc = DateTimeOffset.Parse(row.created_utc),
LastRunUtc = string.IsNullOrWhiteSpace(row.last_run_utc) ? null : DateTimeOffset.Parse(row.last_run_utc),
LastStatus = row.last_status
};
private sealed class Row
{
public long id { get; set; }
public string name { get; set; } = "";
public string source_path { get; set; } = "";
public string dest_path { get; set; } = "";
public int require_git_clean { get; set; }
public int do_compress { get; set; }
public string archive_format { get; set; } = "";
public int do_copy { get; set; }
public int do_rename { get; set; }
public string rename_prefix { get; set; } = "";
public string rename_suffix { get; set; } = "";
public string rename_search { get; set; } = "";
public string rename_replace { get; set; } = "";
public string excludes { get; set; } = "";
public int auto_run { get; set; }
public string? source_volume_guid { get; set; }
public string? dest_volume_guid { get; set; }
public int is_builtin { get; set; }
public string created_utc { get; set; } = "";
public string? last_run_utc { get; set; }
public string? last_status { get; set; }
}
}

View File

@@ -0,0 +1,67 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class RenameBatchStore : IRenameBatchStore
{
private readonly SqliteIndexStore _store;
public RenameBatchStore(SqliteIndexStore store) => _store = store;
public Task<long> CreateAsync(IReadOnlyList<RenameBatchItem> items, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
var id = await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO rename_batches (created_utc, undone) VALUES (@utc, 0);
""", new { utc = DateTimeOffset.UtcNow.ToString("O") }).ConfigureAwait(false);
foreach (var item in items)
{
await conn.ExecuteAsync("""
INSERT INTO rename_batch_items (batch_id, old_path, new_path, sort_order)
VALUES (@id, @old, @newPath, @order)
""", new { id, old = item.OldPath, newPath = item.NewPath, order = item.SortOrder })
.ConfigureAwait(false);
}
return id;
}, cancellationToken);
public async Task<RenameBatch?> GetLatestUndoableAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var header = await conn.QueryFirstOrDefaultAsync<BatchHeader>(
"SELECT id, created_utc, undone FROM rename_batches WHERE undone = 0 ORDER BY id DESC LIMIT 1")
.ConfigureAwait(false);
if (header is null)
{
return null;
}
var items = (await conn.QueryAsync<(string old_path, string new_path, int sort_order)>(
"SELECT old_path, new_path, sort_order FROM rename_batch_items WHERE batch_id=@id ORDER BY sort_order",
new { id = header.id })
.ConfigureAwait(false))
.Select(r => new RenameBatchItem(r.old_path, r.new_path, r.sort_order))
.ToList();
return new RenameBatch
{
Id = header.id,
CreatedUtc = DateTimeOffset.Parse(header.created_utc),
Undone = header.undone != 0,
Items = items
};
}
public Task MarkUndoneAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync(
"UPDATE rename_batches SET undone=1 WHERE id=@id", new { id }), cancellationToken);
}
file sealed class BatchHeader
{
public long id { get; set; }
public string created_utc { get; set; } = "";
public int undone { get; set; }
}

View File

@@ -151,11 +151,20 @@ internal static class SchemaScript
dst TEXT,
status TEXT NOT NULL,
bytes_total INTEGER,
bytes_done INTEGER,
bytes_done INTEGER NOT NULL DEFAULT 0,
files_done INTEGER NOT NULL DEFAULT 0,
files_total INTEGER NOT NULL DEFAULT 0,
current_path TEXT,
created_utc TEXT NOT NULL,
error TEXT
started_utc TEXT,
error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
wait_reason TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
dismissed INTEGER NOT NULL DEFAULT 0
)
""",
"CREATE INDEX IF NOT EXISTS ix_transfer_jobs_status ON transfer_jobs(status, dismissed, sort_order, id)",
"""
CREATE TABLE hash_queue (
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
@@ -191,6 +200,78 @@ internal static class SchemaScript
value TEXT NOT NULL
)
""",
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')"
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')",
"""
CREATE TABLE file_relations (
id INTEGER PRIMARY KEY,
left_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
right_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
origin TEXT NOT NULL,
created_utc TEXT NOT NULL,
CHECK (left_entry_id < right_entry_id),
UNIQUE (left_entry_id, right_entry_id, kind)
)
""",
"CREATE INDEX ix_file_relations_left ON file_relations(left_entry_id)",
"CREATE INDEX ix_file_relations_right ON file_relations(right_entry_id)",
"""
CREATE TABLE rename_batches (
id INTEGER PRIMARY KEY,
created_utc TEXT NOT NULL,
undone INTEGER NOT NULL DEFAULT 0
)
""",
"""
CREATE TABLE rename_batch_items (
id INTEGER PRIMARY KEY,
batch_id INTEGER NOT NULL REFERENCES rename_batches(id) ON DELETE CASCADE,
old_path TEXT NOT NULL,
new_path TEXT NOT NULL,
sort_order INTEGER NOT NULL
)
""",
"CREATE INDEX ix_rename_batches_undo ON rename_batches(undone, id DESC)",
"""
CREATE TABLE sync_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL,
dest_path TEXT NOT NULL,
mode TEXT NOT NULL,
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""",
"""
CREATE TABLE operation_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL DEFAULT '',
dest_path TEXT NOT NULL DEFAULT '',
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',
rename_suffix TEXT NOT NULL DEFAULT '',
rename_search TEXT NOT NULL DEFAULT '',
rename_replace TEXT NOT NULL DEFAULT '',
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
"""
];
}

View File

@@ -29,6 +29,10 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
Analysis = new AnalysisStore(this);
History = new HistoryStore(this);
Hashes = new HashStore(this);
Relations = new FileRelationStore(this);
RenameBatches = new RenameBatchStore(this);
SyncProfiles = new SyncProfileStore(this);
OperationProfiles = new OperationProfileStore(this);
}
public ISourceStore Sources { get; }
@@ -40,6 +44,10 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
public IAnalysisStore Analysis { get; }
public IHistoryStore History { get; }
public IHashStore Hashes { get; }
public IFileRelationStore Relations { get; }
public IRenameBatchStore RenameBatches { get; }
public ISyncProfileStore SyncProfiles { get; }
public IOperationProfileStore OperationProfiles { get; }
internal SqliteConnection Write => _write ?? throw new InvalidOperationException("Store is not open.");
@@ -250,7 +258,11 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
cmd.ExecuteNonQuery();
}
SetUserVersion(conn, 3);
if (ReadUserVersion(conn) < 3)
{
SetUserVersion(conn, 3);
}
Volatile.Write(ref _analysisIndexesReady, 1);
_logger.LogInformation("Migrated SQLite schema to v3 (analysis indexes)");
}
@@ -336,6 +348,155 @@ public sealed class SqliteIndexStore : IIndexStore, IAsyncDisposable
throw new InvalidOperationException(
"Index database is incomplete. Delete index.db under LocalAppData\\ExplorerWorkbench and restart.");
}
if (TableExists(conn, "transfer_jobs") && version < 4)
{
EnsureColumn(conn, "transfer_jobs", "files_done", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "files_total", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "current_path", "TEXT");
EnsureColumn(conn, "transfer_jobs", "started_utc", "TEXT");
EnsureColumn(conn, "transfer_jobs", "retry_count", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "wait_reason", "TEXT");
EnsureColumn(conn, "transfer_jobs", "sort_order", "INTEGER NOT NULL DEFAULT 0");
EnsureColumn(conn, "transfer_jobs", "dismissed", "INTEGER NOT NULL DEFAULT 0");
using var idx = conn.CreateCommand();
idx.CommandText = "CREATE INDEX IF NOT EXISTS ix_transfer_jobs_status ON transfer_jobs(status, dismissed, sort_order, id)";
idx.ExecuteNonQuery();
SetUserVersion(conn, 4);
_logger.LogInformation("Migrated SQLite schema to v4 (transfer queue persistence)");
version = 4;
}
if (version < 5)
{
if (!TableExists(conn, "file_relations"))
{
using var table = conn.CreateCommand();
table.CommandText = """
CREATE TABLE file_relations (
id INTEGER PRIMARY KEY,
left_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
right_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
origin TEXT NOT NULL,
created_utc TEXT NOT NULL,
CHECK (left_entry_id < right_entry_id),
UNIQUE (left_entry_id, right_entry_id, kind)
)
""";
table.ExecuteNonQuery();
using var left = conn.CreateCommand();
left.CommandText = "CREATE INDEX IF NOT EXISTS ix_file_relations_left ON file_relations(left_entry_id)";
left.ExecuteNonQuery();
using var right = conn.CreateCommand();
right.CommandText = "CREATE INDEX IF NOT EXISTS ix_file_relations_right ON file_relations(right_entry_id)";
right.ExecuteNonQuery();
}
SetUserVersion(conn, 5);
_logger.LogInformation("Migrated SQLite schema to v5 (file relations)");
version = 5;
}
if (version < 6)
{
if (!TableExists(conn, "rename_batches"))
{
using var batches = conn.CreateCommand();
batches.CommandText = """
CREATE TABLE rename_batches (
id INTEGER PRIMARY KEY,
created_utc TEXT NOT NULL,
undone INTEGER NOT NULL DEFAULT 0
)
""";
batches.ExecuteNonQuery();
using var items = conn.CreateCommand();
items.CommandText = """
CREATE TABLE rename_batch_items (
id INTEGER PRIMARY KEY,
batch_id INTEGER NOT NULL REFERENCES rename_batches(id) ON DELETE CASCADE,
old_path TEXT NOT NULL,
new_path TEXT NOT NULL,
sort_order INTEGER NOT NULL
)
""";
items.ExecuteNonQuery();
using var idx = conn.CreateCommand();
idx.CommandText = "CREATE INDEX IF NOT EXISTS ix_rename_batches_undo ON rename_batches(undone, id DESC)";
idx.ExecuteNonQuery();
}
SetUserVersion(conn, 6);
_logger.LogInformation("Migrated SQLite schema to v6 (rename batch journal)");
version = 6;
}
if (version < 7)
{
if (!TableExists(conn, "sync_profiles"))
{
using var profiles = conn.CreateCommand();
profiles.CommandText = """
CREATE TABLE sync_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL,
dest_path TEXT NOT NULL,
mode TEXT NOT NULL,
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""";
profiles.ExecuteNonQuery();
}
SetUserVersion(conn, 7);
_logger.LogInformation("Migrated SQLite schema to v7 (folder sync profiles)");
version = 7;
}
if (version < 8)
{
if (!TableExists(conn, "operation_profiles"))
{
using var profiles = conn.CreateCommand();
profiles.CommandText = """
CREATE TABLE operation_profiles (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
source_path TEXT NOT NULL DEFAULT '',
dest_path TEXT NOT NULL DEFAULT '',
require_git_clean INTEGER NOT NULL DEFAULT 0,
do_compress INTEGER NOT NULL DEFAULT 0,
archive_format TEXT NOT NULL DEFAULT 'SevenZip',
do_copy INTEGER NOT NULL DEFAULT 0,
do_rename INTEGER NOT NULL DEFAULT 0,
rename_prefix TEXT NOT NULL DEFAULT '',
rename_suffix TEXT NOT NULL DEFAULT '',
rename_search TEXT NOT NULL DEFAULT '',
rename_replace TEXT NOT NULL DEFAULT '',
excludes TEXT NOT NULL DEFAULT '',
auto_run INTEGER NOT NULL DEFAULT 0,
source_volume_guid TEXT,
dest_volume_guid TEXT,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_utc TEXT NOT NULL,
last_run_utc TEXT,
last_status TEXT
)
""";
profiles.ExecuteNonQuery();
}
SetUserVersion(conn, 8);
_logger.LogInformation("Migrated SQLite schema to v8 (operation profiles)");
}
}
private static void EnsureColumn(SqliteConnection conn, string table, string column, string type)

View File

@@ -0,0 +1,99 @@
using Dapper;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Storage.Sqlite;
internal sealed class SyncProfileStore : ISyncProfileStore
{
private readonly SqliteIndexStore _store;
public SyncProfileStore(SqliteIndexStore store) => _store = store;
public async Task<IReadOnlyList<SyncProfile>> ListAsync(CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var rows = await conn.QueryAsync<Row>("SELECT * FROM sync_profiles ORDER BY name COLLATE NOCASE, id")
.ConfigureAwait(false);
return rows.Select(ToModel).ToList();
}
public async Task<SyncProfile?> GetAsync(long id, CancellationToken cancellationToken = default)
{
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
var row = await conn.QueryFirstOrDefaultAsync<Row>("SELECT * FROM sync_profiles WHERE id=@id", new { id })
.ConfigureAwait(false);
return row is null ? null : ToModel(row);
}
public Task<long> UpsertAsync(SyncProfile profile, CancellationToken cancellationToken = default)
=> _store.WriteAsync(async conn =>
{
if (profile.Id <= 0)
{
return await SqliteInsert.ExecuteAsync(conn, """
INSERT INTO sync_profiles (name, source_path, dest_path, mode, excludes, auto_run,
source_volume_guid, dest_volume_guid, created_utc, last_run_utc, last_status)
VALUES (@name, @src, @dst, @mode, @excludes, @auto, @sg, @dg, @created, @run, @status);
""", Args(profile)).ConfigureAwait(false);
}
await conn.ExecuteAsync("""
UPDATE sync_profiles SET name=@name, source_path=@src, dest_path=@dst, mode=@mode, excludes=@excludes,
auto_run=@auto, source_volume_guid=@sg, dest_volume_guid=@dg, last_run_utc=@run, last_status=@status
WHERE id=@id
""", Args(profile)).ConfigureAwait(false);
return profile.Id;
}, cancellationToken);
public Task DeleteAsync(long id, CancellationToken cancellationToken = default)
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM sync_profiles WHERE id=@id", new { id }), cancellationToken);
private static object Args(SyncProfile profile) => new
{
id = profile.Id,
name = profile.Name,
src = profile.SourcePath,
dst = profile.DestPath,
mode = profile.Mode.ToString(),
excludes = profile.Excludes ?? "",
auto = profile.AutoRun ? 1 : 0,
sg = profile.SourceVolumeGuid,
dg = profile.DestVolumeGuid,
created = (profile.CreatedUtc == default ? DateTimeOffset.UtcNow : profile.CreatedUtc).ToString("O"),
run = profile.LastRunUtc?.ToString("O"),
status = profile.LastStatus
};
private static SyncProfile ToModel(Row row) => new()
{
Id = row.id,
Name = row.name,
SourcePath = row.source_path,
DestPath = row.dest_path,
Mode = Enum.Parse<SyncMode>(row.mode, ignoreCase: true),
Excludes = row.excludes ?? "",
AutoRun = row.auto_run != 0,
SourceVolumeGuid = row.source_volume_guid,
DestVolumeGuid = row.dest_volume_guid,
CreatedUtc = DateTimeOffset.Parse(row.created_utc),
LastRunUtc = string.IsNullOrWhiteSpace(row.last_run_utc) ? null : DateTimeOffset.Parse(row.last_run_utc),
LastStatus = row.last_status
};
private sealed class Row
{
public long id { get; set; }
public string name { get; set; } = "";
public string source_path { get; set; } = "";
public string dest_path { get; set; } = "";
public string mode { get; set; } = "";
public string excludes { get; set; } = "";
public int auto_run { get; set; }
public string? source_volume_guid { get; set; }
public string? dest_volume_guid { get; set; }
public string created_utc { get; set; } = "";
public string? last_run_utc { get; set; }
public string? last_status { get; set; }
}
}

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