Add Explorer Workbench with hierarchical, off-UI Storage analysis.
Storage queries run in the background with cancellation and covering indexes so switching views no longer freezes the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
## Build
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
[Bb]uild/
|
||||||
|
[Ll]og/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.vs/
|
||||||
|
.vs/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Aa]rtifacts/
|
||||||
|
TestResults/
|
||||||
|
*.trx
|
||||||
|
*.coverage
|
||||||
|
*.nupkg
|
||||||
|
project.lock.json
|
||||||
|
|
||||||
|
## IDE
|
||||||
|
.idea/
|
||||||
|
*.DotSettings.user
|
||||||
|
*.rsuser
|
||||||
|
|
||||||
|
## OS
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
## App local (never commit a user's index)
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.log
|
||||||
18
Directory.Build.props
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<WarningsAsErrors />
|
||||||
|
<NeutralLanguage>en</NeutralLanguage>
|
||||||
|
<Company>Explorer Workbench</Company>
|
||||||
|
<Product>Explorer Workbench</Product>
|
||||||
|
<Authors>Explorer Workbench</Authors>
|
||||||
|
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
||||||
|
<FileVersion>0.1.0.0</FileVersion>
|
||||||
|
<InformationalVersion>0.1.0</InformationalVersion>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
26
Explorer.slnx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Explorer.Analysis/Explorer.Analysis.csproj" />
|
||||||
|
<Project Path="src/Explorer.App/Explorer.App.csproj" />
|
||||||
|
<Project Path="src/Explorer.Application/Explorer.Application.csproj" />
|
||||||
|
<Project Path="src/Explorer.Contracts/Explorer.Contracts.csproj" />
|
||||||
|
<Project Path="src/Explorer.Domain/Explorer.Domain.csproj" />
|
||||||
|
<Project Path="src/Explorer.FileOperations/Explorer.FileOperations.csproj" />
|
||||||
|
<Project Path="src/Explorer.Indexing/Explorer.Indexing.csproj" />
|
||||||
|
<Project Path="src/Explorer.Plugin.Abstractions/Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<Project Path="src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj" />
|
||||||
|
<Project Path="src/Explorer.Presentation/Explorer.Presentation.csproj" />
|
||||||
|
<Project Path="src/Explorer.Search/Explorer.Search.csproj" />
|
||||||
|
<Project Path="src/Explorer.Storage.Sqlite/Explorer.Storage.Sqlite.csproj" />
|
||||||
|
<Project Path="src/Explorer.Windows/Explorer.Windows.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/Explorer.Analysis.Tests/Explorer.Analysis.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.Application.Tests/Explorer.Application.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.Domain.Tests/Explorer.Domain.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.FileOperations.Tests/Explorer.FileOperations.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.Indexing.Tests/Explorer.Indexing.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.Search.Tests/Explorer.Search.Tests.csproj" />
|
||||||
|
<Project Path="tests/Explorer.Storage.Tests/Explorer.Storage.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
106
README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Explorer Workbench
|
||||||
|
|
||||||
|
Working name for a Windows file manager with **persistent memory**: live Explorer browsing plus a SQLite index for search, folder sizes, offline media and storage analysis.
|
||||||
|
|
||||||
|
Mental model:
|
||||||
|
|
||||||
|
> Open Explorer → see files → manage files → search immediately → analyze storage.
|
||||||
|
|
||||||
|
The index is infrastructure. You never need to know about SQLite, FTS or change journals to use the app.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Windows 10 version 1809 or later, or Windows 11
|
||||||
|
- [.NET 10 SDK](https://dotnet.microsoft.com/download) (this repo pins `10.0.301` in `global.json`)
|
||||||
|
- No administrator privileges required
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build Explorer.slnx -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet run --project src/Explorer.App -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
The executable is `src/Explorer.App/bin/Release/net10.0-windows/Explorer.App.exe`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet test Explorer.slnx -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture overview
|
||||||
|
|
||||||
|
Modular monolith (see `docs/ARCHITECTURE.md`):
|
||||||
|
|
||||||
|
| Project | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Explorer.App` | WPF shell, themes, composition |
|
||||||
|
| `Explorer.Presentation` | ViewModels (no WPF types) |
|
||||||
|
| `Explorer.Application` | Use-cases (browse, sources) |
|
||||||
|
| `Explorer.Domain` | Entities, identity, excludes |
|
||||||
|
| `Explorer.Indexing` | Full scan, folder reconcile, USN apply, watchers |
|
||||||
|
| `Explorer.Search` | Structured `SearchQuery` → index |
|
||||||
|
| `Explorer.Analysis` | Top-N, types, duplicates, history rollups |
|
||||||
|
| `Explorer.FileOperations` | Transfer queue, copy/move/delete |
|
||||||
|
| `Explorer.Storage.Sqlite` | Schema, WAL, FTS5 |
|
||||||
|
| `Explorer.Windows` | Volumes, USN, Recycle Bin, `CopyFileEx` |
|
||||||
|
|
||||||
|
Listing is **hybrid**: the open folder always comes from the live filesystem when the location is online. Folder sizes, search, analysis and offline media come from the index.
|
||||||
|
|
||||||
|
## Database location
|
||||||
|
|
||||||
|
`%LocalAppData%\ExplorerWorkbench\index.db`
|
||||||
|
|
||||||
|
Never stored beside the executable.
|
||||||
|
|
||||||
|
## Logging location
|
||||||
|
|
||||||
|
`%LocalAppData%\ExplorerWorkbench\logs\explorer-YYYYMMDD.log`
|
||||||
|
|
||||||
|
## Indexing behavior
|
||||||
|
|
||||||
|
- Browsing local drives works immediately with an empty index.
|
||||||
|
- Initial indexing is **user-triggered** (banner **Build index** or the toolbar **Index** button).
|
||||||
|
- Scans run in the background, write in batches, skip inaccessible paths, and can be cancelled.
|
||||||
|
- Local NTFS volumes use the USN change journal when the process can open it; otherwise folder reconcile + `FileSystemWatcher` (best effort; overflow marks the location as possibly out of date).
|
||||||
|
- SMB/UNC paths are added manually (address bar or **Add network**). The Windows Network neighborhood is not enumerated.
|
||||||
|
- Default excludes include `C:\Windows`, recycle bins, `node_modules`, `.git`, and similar.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
| Shortcut | Action |
|
||||||
|
|---|---|
|
||||||
|
| `Alt+Left` / `Back` | Back |
|
||||||
|
| `Alt+Right` | Forward |
|
||||||
|
| `Alt+Up` | Up |
|
||||||
|
| `F5` | Refresh |
|
||||||
|
| `Enter` | Open |
|
||||||
|
| `F2` | Rename |
|
||||||
|
| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | Copy / Cut / Paste |
|
||||||
|
| `Delete` | Delete to Recycle Bin |
|
||||||
|
| `Ctrl+T` | New tab |
|
||||||
|
| `Ctrl+W` | Close tab |
|
||||||
|
|
||||||
|
Drag and drop copies by default; hold `Shift` to move.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Product name `Explorer` is temporary; app data uses `ExplorerWorkbench`.
|
||||||
|
- UI strings are English in this build; resource structure can take German later.
|
||||||
|
- Everything-like search syntax is not implemented; name/glob plus size/type/date filters are.
|
||||||
|
- USN requires privileges Windows does not grant to a normal desktop process on many volumes — fallback is used then.
|
||||||
|
- Duplicate hashing is staged (size → partial hash → full hash only when partial hashes still collide) and runs in the background; confirmed groups appear after hashing finishes.
|
||||||
|
- No treemap, no document full-text, no cloud providers, no installer/MSIX in this run.
|
||||||
|
- Very large folders rely on WPF virtualization; search results are paged (500, hard cap 10 000).
|
||||||
|
- `mmap_size` is not applied; it caused unreliable native SQLite startup under concurrent test hosts.
|
||||||
|
- UI chrome is English; German `.resx` files are not wired through every control in this run.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Use as a private/local project unless otherwise specified.
|
||||||
897
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,897 @@
|
|||||||
|
# Explorer — Technischer Entwurf
|
||||||
|
|
||||||
|
Arbeitsname: **Explorer**. Mentales Modell für den Benutzer:
|
||||||
|
|
||||||
|
> Explorer öffnen → Dateien sehen → Dateien verwalten → sofort suchen → Speicher analysieren.
|
||||||
|
|
||||||
|
Die Datenbank, der Index und „Sources“ sind interne Konzepte. In der UI heißen sie Laufwerke, Ordner, Offline-Medien und Suche — niemals Catalog, Collection oder Database.
|
||||||
|
|
||||||
|
Dieses Dokument ist die Grundlage vor der Implementierung. Es trifft Empfehlungen inklusive Trade-offs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Empfohlene Gesamtarchitektur
|
||||||
|
|
||||||
|
### 1.1 Stil
|
||||||
|
|
||||||
|
**Modularer Monolith** mit klaren Projektgrenzen, nicht Microservices.
|
||||||
|
|
||||||
|
- Eine Solution, mehrere Assemblies.
|
||||||
|
- Die Indexierungs- und Speicherlogik kennt **kein WPF**.
|
||||||
|
- Die WPF-App ist ein Client der Application-Schicht.
|
||||||
|
- Später können CLI, Windows-Dienst oder ein Web-UI dieselbe Application-Schicht nutzen.
|
||||||
|
|
||||||
|
**MVVM ist Pflicht** für die UI (`CommunityToolkit.Mvvm`). Views enthalten kein Business; ViewModels orchestrieren Use-Cases; Domain/Indexer bleiben UI-frei.
|
||||||
|
|
||||||
|
### 1.2 Schichten
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Explorer.App WPF Shell, Themes, Views, Templates │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Explorer.Presentation Navigation, Split, Dialoge, VM │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Explorer.Application Use-Cases, Jobs, Fortschritt, Policy │
|
||||||
|
├───────────────┬──────────────┬──────────────┬───────────────┤
|
||||||
|
│ Indexing │ FileOps │ Search │ Analysis │
|
||||||
|
│ ChangeTrack │ TransferQ │ Query │ Duplicates │
|
||||||
|
│ SourceMgmt │ │ │ History │
|
||||||
|
├───────────────┴──────────────┴──────────────┴───────────────┤
|
||||||
|
│ Explorer.Domain Entities, Value Objects, Policies │
|
||||||
|
├───────────────────────────────┬─────────────────────────────┤
|
||||||
|
│ Explorer.Storage.Sqlite │ Explorer.Windows (Win32) │
|
||||||
|
│ Schema, FTS5, Bulk, WAL │ USN, Volumes, Recycle, IO │
|
||||||
|
└───────────────────────────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Abhängigkeitsregel: **innen kennt außen nicht**. `Explorer.Domain` hat keine SQLite-, Win32- oder WPF-Referenz. `Explorer.Indexing` hängt an Domain + Abstraktionen. Konkrete SQLite- und Win32-Adapter liegen außen.
|
||||||
|
|
||||||
|
### 1.3 Laufzeitmodell (V1)
|
||||||
|
|
||||||
|
Alles läuft im Desktopprozess, aber **nicht** auf dem UI-Thread:
|
||||||
|
|
||||||
|
| Worker | Verantwortung |
|
||||||
|
|---|---|
|
||||||
|
| UI / Dispatcher | Rendering, Input, Bindings |
|
||||||
|
| SQLite-Writer (1 Thread) | Alle Schreibzugriffe, Transaktionen |
|
||||||
|
| SQLite-Reader Pool | Suche, Ordnergrößen, Analyse |
|
||||||
|
| Scan Workers | Enumeration, begrenzt parallel |
|
||||||
|
| USN / Watcher | Change intake → Index-Commands |
|
||||||
|
| Transfer Workers | Copy/Move/Delete Queue |
|
||||||
|
| Hash Workers | Duplikate, niedrige I/O-Priorität |
|
||||||
|
|
||||||
|
SQLite erlaubt nur einen Writer. Deshalb ein **Command-Kanal** (`System.Threading.Channels`) in den Writer-Thread. Leser nutzen WAL und blockieren die UI nicht.
|
||||||
|
|
||||||
|
### 1.4 Live-FS vs. Index (zentrale UX-Entscheidung)
|
||||||
|
|
||||||
|
**Empfehlung: Hybrid.**
|
||||||
|
|
||||||
|
| Situation | Datenquelle |
|
||||||
|
|---|---|
|
||||||
|
| Aktueller Ordner, Source online | Live-Dateisystem, virtuelle Liste |
|
||||||
|
| Ordnergrößen, Suche, Analyse, Offline | Index |
|
||||||
|
| Gerade erstellte Datei im offenen Ordner | sofort sichtbar (Live-FS), Index holt nach |
|
||||||
|
|
||||||
|
Nur-Index-UI fühlt sich nach Katalog an und hinkt hinter dem Explorer her. Nur-FS-UI verschenkt den Index. Der Hybrid hält das Explorer-Gefühl und nutzt das Gedächtnis dort, wo es den Nutzen stiftet.
|
||||||
|
|
||||||
|
### 1.5 Warum nicht WinUI 3 / MAUI
|
||||||
|
|
||||||
|
Der Auftrag ist WPF. WPF hat die reifste Desktop-Splitter-/Virtualisierungs-Geschichte und reicht für Fluent-Optik (Themes, optional WPF-UI). WinUI 3 bleibt eine spätere Shell-Option, weil die Core-Projekte UI-frei sind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Projekt-/Solution-Struktur
|
||||||
|
|
||||||
|
```
|
||||||
|
Explorer.sln
|
||||||
|
├── src/
|
||||||
|
│ ├── Explorer.App/ # WPF, app.manifest, Themes
|
||||||
|
│ ├── Explorer.Presentation/ # ViewModels, UI-Services
|
||||||
|
│ ├── Explorer.Application/ # Use-Cases, Job-Orchestrierung
|
||||||
|
│ ├── Explorer.Domain/ # Source, Entry, Policies
|
||||||
|
│ ├── Explorer.Indexing/ # Scan, USN-Apply, Watcher-Apply
|
||||||
|
│ ├── Explorer.Search/ # Query-Modell, FTS-Adapter
|
||||||
|
│ ├── Explorer.Analysis/ # Folder sizes, Top-N, Duplikate
|
||||||
|
│ ├── Explorer.FileOperations/ # Transfer Queue, Konflikte
|
||||||
|
│ ├── Explorer.Storage.Sqlite/ # Schema, Repositories, FTS5
|
||||||
|
│ ├── Explorer.Windows/ # CsWin32: USN, Volume, IFileOperation
|
||||||
|
│ └── Explorer.Contracts/ # DTOs / später IPC
|
||||||
|
├── tests/
|
||||||
|
│ ├── Explorer.Domain.Tests/
|
||||||
|
│ ├── Explorer.Indexing.Tests/
|
||||||
|
│ ├── Explorer.Storage.Tests/
|
||||||
|
│ └── Explorer.FileOperations.Tests/
|
||||||
|
└── docs/
|
||||||
|
└── ARCHITECTURE.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Trade-off:** Mehr Projekte als ein einziges `Explorer.Core` bedeuten etwas mehr Ceremony, aber verhindern, dass WPF-Types in den Indexer sickern. Das ist die Wartbarkeits-Priorität.
|
||||||
|
|
||||||
|
Zielframework: **.NET 10 LTS** (Stand 2026). WPF, `net10.0-windows`, Windows 10 1809+ / Windows 11.
|
||||||
|
|
||||||
|
NuGet-Kern (V1):
|
||||||
|
|
||||||
|
| Paket | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| CommunityToolkit.Mvvm | MVVM, Messenger, AsyncRelayCommand |
|
||||||
|
| Microsoft.Data.Sqlite | SQLite |
|
||||||
|
| Dapper | Dünnes Mapping, keine EF-Hotpath |
|
||||||
|
| Microsoft.Windows.CsWin32 | USN, Volumes, Recycle Bin |
|
||||||
|
| Serilog | Datei-Log, Scan-Fehler |
|
||||||
|
| Microsoft.Extensions.Hosting | DI, BackgroundService im Prozess |
|
||||||
|
|
||||||
|
**Kein Entity Framework** für den Index. EF ist ungeeignet für Millionen Bulk-UPSERTs, FTS5 und partielle USN-Updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Kernkomponenten und Verantwortlichkeiten
|
||||||
|
|
||||||
|
| Komponente | Verantwortung | Nicht verantwortlich |
|
||||||
|
|---|---|---|
|
||||||
|
| **Shell / Navigation** | Fenster, Tabs, Tree, Breadcrumb, History, Themes | Index, Transfers |
|
||||||
|
| **ExplorerPane** | Ein navigierbares Panel (Pfad, View-Mode, Selection, Sort) | Anderes Panel, DB |
|
||||||
|
| **SplitHost** | 1 oder 2 Panes, Splitter, DnD-Ziel | Datei-I/O selbst |
|
||||||
|
| **Source Manager** | Entdecken, identifizieren, Online/Offline, Anzeige-Name | Scanning |
|
||||||
|
| **Indexer** | Full scan, incremental, Resume, Excludes, Reparse-Policy | UI, File copy |
|
||||||
|
| **Change Tracker** | USN lesen, Watcher-Events, zu Index-Commands normalisieren | Persistenz-Details |
|
||||||
|
| **Storage** | Schema, Transaktionen, FTS, Aggregates | Win32 |
|
||||||
|
| **Search Engine** | Filter/Query → SQL/FTS, Ranking | Index aktualisieren |
|
||||||
|
| **Analysis Engine** | Top-Dirs/Files, Typen, Drilldown aus Aggregates | Live-Walk des FS |
|
||||||
|
| **Duplicate Detector** | Size → Partial Hash → Full Hash, I/O-Throttle | UI-Layout |
|
||||||
|
| **File Operations** | Queue, Progress, Recycle, Clipboard, Konflikte | Index-Schreiben (Indexer beobachtet Ergebnis) |
|
||||||
|
| **Platform** | Volumes, USN, Long Paths, Icons, Recycle, Share-Detect | Business-Regeln |
|
||||||
|
|
||||||
|
Kommunikation intern über:
|
||||||
|
|
||||||
|
- Synchron: Application-Services (aktuelle Ordnerliste, Suche).
|
||||||
|
- Asynchron: `IProgress<ScanProgress>`, `Channel<IndexCommand>`, Messenger für „Source status changed“.
|
||||||
|
|
||||||
|
Indexer-Events aktualisieren den Index; der ExplorerPane subscribed auf „current folder invalidated“ und refresht nur den offenen Pfad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datenmodell / SQLite-Schema
|
||||||
|
|
||||||
|
### 4.1 Betriebsmodus
|
||||||
|
|
||||||
|
```sql
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA temp_store = MEMORY;
|
||||||
|
PRAGMA mmap_size = 268435456; -- 256 MB, tunen
|
||||||
|
PRAGMA cache_size = -131072; -- 128 MB
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema-Version über `PRAGMA user_version`. Migrationen als eingebettete SQL-Skripte.
|
||||||
|
|
||||||
|
### 4.2 Tabellen
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
stable_key TEXT NOT NULL UNIQUE, -- App-UUID, nie Buchstabe
|
||||||
|
kind TEXT NOT NULL, -- NtfsLocal, Removable, Smb, Nfs, Cloud
|
||||||
|
display_name TEXT NOT NULL, -- "SanDisk Ultra 64 GB"
|
||||||
|
volume_guid TEXT, -- \\?\Volume{...}\
|
||||||
|
volume_serial INTEGER, -- NTFS/FAT serial
|
||||||
|
filesystem TEXT, -- NTFS, exFAT, SMBFS, NFS
|
||||||
|
label TEXT,
|
||||||
|
capacity_bytes INTEGER,
|
||||||
|
device_instance_id TEXT, -- USB PnP, optional
|
||||||
|
last_root_path TEXT, -- E:\ oder \\media\movies
|
||||||
|
status TEXT NOT NULL, -- Online, Offline, Scanning, Stale, Error
|
||||||
|
last_seen_utc TEXT,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
usn_journal_id INTEGER, -- 64-bit als INTEGER
|
||||||
|
usn_next INTEGER,
|
||||||
|
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||||
|
parent_id INTEGER REFERENCES entries(id),
|
||||||
|
name TEXT NOT NULL, -- Original-Schreibweise
|
||||||
|
name_norm TEXT NOT NULL, -- Unicode-Fold für Suche/Unique
|
||||||
|
extension TEXT, -- ohne Punkt, lower
|
||||||
|
is_dir INTEGER NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0, -- Datei: logische Größe
|
||||||
|
aggregate_size INTEGER NOT NULL DEFAULT 0, -- Dir: Summe Kinder+selbst
|
||||||
|
child_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
child_dir_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_utc TEXT,
|
||||||
|
modified_utc TEXT,
|
||||||
|
last_seen_utc TEXT NOT NULL,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
attributes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
file_id INTEGER, -- NTFS FRN
|
||||||
|
parent_file_id INTEGER,
|
||||||
|
reparse_tag INTEGER, -- 0 = keines
|
||||||
|
status INTEGER NOT NULL DEFAULT 0, -- 0 Present, 1 Offline, 2 Deleted, 3 Unknown
|
||||||
|
deleted_utc TEXT,
|
||||||
|
path_rel TEXT NOT NULL, -- relativ zum Source-Root, \ getrennt
|
||||||
|
content_hash BLOB, -- SHA-256, nullable
|
||||||
|
hash_state INTEGER NOT NULL DEFAULT 0, -- 0 none, 1 partial, 2 full
|
||||||
|
UNIQUE (source_id, parent_id, name_norm)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status);
|
||||||
|
CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0;
|
||||||
|
CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0;
|
||||||
|
CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc);
|
||||||
|
CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL;
|
||||||
|
CREATE INDEX ix_entries_path ON entries(source_id, path_rel);
|
||||||
|
CREATE INDEX ix_entries_status ON entries(source_id, status);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
||||||
|
name,
|
||||||
|
name_norm,
|
||||||
|
extension,
|
||||||
|
content = 'entries',
|
||||||
|
content_rowid = 'id',
|
||||||
|
tokenize = 'unicode61'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE excludes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scope TEXT, -- global oder source_id
|
||||||
|
source_id INTEGER REFERENCES sources(id),
|
||||||
|
kind TEXT NOT NULL, -- PathPrefix, Glob, Extension, Attribute
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE scan_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- Full, Incremental, Folder, Rebuild
|
||||||
|
status TEXT NOT NULL, -- Queued, Running, Cancelled, Failed, Done
|
||||||
|
started_utc TEXT,
|
||||||
|
finished_utc TEXT,
|
||||||
|
files_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dirs_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resume_path TEXT, -- letzter abgeschlossener Ordner
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE scan_errors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
path TEXT,
|
||||||
|
kind TEXT, -- AccessDenied, Invalid, Io, Network
|
||||||
|
message TEXT,
|
||||||
|
utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transfer_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
op TEXT NOT NULL, -- Copy, Move, Delete, Rename
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bytes_total INTEGER,
|
||||||
|
bytes_done INTEGER,
|
||||||
|
created_utc TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE hash_queue (
|
||||||
|
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL -- Pending, PartialDone, Done, Error
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE source_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
total_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL,
|
||||||
|
dir_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE directory_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
path_rel TEXT NOT NULL, -- nicht jede Datei, nur Dirs über Schwellwert
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
aggregate_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Pfade speichern oder rekonstruieren?
|
||||||
|
|
||||||
|
**Empfehlung: `path_rel` denormalisiert speichern.**
|
||||||
|
|
||||||
|
- Rekonstruktion über Parent-Kette ist bei 10M Zeilen und Suche zu teuer.
|
||||||
|
- Speicher: 10M × ~80 Byte ≈ 0,8 GB — akzeptabel neben dem Nutzen.
|
||||||
|
- Rename eines Vorfahren: `UPDATE entries SET path_rel = $new || substr(path_rel, length($old)+1) WHERE source_id=? AND path_rel LIKE $old || '\%' ESCAPE ...`
|
||||||
|
|
||||||
|
SQLite `NOCASE` ist **nur ASCII**. Deshalb `name_norm` in der App (Unicode-Fold) und FTS5 `unicode61`. Niemals auf `COLLATE NOCASE` für deutsche/asiatische Namen verlassen.
|
||||||
|
|
||||||
|
Root-Eintrag jeder Source: `parent_id IS NULL`, `path_rel = ''`.
|
||||||
|
|
||||||
|
Anzeige-Pfad = `sources.last_root_path` + `path_rel`. Offline: Anzeige über `display_name` + `path_rel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Strategie für Volume-Identifikation
|
||||||
|
|
||||||
|
Eine Source hat eine **stabile App-UUID** (`stable_key`). Laufwerksbuchstaben sind nur `last_root_path`.
|
||||||
|
|
||||||
|
### 5.1 Lokale / Wechselmedien
|
||||||
|
|
||||||
|
Fingerprint, Match-Reihenfolge:
|
||||||
|
|
||||||
|
1. **Volume GUID** (`GetVolumeNameForVolumeMountPoint`) — primär, überlebt Buchstabenwechsel.
|
||||||
|
2. **Volume Serial + Filesystem + Capacity** — GUID fehlt (manche exFAT/USB).
|
||||||
|
3. **Serial + Label + Capacity** — schwächer.
|
||||||
|
4. **Device Instance ID** (USB PnP) als Zusatzsignal.
|
||||||
|
5. Unklar → Benutzer: „Ist das derselbe Datenträger wie SanDisk Ultra 64 GB?“
|
||||||
|
|
||||||
|
Nicht als Identität verwenden: nur Buchstabe, nur Label, nur Größe.
|
||||||
|
|
||||||
|
### 5.2 SMB
|
||||||
|
|
||||||
|
- Kanonische Form: `\\server\share` (Hostname lower-case, Share original).
|
||||||
|
- Zusätzlich speichern: eingegebener Pfad, ggf. IP.
|
||||||
|
- `stable_key` bleibt UUID; UNC ist `last_root_path`.
|
||||||
|
- Hostname vs. IP vs. FQDN können dieselbe Share sein — V1 nicht automatisch mergen; manuell „als dasselbe Medium verbinden“ reicht.
|
||||||
|
|
||||||
|
### 5.3 NFS
|
||||||
|
|
||||||
|
Windows-NFS-Client (optional Feature) erscheint als Laufwerk oder Mount. Behandeln wie Netzwerk-FS ohne USN. Identität: Server+Export-Pfad.
|
||||||
|
|
||||||
|
### 5.4 Cloud (Plugin-Overlay)
|
||||||
|
|
||||||
|
Explorer **implementiert keine Synchronisation**. Offizielle Clients (OneDrive, später Google Drive / Dropbox) bleiben zuständig für Sync, Hydration, Konflikte und Anmeldung.
|
||||||
|
|
||||||
|
Cloud-Ordner werden wie jedes andere gemountete Dateisystem über Win32 gelistet. `Explorer.Plugin.Abstractions` / `IStorageProvider` **reichern** Einträge an (Status, logische vs. allokierte Größe, Pin/Free-up), sie **ersetzen nicht** Enumeration, Watcher oder Source-Identität.
|
||||||
|
|
||||||
|
- Fehlendes, deaktiviertes oder fehlerhaftes Plugin: normales Browse.
|
||||||
|
- Index, Thumbnails, Duplikat-Hash und Analyse **dürfen online-only Dateien nicht hydrieren**. Lesen von Dateiinhalten nur bei expliziter Nutzeraktion (Öffnen, Kopieren, Pin).
|
||||||
|
- Fähigkeiten sind entdeckbar (`CloudState`, `Pin`, `Dehydrate`, `Quota`, …), nicht hart verdrahtet.
|
||||||
|
- Verträge sind IPC-tauglich (`InProcess` / `OutOfProcess`). PluginHost/Marketplace sind nicht Teil dieses Schritts.
|
||||||
|
|
||||||
|
### 5.5 Offline-Anzeige
|
||||||
|
|
||||||
|
Tree-Knoten:
|
||||||
|
|
||||||
|
```
|
||||||
|
SanDisk Ultra 64 GB
|
||||||
|
Offline · Zuletzt gesehen 18.08.2026
|
||||||
|
```
|
||||||
|
|
||||||
|
Statusfarbe/Badge. Doppelklick öffnet den Index-Browser (read-only Listing + Suche), keine Live-Operationen außer „Pfad kopieren“ / „merken, wohin kopiert werden sollte“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Initialscan-Strategie
|
||||||
|
|
||||||
|
Manuell pro Source. Kein stiller Vollscan aller Festplatten beim ersten Start — das wäre unexplorer-typisch und I/O-feindlich. Beim ersten Öffnen eines Laufwerks: dezentes Banner „Index anlegen, damit Suche und Ordnergrößen funktionieren“ mit Aktion **Index aufbauen**.
|
||||||
|
|
||||||
|
### 6.1 Algorithmus
|
||||||
|
|
||||||
|
1. Job anlegen, Status Scanning, `scan_generation++`.
|
||||||
|
2. Exclude-Regeln laden.
|
||||||
|
3. Iterative DFS/Post-Order mit `System.IO.Enumeration.FileSystemEnumerable` und `\\?\`-Präfix.
|
||||||
|
4. Parallelität: lokal 4 Enum-Worker, SMB/NFS **1–2**.
|
||||||
|
5. Pro Batch (2 000–5 000 Einträge) eine Transaktion an den SQLite-Writer.
|
||||||
|
6. Ordnergrößen **post-order im Scan** akkumulieren (Stack), nicht per Parent-Walk pro Datei.
|
||||||
|
7. Einzeldateifehler → `scan_errors`, weiter.
|
||||||
|
8. Access Denied → loggen, Ordner überspringen.
|
||||||
|
9. `CancellationToken` auf jedem Worker; nach Cancel konsistenter Stand (abgeschlossene Batches committed).
|
||||||
|
10. Fortschritt: Dateien, Ordner, Bytes, aktueller Pfad, Fehlerzahl — UI via `IProgress`, gedrosselt (~10 Hz).
|
||||||
|
|
||||||
|
Thread-/Prozess-I/O-Priorität: `PROCESS_MODE_BACKGROUND_BEGIN` während Scan, damit Explorer und andere Apps fluide bleiben.
|
||||||
|
|
||||||
|
### 6.2 Resume (V1 pragmatisch, V1.1 vollständig)
|
||||||
|
|
||||||
|
- V1: unterbrochener Scan gilt als unvollständig; **erneuter Full Scan mit UPSERT** (idempotent). `status=Present` nur für gesehene Einträge der neuen Generation; Rest → Deleted gemäß Retention.
|
||||||
|
- V1.1: `resume_path` = letzter **abgeschlossener** Ordner; Skip bereits geschriebener Subtrees.
|
||||||
|
|
||||||
|
UPSERT-Schlüssel: `(source_id, parent_id, name_norm)`. FRN zusätzlich für Rename-Erkennung.
|
||||||
|
|
||||||
|
### 6.3 Millionen Dateien
|
||||||
|
|
||||||
|
Grobe Rechnung: 5M Dateien × ~250 Byte Indexzeile ≈ 1,25 GB plus FTS und Indizes → **2–4 GB DB** realistisch. SSD lokal: Initialscan CPU/I/O-gebunden, nicht SQLite-gebunden, sofern Batches und ein Writer stimmen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. NTFS-USN-Journal-Strategie
|
||||||
|
|
||||||
|
### 7.1 Ziel
|
||||||
|
|
||||||
|
Nach dem Initialscan **kein** periodischer Full Scan auf lokalen NTFS-Volumes. Änderungen seit `usn_next` einlesen und anwenden.
|
||||||
|
|
||||||
|
### 7.2 Ablauf
|
||||||
|
|
||||||
|
1. `FSCTL_QUERY_USN_JOURNAL` → `UsnJournalID`, `NextUsn`.
|
||||||
|
2. Persistiert in `sources.usn_journal_id` / `usn_next`.
|
||||||
|
3. Beim Start / periodisch / nach Watcher-Burst: `FSCTL_READ_USN_JOURNAL` ab gespeichertem USN.
|
||||||
|
4. Records (`USN_RECORD_V2/V3`) auf `IndexCommand` mappen:
|
||||||
|
- Create → Insert
|
||||||
|
- Delete → Tombstone/Delete
|
||||||
|
- Rename old/new → path_rel umschreiben
|
||||||
|
- Data/Basic info → Size, Times, Attributes
|
||||||
|
5. FRN (`FileReferenceNumber`) → `entries.file_id`.
|
||||||
|
6. Nach erfolgreichem Batch `usn_next` fortschreiben **in derselben Transaktion**.
|
||||||
|
|
||||||
|
Journal-ID geändert oder USN zu alt (Journal wrapped): Source → **Stale**, Banner „Index veraltet — aktualisieren“, kein stilles Vollscan ohne Auftrag (außer User-Setting „automatisch neu aufbauen“).
|
||||||
|
|
||||||
|
### 7.3 Rechte — wichtiger Trade-off
|
||||||
|
|
||||||
|
Das USN-Journal ist oft nur mit **Administrator** oder Backup-Privilegien vollständig lesbar.
|
||||||
|
|
||||||
|
| Option | Vorteil | Nachteil |
|
||||||
|
|---|---|---|
|
||||||
|
| A Immer als Admin | Zuverlässiges USN | UAC, schlechte Desktop-UX |
|
||||||
|
| B Optionaler Index-Dienst als SYSTEM | USN ohne UI-Elevation | Komplex, V2 |
|
||||||
|
| C **V1: USN wenn möglich, sonst Fallback** | Kein Pflicht-Admin | Manche Volumes nur Watcher+Walk |
|
||||||
|
|
||||||
|
**Empfehlung C für V1**, Architektur für B offen halten (`IChangeFeed`).
|
||||||
|
|
||||||
|
Fallback lokal ohne USN:
|
||||||
|
|
||||||
|
- `FileSystemWatcher` (best effort, Buffer können überlaufen).
|
||||||
|
- Directory-mtime / `FindFirstFile` inkrementeller Ordnerwalk beim Öffnen und per manuellem Refresh.
|
||||||
|
- Watcher-Overflow → Source **Stale**.
|
||||||
|
|
||||||
|
FileSystemWatcher **nie** als alleinige Wahrheitsquelle.
|
||||||
|
|
||||||
|
### 7.4 Coalescing
|
||||||
|
|
||||||
|
USN liefert viele Reasons plus oft `USN_REASON_CLOSE`. Apply-Logik coalesct pro FRN im Batch (letzte Create/Delete/Rename gewinnt), um Schreiblast zu senken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Strategie für SMB/NFS
|
||||||
|
|
||||||
|
Kein USN, unzuverlässige Watcher, hohe Latenz.
|
||||||
|
|
||||||
|
**Empfehlung: Hybrid aus opportunistischem Walk und manueller Aktualisierung.**
|
||||||
|
|
||||||
|
| Trigger | Aktion |
|
||||||
|
|---|---|
|
||||||
|
| Ordner öffnen (online) | Live-Listing; Diff gegen Index für diesen Ordner (Namen, Size, Mtime) |
|
||||||
|
| Manuell Refresh | Ordner oder Source |
|
||||||
|
| Optional Timer (User) | z. B. alle 30 min, nur gemountete Shares, idle |
|
||||||
|
| Watcher, falls der Redirector Events liefert | best effort, Overflow → Stale |
|
||||||
|
|
||||||
|
Inkrementeller Ordner-Rescan:
|
||||||
|
|
||||||
|
1. Kinder enumerieren.
|
||||||
|
2. Vergleich mit `entries` dieses `parent_id`.
|
||||||
|
3. Neu → insert, fehlend → tombstone, Mtime/Size geändert → update.
|
||||||
|
4. Rekursiv nur wenn User „Ordner neu einlesen“ oder Full Rescan.
|
||||||
|
|
||||||
|
Netzwerk-I/O: **ein Enum-Worker pro Share**, Timeouts, Reconnect-Backoff. Verschwundene Share → Offline, Index bleibt.
|
||||||
|
|
||||||
|
NFS unter Windows nur, wenn der optional Client installiert ist. Sonst UNC/NFS nicht erzwingen; Source-Typ `Nfs` vorsehen, Provider kann „nicht verfügbar“ liefern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Suche und benötigte DB-Indizes
|
||||||
|
|
||||||
|
### 9.1 Syntax vs. UI-Filter
|
||||||
|
|
||||||
|
**V1: UI-Filter + schnelles Suchfeld für Namen/Glob.**
|
||||||
|
**V1.1: kompakte Query-Syntax.**
|
||||||
|
|
||||||
|
Begründung: Syntax ohne Parser-Disziplin wird zur Falle (`size > 10 GB` vs. `size>10GB`). Everything-Nutzer erwarten sie später. Die Query-Engine sollte intern schon ein strukturiertes `SearchQuery`-Objekt haben, das die Syntax später nur parst.
|
||||||
|
|
||||||
|
V1 Suchfeld:
|
||||||
|
|
||||||
|
- Freitext → FTS5 auf `name` / `name_norm` (Prefix `abc*`)
|
||||||
|
- `*.mkv` → Extension-Filter
|
||||||
|
- optional ein Filter-Panel: Typ, Größe von–bis, Datum, nur Ordner, Source-Scope
|
||||||
|
|
||||||
|
V1.1 Syntax (Everything-ähnlich, klein halten):
|
||||||
|
|
||||||
|
```
|
||||||
|
*.mkv size:>10gb
|
||||||
|
type:video size:>5gb
|
||||||
|
modified:<2025-01-01
|
||||||
|
*.zip size:>1gb
|
||||||
|
path:Downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
Keine vollständige Programmiersprache. Ein PEG/Regex-Lexer reicht.
|
||||||
|
|
||||||
|
### 9.2 Scopes
|
||||||
|
|
||||||
|
`SearchScope`: CurrentFolder | CurrentTree | Selected | Sources[] | AllKnown (inkl. Offline).
|
||||||
|
|
||||||
|
CurrentFolder kann Live-FS filtern (sofort). CurrentTree und AllKnown **nur Index** — das ist der Produktvorteil.
|
||||||
|
|
||||||
|
### 9.3 Indizes (siehe Schema)
|
||||||
|
|
||||||
|
Hot Paths:
|
||||||
|
|
||||||
|
- FTS5 für Teilnamen
|
||||||
|
- `(source_id, parent_id, status)` Listing
|
||||||
|
- `(source_id, extension, size_bytes)` Typ+Größe
|
||||||
|
- `(source_id, size_bytes)` Duplikat-Kandidaten
|
||||||
|
- `(source_id, path_rel)` Tree-Suche `path_rel LIKE 'Movies\%'`
|
||||||
|
- `(source_id, file_id)` USN
|
||||||
|
|
||||||
|
Query-Plan in Tests mit `EXPLAIN QUERY PLAN` gegen eine 1M-Zeilen-Fixture absichern.
|
||||||
|
|
||||||
|
Ergebnis-Limit default 10 000, virtuelle Liste, „mehr laden“. Niemals 2M Rows in die UI binden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Architektur der Split View
|
||||||
|
|
||||||
|
Tabs und Split sind **orthogonal**.
|
||||||
|
|
||||||
|
```
|
||||||
|
Window
|
||||||
|
└── TabStrip mehrere Arbeitsbereiche
|
||||||
|
└── ExplorerTab
|
||||||
|
└── SplitHost Single | Dual (vertikal, später horizontal)
|
||||||
|
├── ExplorerPane A eigene History, Pfad, View, Selection
|
||||||
|
└── ExplorerPane B
|
||||||
|
```
|
||||||
|
|
||||||
|
Nicht: Tabs ersetzen Split. Nicht: ein globaler Dual-Pane ohne Tabs (Total Commander). Beides:
|
||||||
|
|
||||||
|
- Tab = Arbeitskontext (z. B. „Videos sortieren“).
|
||||||
|
- Split in einem Tab = zwei unabhängige `ExplorerPane`.
|
||||||
|
|
||||||
|
Default neues Tab: **Single**. Aktion „Split“ öffnet rechtes Panel mit demselben Pfad oder dem zweiten selektierten Ordner. Layout persistieren pro Tab.
|
||||||
|
|
||||||
|
Jedes Pane:
|
||||||
|
|
||||||
|
- `NavigationService` (Back/Forward/Up, Breadcrumb)
|
||||||
|
- `FolderViewModel` (Items, Sort, Filter, ViewMode Details/List/Icons)
|
||||||
|
- `IDropTarget` / `IDragSource`
|
||||||
|
|
||||||
|
DnD und Copy/Paste zwischen Panes = dieselben `FileOperations` wie innerhalb eines Panes. Quelle und Ziel sind Pfade, nicht „Panel-IDs“.
|
||||||
|
|
||||||
|
Tastatur (V1 anlehnen, später konfigurierbar):
|
||||||
|
|
||||||
|
- `Tab` Fokus anderes Panel
|
||||||
|
- `F5` / `F6` Refresh (nicht TC-Copy, um Explorer-Nutzer nicht zu verwirren)
|
||||||
|
- Copy/Paste Standard, plus später TC-Shortcuts als Option
|
||||||
|
|
||||||
|
Tree links: **fensterweit**, nicht pro Pane — sonst wird die UI eng. Klick setzt das **aktive** Pane. Optional später Tree pro Pane.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Dateioperationen und Transfer Queue
|
||||||
|
|
||||||
|
### 11.1 V1-Operationen
|
||||||
|
|
||||||
|
Öffnen, Kopieren, Verschieben, Umbenennen, Löschen (Recycle), Neuer Ordner, DnD, Clipboard (`CF_HDROP` / Shell IDLists), Pfad kopieren.
|
||||||
|
|
||||||
|
Asynchron, UI nie blockieren. Kurze Ops (Rename, New Folder) ohne Queue-Fenster; lange Ops in die Queue.
|
||||||
|
|
||||||
|
### 11.2 Queue
|
||||||
|
|
||||||
|
```
|
||||||
|
UI → FileOperationService.Enqueue(op)
|
||||||
|
→ Channel<TransferJob>
|
||||||
|
→ 1–2 Worker (lokal parallel begrenzt, SMB serieller)
|
||||||
|
→ Progress (bytes_done) → TransferPanel
|
||||||
|
```
|
||||||
|
|
||||||
|
V1: Queue, Fortschritt, Abbrechen, Fehler anzeigen.
|
||||||
|
Später: Pause/Resume (`CopyFileEx` COPY_FILE_RESTARTABLE), Konfliktdialog, Retry, Undo (Rename/Move inner volume).
|
||||||
|
|
||||||
|
### 11.3 Windows-Integration
|
||||||
|
|
||||||
|
| Op | API |
|
||||||
|
|---|---|
|
||||||
|
| Recycle Delete | `IFileOperation` (empfohlen) oder `SHFileOperation` mit `FOF_ALLOWUNDO` |
|
||||||
|
| Copy/Move mit Progress | `CopyFileEx` / `MoveFileWithProgress` **oder** `IFileOperation` mit Progress-Sink |
|
||||||
|
| Open | `ShellExecute` / `Process.Start(UseShellExecute=true)` |
|
||||||
|
| Icons | `SHGetFileInfo` / ImageList, gecacht |
|
||||||
|
|
||||||
|
**Empfehlung V1:** `IFileOperation` für Delete-to-Recycle (korrekte Undo, Elevation-Prompt). Eigene Copy-Engine (`CopyFileEx`) für Queue-Kontrolle. Trade-off: zwei Pfade vs. volle Kontrolle über Pause und Throughput.
|
||||||
|
|
||||||
|
Locked Files: Fehler in der Queue, Rest weiter. USB während Copy entfernt: Job Failed, Source Offline.
|
||||||
|
|
||||||
|
Nach erfolgreicher Op: Indexer per Command (oder USN) aktualisieren — nicht auf Watcher hoffen.
|
||||||
|
|
||||||
|
Long Paths: Manifest `longPathAware`, intern `\\?\` / `\\?\UNC\`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Analyse- / Folder-Size-Konzept
|
||||||
|
|
||||||
|
Der Index hält `aggregate_size` und Child-Counts **immer aktuell**, nicht beim Öffnen der Analyse.
|
||||||
|
|
||||||
|
### 12.1 Pflege der Aggregates
|
||||||
|
|
||||||
|
- **Initialscan:** Post-Order, einmal schreiben.
|
||||||
|
- **USN/incremental:** Delta an der Datei entlang der Parent-Kette (`parent_id`) addieren/subtrahieren. Tiefe selten > 20; bei Rename: subtract am alten Ast, add am neuen.
|
||||||
|
- Niemals `SUM(*)` über den ganzen Tree für die Explorer-Spalte.
|
||||||
|
|
||||||
|
### 12.2 Explorer-Spalte
|
||||||
|
|
||||||
|
Optionale Spalte „Größe“ für Ordner aus `aggregate_size`. Wenn Index Stale/Scanning: graue Zahl + Hinweis. Online ohne Index: Spalte leer oder „—“, kein heimlicher Recursive Walk (sonst sind wir wieder WinDirStat).
|
||||||
|
|
||||||
|
### 12.3 Analyse-View
|
||||||
|
|
||||||
|
Eigenes Overlay, nicht den Explorer ersetzen. Default ist ein **hierarchischer Tree** der indexierten Sources und Ordner (WinDirStat-ähnlich, Explorer-Workbench-Look). Ranking-Views daneben: Biggest folders, Biggest files, By file type, By source.
|
||||||
|
|
||||||
|
- Tree: Roots = Sources (`GetRootAsync`); Expand lädt Kinder lazy via `LargestDirectoriesAsync(sourceId, parentId)` aus dem Index. Nie den kompletten Tree in WPF materialisieren, nie das Dateisystem walken.
|
||||||
|
- Balken im Tree relativ zu den **Geschwistern des aktuellen Parents**; in Ranking-Views relativ zur Liste.
|
||||||
|
- Biggest folders/files zeigen vollständigen oder gekürzten Pfad (`PathRules.ShortenDisplay`), Sortierung `aggregate_size` / `size_bytes` DESC.
|
||||||
|
- Actions: Open (aktuelles Pane / anderes Split-Pane / neuer Tab), Search within, Rescan (Indexer-Queue), Copy path.
|
||||||
|
- Virtualisierte `ListView`, Children-Cap `AnalysisTreeChildTake`.
|
||||||
|
|
||||||
|
V1 kein Treemap (Canvas/WPF-Heavy). Datenmodell erlaubt Treemap später (`aggregate_size` pro Kind).
|
||||||
|
|
||||||
|
WinDirStat-Vorteil: **O(log n) Queries statt Disk-Walk**. Nachteil: Zahl so gut wie der Index — deshalb Status „aktuell / veraltet“ sichtbar machen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Duplikaterkennung
|
||||||
|
|
||||||
|
Nicht jede Datei hashen.
|
||||||
|
|
||||||
|
1. SQL: `GROUP BY size_bytes HAVING COUNT(*) > 1` (nur Present, nicht excluded).
|
||||||
|
2. Gruppen in `hash_queue`, große Dateien niedrige Priorität oder umgekehrt je nach User-Ziel.
|
||||||
|
3. Partial Hash: erste 64 KiB SHA-256 (`hash_state=1`).
|
||||||
|
4. Nur kollidierende Partial-Gruppen: Full-File SHA-256, `content_hash` persistieren.
|
||||||
|
5. Worker: `SetThreadPriority` Background, max. 1 sequentieller Reader pro Volume, pausieren wenn Transfer-Queue aktiv.
|
||||||
|
|
||||||
|
Filter: Source, Pfad-Prefix, Exclude-Liste. Identisch = gleicher Full Hash, nicht gleicher Name.
|
||||||
|
|
||||||
|
Hardlinks (gleiche FRN): als „gleiche Datei, mehrere Pfade“ markieren, nicht als Duplikat-Müll.
|
||||||
|
|
||||||
|
V1 der Detector kann nach dem Explorer-MVP kommen; Schema (`content_hash`, `hash_queue`) trotzdem in V1 anlegen, damit kein zweites Migration-Chaos entsteht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Offline-Media-Konzept
|
||||||
|
|
||||||
|
- Source bleibt im Tree, Badge **Offline**, `last_seen_utc`, Kapazität/Label.
|
||||||
|
- Entries: `status=Offline` (Volume weg) vs. `Deleted` (USN/Scan hat Löschung gesehen).
|
||||||
|
- Navigation: Index-Listing, Suche über Offline-Medien **an**.
|
||||||
|
- Dateioperationen: Copy/Move/Delete disabled; Öffnen disabled; Pfad kopieren erlaubt.
|
||||||
|
- Wieder da: Fingerprint-Match, `last_root_path` updaten, USN oder Folder-Diff, Status Online.
|
||||||
|
|
||||||
|
USB mitten im Scan: Job abbrechen/fehlschlagen, committed Batches behalten, Source Offline, Banner „Scan unvollständig“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Historienkonzept (speichereffizient)
|
||||||
|
|
||||||
|
Nicht jede Datei versionieren.
|
||||||
|
|
||||||
|
**Drei Stufen:**
|
||||||
|
|
||||||
|
1. **Tombstones** auf `entries` (`status=Deleted`, `deleted_utc`) — Datei existierte. Retention-Policy (sofort / N Tage / dauerhaft).
|
||||||
|
2. **Source-Rollups** täglich: eine Zeile in `source_stats_history` (Größe, Counts). Das liefert „+384 GB seit letztem Monat“ ohne Dateihistorie.
|
||||||
|
3. **Directory-Rollups** nur für Ordner über Schwellwert (z. B. > 1 GB oder Top 200 je Source) in `directory_stats_history`. Retention: 90 Tage täglich, danach wöchentlich verdichten.
|
||||||
|
|
||||||
|
Kein Copy-on-Write des ganzen Trees. Kein Git fürs Dateisystem.
|
||||||
|
|
||||||
|
Spätere UI: Delta-Badge am Ordner, wenn zwei Snapshots existieren. V1 nur Schema + nächtlicher Rollup-Job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Fehler- und Recovery-Konzept
|
||||||
|
|
||||||
|
Prinzip: **Fehler sind lokal, Jobs sind unterbrechbar, die DB überlebt Crashes.**
|
||||||
|
|
||||||
|
| Ereignis | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| Access Denied | `scan_errors`, weiter |
|
||||||
|
| Invalid/corrupt dirent | loggen, weiter |
|
||||||
|
| Netzwerk weg | Source Offline, Job Failed/Cancelled, Index behalten |
|
||||||
|
| USB gezogen | wie Netzwerk; offene Transfers Failed |
|
||||||
|
| Locked file (Hash/Copy) | Eintrag/Job Error, Queue weiter |
|
||||||
|
| Sehr lange Pfade | `\\?\`, sonst Error-Log |
|
||||||
|
| Unicode | UTF-8 in SQLite, Originalname in `name` |
|
||||||
|
| UI-Crash während Scan | WAL; letzte Transaktion committed; Scan-Job beim Start als unterbrochen markieren |
|
||||||
|
| SQLite I/O error | Writer stoppt, Banner, keine stillen Writes |
|
||||||
|
| Cancel | keine halben Batches; `usn_next` nur nach Commit |
|
||||||
|
| Scan während Änderungen | USN nach Scan-Ende nachziehen; oder generation-mark + missing → Deleted |
|
||||||
|
| Journal lost | Stale + Full Rescan nötig |
|
||||||
|
|
||||||
|
DB-Integrität: nach Crash `PRAGMA quick_check` beim Start (asynchron, UI nicht blockieren). Backup optional: periodisches `VACUUM INTO` später, nicht V1.
|
||||||
|
|
||||||
|
Logging: Serilog rolling file unter `%LocalAppData%\…\logs`. Scan-Fehler zusätzlich in DB für die UI-Liste „übersprungene Ordner“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Performance-Risiken
|
||||||
|
|
||||||
|
| Risiko | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| UI-Freeze durch Binding von 100k Items | Virtualizing `ListView`/`DataGrid`, Paging, nie ganze Source materialisieren |
|
||||||
|
| SQLite Writer-Stau | Ein Writer, große Batches, keine UI-Reads im Writer |
|
||||||
|
| FTS5 bloat | External content table, `optimize` nach Full Scan |
|
||||||
|
| Watcher-Flood | Coalesce 300–500 ms, Overflow → Stale |
|
||||||
|
| SMB-Latenz | Live-Listing async, Placeholder-Zeilen, Timeout |
|
||||||
|
| Recursive LIKE ohne Index | Immer `source_id` zuerst; Prefix-LIKE nur mit Index auf `path_rel` |
|
||||||
|
| Aggregate-Update-Ketten bei Massen-Delete | Batch-Delta pro Parent statt N einzelne UPDATEs wo möglich |
|
||||||
|
| RAM | Streaming Enum, kein `List<File>` der ganzen Platte; DB mmap statt alles in CLR |
|
||||||
|
| Hash I/O | Throttle, ein Disk-Head, pause on user copy |
|
||||||
|
| Icon extraction | Async, Default-Icon zuerst, Cache |
|
||||||
|
| TreeView alle Drives expand | Lazy, nur sichtbare Children aus FS oder Index |
|
||||||
|
|
||||||
|
Ziel-SLO (Richtwerte, kein Contract):
|
||||||
|
|
||||||
|
- Ordner mit 10k Dateien öffnen (online, lokal): < 100 ms bis erste Zeilen.
|
||||||
|
- Namenssuche 5M Rows, indexed: < 200 ms für erste Seite.
|
||||||
|
- UI während Scan: Eingabe ohne Ruckeln; Scan-Progress 10 Hz.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. MVP-Abgrenzung
|
||||||
|
|
||||||
|
### MVP (fühlbarer Explorer + Gedächtnis)
|
||||||
|
|
||||||
|
- WPF Shell, Dark/Light
|
||||||
|
- Nav-Tree: lokale Volumes + manuell UNC
|
||||||
|
- Ein ExplorerPane: Breadcrumb, Back/Forward/Up, Details+List
|
||||||
|
- Tabs
|
||||||
|
- Split View + DnD zwischen Panes
|
||||||
|
- Live-FS Listing wenn online
|
||||||
|
- Basis-Ops: Open, Copy, Move, Rename, Delete→Recycle, New Folder, Clipboard, Pfad kopieren
|
||||||
|
- Transfer-Queue mit Progress + Cancel
|
||||||
|
- SQLite-Index, manuelle Source-Auswahl, Full Scan async
|
||||||
|
- Excludes (Pfad, Glob, Ext, Hidden/System optional)
|
||||||
|
- Reparse: nicht folgen (siehe unten)
|
||||||
|
- Ordnergröße aus Index (Spalte)
|
||||||
|
- Suche: Name/Glob/Größe/Datum, Scope aktueller Tree oder alle Sources inkl. Offline
|
||||||
|
- Offline-Volume im Tree
|
||||||
|
- Source-Status: Online / Scanning / Stale / Offline / Error
|
||||||
|
- Manuell: Refresh, Rescan Folder, Full Rescan
|
||||||
|
|
||||||
|
### Nicht im MVP
|
||||||
|
|
||||||
|
- USN (sofort danach Phase 2)
|
||||||
|
- Windows-Dienst
|
||||||
|
- NFS-Feinschliff (Typ vorsehen, nur wenn Client da)
|
||||||
|
- Query-Syntax
|
||||||
|
- Duplikat-UI (Schema ja)
|
||||||
|
- Historien-Charts (Schema ja)
|
||||||
|
- Pause/Resume Transfers, Konflikt-UI, Undo
|
||||||
|
- Icon-View, Treemap, Preview-Pane
|
||||||
|
- Weitere Cloud-Provider (Google Drive, Dropbox) und PluginHost/IPC
|
||||||
|
- Explorer.exe ersetzen
|
||||||
|
- Volltext in Dateiinhalten
|
||||||
|
|
||||||
|
### Reparse-Policy (V1 festlegen)
|
||||||
|
|
||||||
|
| Typ | Default |
|
||||||
|
|---|---|
|
||||||
|
| Directory Junction / Directory Symlink | **Nicht folgen**; als Link-Eintrag indexieren, Overlay-Icon |
|
||||||
|
| Volume Mount Point | Als **eigene Source** behandeln, wenn Volume-GUID bekannt; sonst nicht in den Baum des Parents mergen |
|
||||||
|
| File Symlink | Als Datei indexieren, Größe des Links; Hash nicht dem Target folgen |
|
||||||
|
| Andere Reparse (OneDrive placeholder, Dedup) | Als Datei/Ordner mit Tag; Enumeration normal, wenn Win32 sie als Datei zeigt |
|
||||||
|
|
||||||
|
Schleifen: besuchte `(volume_guid, FRN)`-Menge während eines Scans. Zweites Betreten → skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Entwicklungsphasen
|
||||||
|
|
||||||
|
### Phase 0 — Gerüst (ca. 1 Woche)
|
||||||
|
|
||||||
|
Solution, DI, Logging, leeres Fenster, Themes, SQLite-Ping, Volume-Enumeration ohne Index.
|
||||||
|
|
||||||
|
### Phase 1 — Live-Explorer (2–3 Wochen)
|
||||||
|
|
||||||
|
Pane, Tree, Breadcrumb, History, Details-View, Virtualization, Basis-Ops ohne Queue, Recycle, Long Paths. **Fühlt sich schon wie Explorer an.**
|
||||||
|
|
||||||
|
### Phase 2 — Tabs, Split, Queue (2 Wochen)
|
||||||
|
|
||||||
|
Tabs, Dual Pane, DnD, Transfer-Queue, Progress-Panel.
|
||||||
|
|
||||||
|
### Phase 3 — Index-MVP (3 Wochen)
|
||||||
|
|
||||||
|
Schema, Source-Identität, Full Scan, Excludes, Aggregates, Offline, Ordnergrößen-Spalte, Suche über Index, Refresh/Rescan.
|
||||||
|
|
||||||
|
### Phase 4 — Change Tracking (2 Wochen)
|
||||||
|
|
||||||
|
USN wo möglich, Watcher+Folder-Diff Fallback, Stale-Logik, Resume-Scan.
|
||||||
|
|
||||||
|
### Phase 5 — Analyse (1–2 Wochen)
|
||||||
|
|
||||||
|
Analyse-View, Balken, Drilldown, Typ-Aggregation.
|
||||||
|
|
||||||
|
### Phase 6 — Duplikate + Historie (2 Wochen)
|
||||||
|
|
||||||
|
Hash-Pipeline, Duplikat-UI, Rollups, Tombstone-Retention-Settings.
|
||||||
|
|
||||||
|
### Phase 7 — Härten
|
||||||
|
|
||||||
|
SMB-Stabilität, Elevation-optional, Query-Syntax, Dienst-Schnittstelle (`Explorer.Contracts` IPC), Installer.
|
||||||
|
|
||||||
|
Phasen 1–3 sind das kleinste lieferbare Produkt, das die Vision trägt. USN ist bewusst Phase 4: ohne soliden Scan und Identity ist das Journal wertlos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Entscheidungen vor Implementierungsbeginn
|
||||||
|
|
||||||
|
Bitte diese Punkte klären. Empfohlene Defaults in Klammern:
|
||||||
|
|
||||||
|
1. **Produktname und AppId** (Arbeitsname Explorer ist ungeeignet für Store/Mutex/`LocalAppData`).
|
||||||
|
2. **Hybrid Live-FS + Index** vs. Index-first Listing (**Hybrid**).
|
||||||
|
3. **Elevation:** nie Admin / optional USN-Dienst / App immer elevatet (**USN opportunistisch, kein Pflicht-Admin**).
|
||||||
|
4. **Split-Default:** neues Tab single oder immer dual (**single, Split per Tab**).
|
||||||
|
5. **Tombstones default:** sofort löschen / 30 Tage / dauerhaft (**30 Tage**).
|
||||||
|
6. **Erstes Laufwerk:** Banner „Index aufbauen“ vs. stiller Scan (**Banner, User startet Scan**).
|
||||||
|
7. **Sprache UI:** de / en / beide (**de + en, System-UI**).
|
||||||
|
8. **Distribution:** portable ZIP / MSIX / Setup (**MSIX oder Setup, DB unter LocalAppData**).
|
||||||
|
9. **Mindest-OS:** Windows 10 22H2 vs. 11 only (**Windows 10 1809+ / 11**).
|
||||||
|
10. **WPF-Fluent-Kit:** WPF-UI (lepoco) vs. eigene ResourceDictionaries (**WPF-UI für Tempo, austauschbar hinter Themes**).
|
||||||
|
11. **Copy-Engine:** nur IFileOperation vs. CopyFileEx-Queue (**Mischung, siehe §11**).
|
||||||
|
12. **Netzwerk-Shares im Tree:** nur manuell vs. zuletzt verwendete vs. Windows-Netzwerkumgebung (**manuell + Recents**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hintergrunddienst: A vs. B
|
||||||
|
|
||||||
|
| | A In-Process (V1) | B Windows Service (später) |
|
||||||
|
|---|---|---|
|
||||||
|
| Komplexität | niedrig | Session 0, ACL, IPC, Updates |
|
||||||
|
| Index wenn UI zu | stoppt | läuft weiter |
|
||||||
|
| USN-Rechte | oft unzureichend | SYSTEM kann Journal lesen |
|
||||||
|
| Crash-Isolation | UI-Crash stoppt Index | getrennt |
|
||||||
|
| Empfohlen | **V1 = A** | **V2 = B**, wenn Identity+Schema stabil sind |
|
||||||
|
|
||||||
|
V1 so schneiden, dass der Indexer ein `IHostedService` ist. Im Dienst-Host später derselbe Service, UI spricht über Named Pipe / gRPC (`Explorer.Contracts`). Nicht in V1 bauen, Interfaces nicht an WPF kleben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sicherheit und Robustheit (kurz)
|
||||||
|
|
||||||
|
- Keine Indexierung von Inhalten geschützter Ordner ohne User-Exclude-Defaults: `C:\Windows`, `C:\System Volume Information`, `$Recycle.Bin`, `C:\ProgramData\Microsoft`, Standard-Temp.
|
||||||
|
- Scans laufen in User-Rechten; nicht heimlich SeBackupPrivilege fordern.
|
||||||
|
- DB liegt im User-Profil, nicht world-writable.
|
||||||
|
- UNC-Pfade nicht als ausführbare Kommandos interpretieren.
|
||||||
|
- Hash-Worker lesen nur; keine Writes ins FS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UX-Sprache (verbindlich)
|
||||||
|
|
||||||
|
| Intern | UI |
|
||||||
|
|---|---|
|
||||||
|
| Source | Laufwerk, Medium, Speicherort |
|
||||||
|
| Entry | Datei, Ordner |
|
||||||
|
| Full Scan | Index aufbauen / Vollständig einlesen |
|
||||||
|
| Stale | Möglicherweise nicht aktuell |
|
||||||
|
| Tombstone | Früher vorhanden / Nicht mehr vorhanden |
|
||||||
|
| FTS / SQLite | — (nie zeigen) |
|
||||||
|
| USN | — (nie zeigen; höchstens „Änderungsprotokoll des Laufwerks“) |
|
||||||
|
|
||||||
|
Statuszeile darf „1,2 Mio. Dateien indexiert · aktuell“ sagen. Das ist Explorer-Gedächtnis, kein Datenbankprodukt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation notes (this codebase)
|
||||||
|
|
||||||
|
Deviations from the design above, with reasons:
|
||||||
|
|
||||||
|
1. **`entries.scan_generation`** — added so a full/folder scan can mark unseen rows as deleted without keeping the whole tree in RAM. UPSERT stamps the generation; missing rows with an older generation become tombstones.
|
||||||
|
2. **CsWin32** — not used. Win32 is called via `LibraryImport`/`DllImport` in `Explorer.Windows`. CsWin32 generated code was heavier than needed for the small API surface (volumes, USN, `CopyFileEx`, Recycle Bin).
|
||||||
|
3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable.
|
||||||
|
4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain.
|
||||||
|
5. **App data folder** — `%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer.
|
||||||
|
6. **Background work** — in-process `IHostedService` instances (indexer, transfer queue, hash worker, history rollup, watchers). No Windows Service in this run.
|
||||||
|
7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7).
|
||||||
|
8. **UNIQUE identity** — `UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct.
|
||||||
|
9. **INSERT ids** — `Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`.
|
||||||
|
10. **SQLite cache** — `SqliteCacheMode.Shared` deadlocked reader+writer connections in-process. Connections use the default private cache.
|
||||||
|
11. **Schema apply** — Microsoft.Data.Sqlite/`Execute` splitting on `;` broke `CREATE TRIGGER` bodies. Schema is applied as an explicit statement array (`SchemaScript.Statements`).
|
||||||
|
12. **Search CurrentFolder** — `SearchRequest.DirectChildrenOnly` restricts to the folder’s children in SQL (not a client-side filter after paging).
|
||||||
|
13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read.
|
||||||
|
|
||||||
BIN
explorer-workbench-icons/explorer-workbench-128.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
explorer-workbench-icons/explorer-workbench-16.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
explorer-workbench-icons/explorer-workbench-20.png
Normal file
|
After Width: | Height: | Size: 987 B |
BIN
explorer-workbench-icons/explorer-workbench-24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
explorer-workbench-icons/explorer-workbench-256.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
explorer-workbench-icons/explorer-workbench-32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
explorer-workbench-icons/explorer-workbench-48.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
explorer-workbench-icons/explorer-workbench-512.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
explorer-workbench-icons/explorer-workbench-64.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
explorer-workbench-icons/explorer-workbench-96.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
explorer-workbench-icons/explorer-workbench.ico
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
explorer-workbench-icons/explorer-workbench.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.301",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/Explorer.Analysis/AnalysisResultCache.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
namespace Explorer.Analysis;
|
||||||
|
|
||||||
|
internal sealed class AnalysisResultCache
|
||||||
|
{
|
||||||
|
private const int MaxEntries = 32;
|
||||||
|
private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(20);
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly Dictionary<string, Entry> _items = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public bool TryGet<T>(string key, long stamp, out T value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_items.TryGetValue(key, out var entry)
|
||||||
|
&& entry.Stamp == stamp
|
||||||
|
&& entry.Utc + Ttl > DateTime.UtcNow
|
||||||
|
&& entry.Value is T typed)
|
||||||
|
{
|
||||||
|
value = typed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value = default!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Set<T>(string key, long stamp, T value)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_items[key] = new Entry(stamp, value!, DateTime.UtcNow);
|
||||||
|
if (_items.Count <= MaxEntries)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldest = _items.OrderBy(p => p.Value.Utc).First().Key;
|
||||||
|
_items.Remove(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct Entry(long Stamp, object Value, DateTime Utc);
|
||||||
|
}
|
||||||
117
src/Explorer.Analysis/AnalysisService.cs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Analysis;
|
||||||
|
|
||||||
|
public sealed class AnalysisService
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly AnalysisResultCache _cache = new();
|
||||||
|
private readonly SemaphoreSlim _ready = new(1, 1);
|
||||||
|
private bool _readyDone;
|
||||||
|
|
||||||
|
public AnalysisService(IIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task EnsureReadyAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> RunOffUiAsync(async ct =>
|
||||||
|
{
|
||||||
|
if (_readyDone)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _ready.WaitAsync(ct).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_readyDone)
|
||||||
|
{
|
||||||
|
await _store.Analysis.EnsureReadyAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
_readyDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_ready.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> RunOffUiAsync(ct => _store.Analysis.GetIndexStampAsync(ct), cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync("roots", ct => _store.Analysis.GetDirectoryRootsAsync(ct), cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(
|
||||||
|
long? sourceId,
|
||||||
|
long? parentId,
|
||||||
|
int take = AppConstants.AnalysisTopN,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync(
|
||||||
|
$"dirs:{sourceId}:{parentId}:{take}",
|
||||||
|
ct => _store.Analysis.LargestDirectoriesAsync(sourceId, parentId, take, ct),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(
|
||||||
|
long? sourceId,
|
||||||
|
string? pathRelPrefix,
|
||||||
|
int take = AppConstants.AnalysisTopN,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync(
|
||||||
|
$"files:{sourceId}:{pathRelPrefix}:{take}",
|
||||||
|
ct => _store.Analysis.LargestFilesAsync(sourceId, pathRelPrefix, take, ct),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(
|
||||||
|
long? sourceId,
|
||||||
|
string? pathRelPrefix,
|
||||||
|
int take = AppConstants.AnalysisTopN,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync(
|
||||||
|
$"types:{sourceId}:{pathRelPrefix}:{take}",
|
||||||
|
ct => _store.Analysis.UsageByExtensionAsync(sourceId, pathRelPrefix, take, ct),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync("sources", ct => _store.Analysis.UsageBySourceAsync(ct), cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IndexEntry>> DrilldownAsync(
|
||||||
|
long parentId,
|
||||||
|
int take = AppConstants.AnalysisTopN,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> CachedAsync(
|
||||||
|
$"children:{parentId}:{take}",
|
||||||
|
ct => _store.Analysis.ChildrenBySizeAsync(parentId, take, ct),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Source>> GetKnownSourcesAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken);
|
||||||
|
|
||||||
|
private async Task<T> CachedAsync<T>(string key, Func<CancellationToken, Task<T>> query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await EnsureReadyAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var stamp = await GetIndexStampAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (_cache.TryGet<T>(key, stamp, out var hit))
|
||||||
|
{
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = await RunOffUiAsync(query, cancellationToken).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
_cache.Set(key, stamp, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<T> RunOffUiAsync<T>(Func<CancellationToken, Task<T>> work, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (SynchronizationContext.Current is null)
|
||||||
|
{
|
||||||
|
return await work(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Task.Run(async () => await work(cancellationToken).ConfigureAwait(false), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/Explorer.Analysis/DuplicateAndHistory.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Analysis;
|
||||||
|
|
||||||
|
public sealed class DuplicateHashWorker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly IHydrationGuard _hydration;
|
||||||
|
private readonly ILogger<DuplicateHashWorker> _logger;
|
||||||
|
private volatile bool _paused;
|
||||||
|
|
||||||
|
public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger<DuplicateHashWorker> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_hydration = hydration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Pause() => _paused = true;
|
||||||
|
public void Resume() => _paused = false;
|
||||||
|
|
||||||
|
public async Task ProcessPendingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var batch = await _store.Hashes.DequeueAsync(8, cancellationToken).ConfigureAwait(false);
|
||||||
|
foreach (var item in batch)
|
||||||
|
{
|
||||||
|
if (_paused || cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.RootPath is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = PathRules.Combine(item.RootPath, item.PathRel);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_hydration.WouldHydrateOnRead(item.Attributes, item.CloudAvailability)
|
||||||
|
|| await _hydration.WouldHydrateOnReadAsync(path, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await _store.Hashes.MarkSkippedAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.State == "Pending")
|
||||||
|
{
|
||||||
|
var hash = await HashAsync(path, AppConstants.PartialHashBytes, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (hash is not null)
|
||||||
|
{
|
||||||
|
await _store.Hashes.CompletePartialAsync(item.EntryId, hash, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (item.State == "PartialDone")
|
||||||
|
{
|
||||||
|
if (!await _store.Hashes.HasPartialCollisionAsync(item.EntryId, item.SizeBytes, cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await _store.Hashes.MarkUniquePartialAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = await HashAsync(path, null, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (hash is not null)
|
||||||
|
{
|
||||||
|
await _store.Hashes.CompleteFullAsync(item.EntryId, hash, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Hash failed for {Path}", path);
|
||||||
|
await _store.Hashes.MarkErrorAsync(item.EntryId, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (_paused)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessPendingAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Hash worker loop error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]?> HashAsync(string path, int? limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ext = PathRules.ToExtended(path);
|
||||||
|
await using var stream = new FileStream(ext, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
|
if (limit is int n)
|
||||||
|
{
|
||||||
|
var buffer = new byte[n];
|
||||||
|
var read = await stream.ReadAsync(buffer.AsMemory(0, n), cancellationToken).ConfigureAwait(false);
|
||||||
|
return SHA256.HashData(buffer.AsSpan(0, read));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HistoryRollupService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private DateTime _last = DateTime.MinValue;
|
||||||
|
|
||||||
|
public HistoryRollupService(IIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
|
||||||
|
await CaptureAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await CaptureAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CaptureAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if ((DateTime.UtcNow - _last).TotalHours < 20)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var utc = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var source in sources.Where(s => s.IsIndexed))
|
||||||
|
{
|
||||||
|
await _store.History.CaptureSourceSnapshotAsync(source.Id, utc, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _store.History.CaptureDirectorySnapshotsAsync(
|
||||||
|
source.Id, utc, AppConstants.DirectoryHistoryThresholdBytes, AppConstants.DirectoryHistoryTopN, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var days = AppConstants.DefaultTombstoneRetentionDays;
|
||||||
|
await _store.Entries.DeleteExpiredTombstonesAsync(utc.AddDays(-days), cancellationToken).ConfigureAwait(false);
|
||||||
|
_last = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Explorer.Analysis/Explorer.Analysis.csproj
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Analysis</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
850
src/Explorer.App/App.xaml
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
<Application x:Class="Explorer.App.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="clr-namespace:Explorer.App"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ResourceDictionary Source="Themes/Dark.xaml"/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
<FontFamily x:Key="Symbol">Segoe MDL2 Assets</FontFamily>
|
||||||
|
<BooleanToVisibilityConverter x:Key="BoolVis"/>
|
||||||
|
<local:ActiveThicknessConverter x:Key="ActiveThickness"/>
|
||||||
|
<local:FractionWidthConverter x:Key="FractionWidth"/>
|
||||||
|
<local:IndentConverter x:Key="Indent"/>
|
||||||
|
<local:ViewModeToVisibilityConverter x:Key="ViewDetails" Match="Details"/>
|
||||||
|
<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"/>
|
||||||
|
<DataTemplate x:Key="NameWithIcon">
|
||||||
|
<StackPanel Orientation="Horizontal" ToolTip="{Binding SizeTooltip}">
|
||||||
|
<Image Width="16" Height="16" Margin="0,0,8,0" RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
|
Source="{Binding Converter={StaticResource ShellIcon}}"/>
|
||||||
|
<TextBlock Text="{Binding Name}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||||
|
<TextBlock Text="{Binding CloudStatus}" Margin="8,0,0,0" VerticalAlignment="Center" FontSize="11"
|
||||||
|
Foreground="{DynamicResource FgMuted}"
|
||||||
|
Visibility="{Binding HasCloudStatus, 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"
|
||||||
|
Source="{Binding Converter={StaticResource ShellIcon}, ConverterParameter=large}">
|
||||||
|
<Image.Style>
|
||||||
|
<Style TargetType="Image">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsImage}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Image.Style>
|
||||||
|
</Image>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding Name}" TextAlignment="Center" TextWrapping="Wrap" TextTrimming="CharacterEllipsis"
|
||||||
|
MaxHeight="36" Margin="0,4,0,0" Foreground="{DynamicResource Fg}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
|
||||||
|
<Style x:Key="CrumbButton" TargetType="Button">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="8,3"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="3"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<DataTemplate x:Key="PaneAddressBar">
|
||||||
|
<DockPanel LastChildFill="True" Margin="6,6,6,4">
|
||||||
|
<Button DockPanel.Dock="Left" Content="↑" Width="32" Height="28" Margin="0,0,6,0"
|
||||||
|
Style="{StaticResource CrumbButton}"
|
||||||
|
Command="{Binding UpCommand}"
|
||||||
|
IsEnabled="{Binding CanGoUp}"
|
||||||
|
ToolTip="Up one folder"/>
|
||||||
|
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
|
||||||
|
BorderThickness="1" CornerRadius="4" Padding="4,0" MinHeight="28">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||||
|
Focusable="False">
|
||||||
|
<ItemsControl ItemsSource="{Binding Breadcrumb}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Style="{StaticResource CrumbButton}"
|
||||||
|
Command="{Binding DataContext.GoBreadcrumbCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip="{Binding Path}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock FontFamily="{StaticResource Symbol}" Text="" FontSize="14"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Label}" Value="This PC">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Text="›" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsLast}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
<Style x:Key="PaneChrome" TargetType="Border">
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}}" Value="True">
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
</DataTrigger>
|
||||||
|
<MultiDataTrigger>
|
||||||
|
<MultiDataTrigger.Conditions>
|
||||||
|
<Condition Binding="{Binding IsActive}" Value="True"/>
|
||||||
|
<Condition Binding="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}}" Value="True"/>
|
||||||
|
</MultiDataTrigger.Conditions>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="3,1,1,1"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ActivePaneFill}"/>
|
||||||
|
</MultiDataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="ExpandToggle" TargetType="ToggleButton">
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Width" Value="16"/>
|
||||||
|
<Setter Property="Height" Value="16"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="Transparent" Padding="2">
|
||||||
|
<Path x:Name="Arrow"
|
||||||
|
Data="M 3 1 L 9 7 L 3 13 Z"
|
||||||
|
Fill="{DynamicResource FgMuted}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsChecked" Value="True">
|
||||||
|
<Setter TargetName="Arrow" Property="Data" Value="M 1 3 L 13 3 L 7 11 Z"/>
|
||||||
|
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Arrow" Property="Fill" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Fill}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="Padding" Value="10,6"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="MinHeight" Value="32"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="MinHeight" Value="32"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
<Setter Property="ItemContainerStyle">
|
||||||
|
<Setter.Value>
|
||||||
|
<Style TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Padding" Value="8,4"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<ToggleButton x:Name="Toggle" ClickMode="Press" Focusable="False"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" CornerRadius="4">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="22"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Path Grid.Column="1" Data="M 0 0 L 8 0 L 4 5 Z" Fill="{DynamicResource Fg}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
</ToggleButton>
|
||||||
|
<ContentPresenter Margin="8,0,28,0" VerticalAlignment="Center" HorizontalAlignment="Left"
|
||||||
|
Content="{TemplateBinding SelectionBoxItem}"
|
||||||
|
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
|
||||||
|
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
|
||||||
|
IsHitTestVisible="False"/>
|
||||||
|
<Popup x:Name="PART_Popup" IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom" AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
|
||||||
|
<Border MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||||
|
Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1">
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="PathComboBox" TargetType="ComboBox" BasedOn="{StaticResource {x:Type ComboBox}}">
|
||||||
|
<Setter Property="IsEditable" Value="True"/>
|
||||||
|
<Setter Property="IsTextSearchEnabled" Value="False"/>
|
||||||
|
<Setter Property="StaysOpenOnEdit" Value="True"/>
|
||||||
|
<Setter Property="MaxDropDownHeight" Value="280"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ComboBox">
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{DynamicResource InputBg}" BorderBrush="{DynamicResource Stroke}"
|
||||||
|
BorderThickness="1" CornerRadius="4"/>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="26"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="PART_EditableTextBox"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="0"
|
||||||
|
Foreground="{DynamicResource Fg}"
|
||||||
|
CaretBrush="{DynamicResource Fg}"
|
||||||
|
Padding="8,6"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
FontSize="13"/>
|
||||||
|
<ToggleButton Grid.Column="1" Focusable="False" ClickMode="Press"
|
||||||
|
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="Transparent">
|
||||||
|
<Path Data="M 0 0 L 8 0 L 4 5 Z" Fill="{DynamicResource Fg}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
</ToggleButton>
|
||||||
|
</Grid>
|
||||||
|
<Popup x:Name="PART_Popup" IsOpen="{TemplateBinding IsDropDownOpen}" Placement="Bottom"
|
||||||
|
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
|
||||||
|
<Border MinWidth="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
MaxHeight="{TemplateBinding MaxDropDownHeight}"
|
||||||
|
Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1">
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter KeyboardNavigation.DirectionalNavigation="Contained"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
</Popup>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="CheckBox">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ContextMenu">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="1"/>
|
||||||
|
<Setter Property="Padding" Value="4"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ContextMenu">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="Separator">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="Margin" Value="8,4"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Separator">
|
||||||
|
<Border Height="1" Background="{TemplateBinding Background}" Margin="{TemplateBinding Margin}"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="MenuItem">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" 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>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ProgressBar">
|
||||||
|
<Setter Property="Height" Value="4"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Fill}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="StorageSizeBar" TargetType="ProgressBar" BasedOn="{StaticResource {x:Type ProgressBar}}">
|
||||||
|
<Setter Property="Height" Value="12"/>
|
||||||
|
<Setter Property="Minimum" Value="0"/>
|
||||||
|
<Setter Property="Maximum" Value="1"/>
|
||||||
|
<Setter Property="Margin" Value="8,0"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CaptionButton" TargetType="Button">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Width" Value="46"/>
|
||||||
|
<Setter Property="Height" Value="40"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="shell:WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="CaptionCloseButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Danger}"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="PlainListItem" TargetType="ListViewItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Padding" Value="6,6"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListViewItem">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ListBox">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ListView">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
|
||||||
|
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ListViewItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Padding" Value="8,4"/>
|
||||||
|
<Setter Property="Margin" Value="0"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListViewItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<Grid>
|
||||||
|
<GridViewRowPresenter x:Name="GridRow"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<ContentPresenter x:Name="Content"
|
||||||
|
Content="{TemplateBinding Content}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
ContentStringFormat="{TemplateBinding ContentStringFormat}"
|
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Visibility="Collapsed"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="GridView.ColumnCollection" Value="{x:Null}">
|
||||||
|
<Setter TargetName="Content" Property="Visibility" Value="Visible"/>
|
||||||
|
<Setter TargetName="GridRow" Property="Visibility" Value="Collapsed"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ListSelection}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="IconListItem" TargetType="ListViewItem" BasedOn="{StaticResource {x:Type ListViewItem}}">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Top"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="Margin" Value="2"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="ColumnHeaderGripper" TargetType="Thumb">
|
||||||
|
<Setter Property="Width" Value="8"/>
|
||||||
|
<Setter Property="Cursor" Value="SizeWE"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Border Background="Transparent">
|
||||||
|
<Border Width="1" HorizontalAlignment="Center" Background="{DynamicResource Stroke}"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="GridViewColumnHeader">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0,0,1,1"/>
|
||||||
|
<Setter Property="Padding" Value="8,6"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Left"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="GridViewColumnHeader">
|
||||||
|
<Grid>
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<Thumb x:Name="PART_HeaderGripper" Style="{StaticResource ColumnHeaderGripper}"
|
||||||
|
HorizontalAlignment="Right"/>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="Role" Value="Padding">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="GridViewColumnHeader">
|
||||||
|
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}"
|
||||||
|
BorderThickness="0,0,0,1"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TreeView">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource Panel}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="4,0,4,8"/>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TreeViewItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Padding" Value="4,3"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="SnapsToDevicePixels" Value="True"/>
|
||||||
|
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}"/>
|
||||||
|
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TreeViewItem">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="16"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ToggleButton x:Name="Expander"
|
||||||
|
Style="{StaticResource ExpandToggle}"
|
||||||
|
IsChecked="{Binding IsExpanded, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
ClickMode="Press"/>
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Grid.Column="1"
|
||||||
|
Background="{TemplateBinding Background}"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
CornerRadius="3"
|
||||||
|
SnapsToDevicePixels="True">
|
||||||
|
<ContentPresenter x:Name="PART_Header"
|
||||||
|
ContentSource="Header"
|
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
|
||||||
|
</Border>
|
||||||
|
<ItemsPresenter x:Name="ItemsHost" Grid.Row="1" Grid.Column="1"/>
|
||||||
|
</Grid>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsExpanded" Value="False">
|
||||||
|
<Setter TargetName="ItemsHost" Property="Visibility" Value="Collapsed"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="HasItems" Value="False">
|
||||||
|
<Setter TargetName="Expander" Property="Visibility" Value="Hidden"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<MultiTrigger>
|
||||||
|
<MultiTrigger.Conditions>
|
||||||
|
<Condition Property="IsSelected" Value="True"/>
|
||||||
|
<Condition Property="IsSelectionActive" Value="False"/>
|
||||||
|
</MultiTrigger.Conditions>
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</MultiTrigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<MultiTrigger>
|
||||||
|
<MultiTrigger.Conditions>
|
||||||
|
<Condition Property="IsSelected" Value="True"/>
|
||||||
|
<Condition Property="IsMouseOver" Value="True"/>
|
||||||
|
</MultiTrigger.Conditions>
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource TreeSelection}"/>
|
||||||
|
</MultiTrigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsPlaceholder}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TabControl">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Padding" Value="0"/>
|
||||||
|
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TabControl">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TabPanel Grid.Row="0" IsItemsHost="True" Margin="4,4,4,0"/>
|
||||||
|
<Border Grid.Row="1" Background="{DynamicResource Bg}">
|
||||||
|
<ContentPresenter x:Name="PART_SelectedContentHost"
|
||||||
|
ContentSource="SelectedContent"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TabItem">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource FgMuted}"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Padding" Value="12,8"/>
|
||||||
|
<Setter Property="MinWidth" Value="140"/>
|
||||||
|
<Setter Property="MinHeight" Value="32"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="TabItem">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Padding="{TemplateBinding Padding}"
|
||||||
|
BorderBrush="Transparent"
|
||||||
|
BorderThickness="0,0,0,2"
|
||||||
|
Background="Transparent"
|
||||||
|
Margin="0,0,4,0">
|
||||||
|
<ContentPresenter ContentSource="Header" HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource FillHover}"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Fg}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="ScrollBarThumb" TargetType="Thumb">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{DynamicResource ScrollThumb}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Margin="3,2"/>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbPressed}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="ScrollBarThumbHorizontal" TargetType="Thumb" BasedOn="{StaticResource ScrollBarThumb}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Thumb">
|
||||||
|
<Border x:Name="Bd"
|
||||||
|
Background="{DynamicResource ScrollThumb}"
|
||||||
|
CornerRadius="4"
|
||||||
|
Margin="2,3"/>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbHover}"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsDragging" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource ScrollThumbPressed}"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="ScrollBarPageButton" TargetType="RepeatButton">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Focusable" Value="False"/>
|
||||||
|
<Setter Property="IsTabStop" Value="False"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="RepeatButton">
|
||||||
|
<Border Background="Transparent"/>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
<ControlTemplate x:Key="VerticalScrollBar" TargetType="ScrollBar">
|
||||||
|
<Border Background="{DynamicResource ScrollTrack}" SnapsToDevicePixels="True">
|
||||||
|
<Track x:Name="PART_Track" IsDirectionReversed="True">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageUpCommand" Style="{StaticResource ScrollBarPageButton}"/>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb Style="{StaticResource ScrollBarThumb}"/>
|
||||||
|
</Track.Thumb>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageDownCommand" Style="{StaticResource ScrollBarPageButton}"/>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
</Track>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
<ControlTemplate x:Key="HorizontalScrollBar" TargetType="ScrollBar">
|
||||||
|
<Border Background="{DynamicResource ScrollTrack}" SnapsToDevicePixels="True">
|
||||||
|
<Track x:Name="PART_Track" IsDirectionReversed="False">
|
||||||
|
<Track.DecreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageLeftCommand" Style="{StaticResource ScrollBarPageButton}"/>
|
||||||
|
</Track.DecreaseRepeatButton>
|
||||||
|
<Track.Thumb>
|
||||||
|
<Thumb Style="{StaticResource ScrollBarThumbHorizontal}"/>
|
||||||
|
</Track.Thumb>
|
||||||
|
<Track.IncreaseRepeatButton>
|
||||||
|
<RepeatButton Command="ScrollBar.PageRightCommand" Style="{StaticResource ScrollBarPageButton}"/>
|
||||||
|
</Track.IncreaseRepeatButton>
|
||||||
|
</Track>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
<Style TargetType="ScrollBar">
|
||||||
|
<Setter Property="OverridesDefaultStyle" Value="True"/>
|
||||||
|
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="False"/>
|
||||||
|
<Setter Property="Width" Value="12"/>
|
||||||
|
<Setter Property="MinWidth" Value="12"/>
|
||||||
|
<Setter Property="Template" Value="{StaticResource VerticalScrollBar}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="Orientation" Value="Horizontal">
|
||||||
|
<Setter Property="Width" Value="Auto"/>
|
||||||
|
<Setter Property="MinWidth" Value="0"/>
|
||||||
|
<Setter Property="Height" Value="12"/>
|
||||||
|
<Setter Property="MinHeight" Value="12"/>
|
||||||
|
<Setter Property="Template" Value="{StaticResource HorizontalScrollBar}"/>
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ScrollViewer">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
</Style>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
56
src/Explorer.App/App.xaml.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using Explorer.Presentation.ViewModels;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public partial class App : System.Windows.Application
|
||||||
|
{
|
||||||
|
private IHost? _host;
|
||||||
|
|
||||||
|
protected override async void OnStartup(StartupEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnStartup(e);
|
||||||
|
DispatcherUnhandledException += (_, args) =>
|
||||||
|
{
|
||||||
|
Log.Error(args.Exception, "Unhandled UI exception");
|
||||||
|
args.Handled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
var env = new Windows.WindowsAppEnvironment();
|
||||||
|
Log.Logger = new LoggerConfiguration()
|
||||||
|
.MinimumLevel.Information()
|
||||||
|
.WriteTo.File(
|
||||||
|
Path.Combine(env.LogDirectory, "explorer-.log"),
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 14)
|
||||||
|
.CreateLogger();
|
||||||
|
|
||||||
|
_host = Host.CreateDefaultBuilder()
|
||||||
|
.UseSerilog()
|
||||||
|
.ConfigureServices((_, services) => services.AddExplorer())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var vm = _host.Services.GetRequiredService<MainViewModel>();
|
||||||
|
await vm.InitializeAsync().ConfigureAwait(true);
|
||||||
|
await _host.StartAsync().ConfigureAwait(true);
|
||||||
|
var window = _host.Services.GetRequiredService<MainWindow>();
|
||||||
|
window.DataContext = vm;
|
||||||
|
window.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
|
{
|
||||||
|
if (_host is not null)
|
||||||
|
{
|
||||||
|
await _host.StopAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(true);
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.CloseAndFlush();
|
||||||
|
base.OnExit(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Explorer.App/AppServices.cs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
using Explorer.Analysis;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
using Explorer.Indexing;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Plugin.OneDrive;
|
||||||
|
using Explorer.Presentation.ViewModels;
|
||||||
|
using Explorer.Search;
|
||||||
|
using Explorer.Storage.Sqlite;
|
||||||
|
using Explorer.Windows;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public static class AppServices
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddExplorer(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IClock, SystemClock>();
|
||||||
|
services.AddSingleton<IAppEnvironment, WindowsAppEnvironment>();
|
||||||
|
services.AddSingleton<IVolumeService, WindowsVolumeService>();
|
||||||
|
services.AddSingleton<IFileSystemEnumerator, WindowsFileSystemEnumerator>();
|
||||||
|
services.AddSingleton<IUsnJournal, WindowsUsnJournal>();
|
||||||
|
services.AddSingleton<IShellFileOperations, WindowsShellFileOperations>();
|
||||||
|
services.AddSingleton<IOsClipboard, Services.WpfClipboard>();
|
||||||
|
services.AddSingleton<IIndexStore>(sp =>
|
||||||
|
{
|
||||||
|
var env = sp.GetRequiredService<IAppEnvironment>();
|
||||||
|
var logger = sp.GetRequiredService<ILogger<SqliteIndexStore>>();
|
||||||
|
return new SqliteIndexStore(env.DatabasePath, logger);
|
||||||
|
});
|
||||||
|
services.AddSingleton<IStorageProvider, OneDriveStorageProvider>();
|
||||||
|
services.AddSingleton<StorageProviderRegistry>();
|
||||||
|
services.AddSingleton<IHydrationGuard, HydrationGuard>();
|
||||||
|
services.AddSingleton<SourceManager>();
|
||||||
|
services.AddSingleton<PathHistoryStore>();
|
||||||
|
services.AddSingleton<CloudPlaceStore>();
|
||||||
|
services.AddSingleton<BrowseService>();
|
||||||
|
services.AddSingleton<FilesystemScanner>();
|
||||||
|
services.AddSingleton<FolderReconciler>();
|
||||||
|
services.AddSingleton<UsnChangeApplier>();
|
||||||
|
services.AddSingleton<IndexingCoordinator>();
|
||||||
|
services.AddSingleton<DirectoryWatcherHub>();
|
||||||
|
services.AddSingleton<SearchService>();
|
||||||
|
services.AddSingleton<AnalysisService>();
|
||||||
|
services.AddSingleton<TransferQueue>();
|
||||||
|
services.AddSingleton<FileOperationService>();
|
||||||
|
services.AddSingleton<DuplicateHashWorker>();
|
||||||
|
services.AddSingleton<HistoryRollupService>();
|
||||||
|
services.AddSingleton<MainViewModel>();
|
||||||
|
services.AddSingleton<MainWindow>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<IndexingCoordinator>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<TransferQueue>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<DuplicateHashWorker>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<HistoryRollupService>());
|
||||||
|
services.AddHostedService<WatcherHostedService>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WatcherHostedService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly DirectoryWatcherHub _hub;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
|
||||||
|
public WatcherHostedService(DirectoryWatcherHub hub, SourceManager sources)
|
||||||
|
{
|
||||||
|
_hub = hub;
|
||||||
|
_sources = sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||||
|
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await _sources.RefreshOnlineStateAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
await _hub.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/Explorer.App/AssemblyInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
[assembly:ThemeInfo(
|
||||||
|
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// or application resource dictionaries)
|
||||||
|
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||||
|
//(used if a resource is not found in the page,
|
||||||
|
// app, or any theme specific resource dictionaries)
|
||||||
|
)]
|
||||||
107
src/Explorer.App/Converters.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public sealed class ActiveThicknessConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> value is true ? new Thickness(2) : new Thickness(0);
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FractionWidthConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var fraction = value is double d ? d : 0;
|
||||||
|
return Math.Max(2, fraction * 360);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IndentConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var depth = value is double d ? d : value is int i ? i : 0;
|
||||||
|
return new Thickness(depth, 0, 8, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ViewModeToVisibilityConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public FolderViewMode Match { get; set; }
|
||||||
|
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> value is FolderViewMode mode && mode == Match ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> 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)
|
||||||
|
=> value is TransferStatus.Failed ? "Dismiss" : "Cancel";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Explorer.App/Explorer.App.csproj
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>..\..\explorer-workbench-icons\explorer-workbench.ico</ApplicationIcon>
|
||||||
|
<RootNamespace>Explorer.App</RootNamespace>
|
||||||
|
<AssemblyName>Explorer.App</AssemblyName>
|
||||||
|
<ApplicationTitle>Explorer Workbench</ApplicationTitle>
|
||||||
|
</PropertyGroup>
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Plugin.OneDrive\Explorer.Plugin.OneDrive.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Presentation\Explorer.Presentation.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
65
src/Explorer.App/ListViewLayout.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes the first GridView column consume leftover width so Details/List fill the pane.
|
||||||
|
/// </summary>
|
||||||
|
public static class ListViewLayout
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty StretchFirstColumnProperty =
|
||||||
|
DependencyProperty.RegisterAttached(
|
||||||
|
"StretchFirstColumn",
|
||||||
|
typeof(bool),
|
||||||
|
typeof(ListViewLayout),
|
||||||
|
new PropertyMetadata(false, OnStretchChanged));
|
||||||
|
|
||||||
|
public static void SetStretchFirstColumn(ListView element, bool value) => element.SetValue(StretchFirstColumnProperty, value);
|
||||||
|
|
||||||
|
public static bool GetStretchFirstColumn(ListView element) => (bool)element.GetValue(StretchFirstColumnProperty);
|
||||||
|
|
||||||
|
private static void OnStretchChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (d is not ListView list)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.NewValue is true)
|
||||||
|
{
|
||||||
|
list.SizeChanged += OnListSizeChanged;
|
||||||
|
list.Loaded += OnListSizeChanged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
list.SizeChanged -= OnListSizeChanged;
|
||||||
|
list.Loaded -= OnListSizeChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void OnListSizeChanged(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is ListView list)
|
||||||
|
{
|
||||||
|
Stretch(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Stretch(ListView list)
|
||||||
|
{
|
||||||
|
if (list.View is not GridView view || view.Columns.Count == 0 || list.ActualWidth <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
double reserved = 8 + SystemParameters.VerticalScrollBarWidth;
|
||||||
|
for (var i = 1; i < view.Columns.Count; i++)
|
||||||
|
{
|
||||||
|
var width = view.Columns[i].Width;
|
||||||
|
reserved += double.IsNaN(width) ? 120 : width;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.Columns[0].Width = Math.Max(96, list.ActualWidth - reserved);
|
||||||
|
}
|
||||||
|
}
|
||||||
630
src/Explorer.App/MainWindow.xaml
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
<Window x:Class="Explorer.App.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:Explorer.Presentation.ViewModels;assembly=Explorer.Presentation"
|
||||||
|
xmlns:local="clr-namespace:Explorer.App"
|
||||||
|
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
|
||||||
|
Title="Explorer Workbench"
|
||||||
|
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||||
|
Height="820" Width="1280"
|
||||||
|
MinHeight="500" MinWidth="800"
|
||||||
|
Background="{DynamicResource Bg}"
|
||||||
|
Foreground="{DynamicResource Fg}"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
WindowStyle="None"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
UseLayoutRounding="True"
|
||||||
|
SnapsToDevicePixels="True"
|
||||||
|
PreviewKeyDown="OnPreviewKeyDown">
|
||||||
|
<shell:WindowChrome.WindowChrome>
|
||||||
|
<shell:WindowChrome CaptionHeight="40"
|
||||||
|
ResizeBorderThickness="6"
|
||||||
|
GlassFrameThickness="0"
|
||||||
|
CornerRadius="0"
|
||||||
|
UseAeroCaptionButtons="False"/>
|
||||||
|
</shell:WindowChrome.WindowChrome>
|
||||||
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Top" Height="40" Background="{DynamicResource Panel}"
|
||||||
|
BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1"
|
||||||
|
MouseLeftButtonDown="OnTitleBarMouseDown">
|
||||||
|
<Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0" IsHitTestVisible="False">
|
||||||
|
<Image Width="20" Height="20" Margin="0,0,8,0" VerticalAlignment="Center"
|
||||||
|
RenderOptions.BitmapScalingMode="HighQuality"
|
||||||
|
Source="pack://application:,,,/Assets/explorer-workbench-20.png"/>
|
||||||
|
<TextBlock Text="Explorer Workbench" FontWeight="SemiBold" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<Button Style="{StaticResource CaptionButton}" Content="─" Click="OnMinimize" ToolTip="Minimize"/>
|
||||||
|
<Button x:Name="MaxRestoreButton" Style="{StaticResource CaptionButton}" Content="☐" Click="OnMaxRestore" ToolTip="Maximize"/>
|
||||||
|
<Button Style="{StaticResource CaptionCloseButton}" Content="✕" Click="OnCloseWindow" ToolTip="Close"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,0,0,1" Padding="8">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="280"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Content="←" Command="{Binding BackCommand}" Width="36" Margin="0,0,4,0" ToolTip="Back (Alt+Left)"/>
|
||||||
|
<Button Content="→" Command="{Binding ForwardCommand}" Width="36" Margin="0,0,4,0" ToolTip="Forward (Alt+Right)"/>
|
||||||
|
<Button Content="↑" Command="{Binding UpCommand}" Width="36" Margin="0,0,4,0" ToolTip="Up (Alt+Up)"/>
|
||||||
|
<Button Content="↻" Command="{Binding RefreshCommand}" Width="36" Margin="0,0,12,0" ToolTip="Refresh (F5)"/>
|
||||||
|
<Button Content="New tab" Command="{Binding NewTabCommand}" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="Split" Command="{Binding SplitCommand}" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="Index" Command="{Binding BuildIndexCommand}" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="Details" Command="{Binding SetViewCommand}" CommandParameter="Details" Margin="8,0,4,0"/>
|
||||||
|
<Button Content="List" Command="{Binding SetViewCommand}" CommandParameter="List" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="Preview" Command="{Binding SetViewCommand}" CommandParameter="Preview"/>
|
||||||
|
</StackPanel>
|
||||||
|
<ComboBox Grid.Column="1" Margin="12,0" Style="{StaticResource PathComboBox}"
|
||||||
|
ItemsSource="{Binding PathHistory}"
|
||||||
|
Text="{Binding PathText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
PreviewKeyDown="OnPathKeyDown"
|
||||||
|
SelectionChanged="OnPathHistorySelected"/>
|
||||||
|
<TextBox Grid.Column="2" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
KeyDown="OnSearchKeyDown"/>
|
||||||
|
<StackPanel Grid.Column="3" Orientation="Horizontal" Margin="8,0,0,0">
|
||||||
|
<Button Content="Search" Command="{Binding SearchCommand}" Margin="0,0,4,0"/>
|
||||||
|
<Button Content="Theme" Command="{Binding ToggleThemeCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Bottom" Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="0,1,0,0" Padding="8,6">
|
||||||
|
<Grid>
|
||||||
|
<TextBlock Text="{Binding Footer}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<ItemsControl ItemsSource="{Binding Transfers.Jobs}" Margin="0,0,12,0">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,10,0">
|
||||||
|
<TextBlock VerticalAlignment="Center" FontSize="11" Foreground="{DynamicResource FgMuted}"
|
||||||
|
Text="{Binding Op, StringFormat={}{0}:}"/>
|
||||||
|
<TextBlock VerticalAlignment="Center" FontSize="11" Margin="4,0,0,0" Text="{Binding Status}"/>
|
||||||
|
<Button Margin="6,0,0,0" Padding="6,2" FontSize="11"
|
||||||
|
Content="{Binding Status, Converter={StaticResource TransferAction}}"
|
||||||
|
Command="{Binding DataContext.CancelTransferCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<Button Content="Storage" Command="{Binding Analysis.OpenCommand}" Margin="0,0,6,0"/>
|
||||||
|
<Button Content="Duplicates" Command="{Binding Duplicates.OpenCommand}" Margin="0,0,6,0"/>
|
||||||
|
<Button Content="Add network" Click="OnAddNetwork" Margin="0,0,6,0"/>
|
||||||
|
<Button Content="Add OneDrive" Click="OnAddOneDrive"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="260" MinWidth="160"/>
|
||||||
|
<ColumnDefinition Width="6"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<DockPanel Grid.Column="0" Background="{DynamicResource Panel}">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Margin="12,10,12,6" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" Text="Locations"/>
|
||||||
|
<TreeView x:Name="NavTree" ItemsSource="{Binding Tree.Roots}"
|
||||||
|
SelectedItemChanged="OnTreeSelected"
|
||||||
|
TreeViewItem.Expanded="OnTreeExpanded"
|
||||||
|
HorizontalContentAlignment="Stretch">
|
||||||
|
<TreeView.ItemTemplate>
|
||||||
|
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<TextBlock FontFamily="{StaticResource Symbol}" Text="{Binding Glyph}" Margin="0,0,8,0" Foreground="{DynamicResource Accent}"/>
|
||||||
|
<TextBlock Text="{Binding Label}" Foreground="{DynamicResource Fg}"/>
|
||||||
|
<TextBlock Text="{Binding Status}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" FontSize="11"/>
|
||||||
|
</StackPanel>
|
||||||
|
</HierarchicalDataTemplate>
|
||||||
|
</TreeView.ItemTemplate>
|
||||||
|
</TreeView>
|
||||||
|
</DockPanel>
|
||||||
|
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="{DynamicResource Stroke}"/>
|
||||||
|
<DockPanel Grid.Column="2" LastChildFill="True">
|
||||||
|
<TabControl x:Name="Tabs" ItemsSource="{Binding Tabs}" SelectedItem="{Binding ActiveTab}"
|
||||||
|
SelectionChanged="OnTabChanged"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
<TabControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal" MinWidth="120">
|
||||||
|
<TextBlock Text="⋮" Margin="0,0,8,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Title}" Margin="0,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" MaxWidth="160"/>
|
||||||
|
<Button Content="×" Padding="4,0" MinWidth="22" Click="OnCloseTab" Tag="{Binding}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</TabControl.ItemTemplate>
|
||||||
|
<TabControl.ContentTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Loaded="OnPaneSplitLoaded"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="0"/>
|
||||||
|
<ColumnDefinition Width="0" MinWidth="0"/>
|
||||||
|
<ColumnDefinition Width="0" MinWidth="0"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Grid.Column="0" DataContext="{Binding Left}" Style="{StaticResource PaneChrome}"
|
||||||
|
PreviewMouseDown="OnPaneChromeMouseDown">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
|
||||||
|
Focusable="False"/>
|
||||||
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
|
||||||
|
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Build index" Click="OnBuildIndex"/>
|
||||||
|
<TextBlock Text="{Binding IndexBannerText}" TextWrapping="Wrap" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource Fg}"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<Grid>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
<ListView.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Open" Click="OnCtxOpen"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Cut" Command="{Binding DataContext.CutCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Copy" Command="{Binding DataContext.CopyCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Paste" Command="{Binding DataContext.PasteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Delete" Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Rename" Click="OnCtxRename"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="New folder" Command="{Binding DataContext.NewFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyPathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<Separator/>
|
||||||
|
<MenuItem Header="Refresh" Command="{Binding DataContext.RefreshCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Rescan folder" Command="{Binding DataContext.RescanFolderCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<Separator Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Always keep on this device"
|
||||||
|
Command="{Binding DataContext.PinCloudCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
Visibility="{Binding DataContext.ShowCloudPin, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Free up space"
|
||||||
|
Command="{Binding DataContext.FreeUpCloudSpaceCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
Visibility="{Binding DataContext.ShowCloudDehydrate, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</ListView.ContextMenu>
|
||||||
|
</ListView>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
ItemTemplate="{StaticResource PreviewTile}"
|
||||||
|
ItemContainerStyle="{StaticResource IconListItem}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
|
||||||
|
<ListView.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListView.ItemsPanel>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<GridSplitter Grid.Column="1"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
ResizeBehavior="PreviousAndNext"
|
||||||
|
Background="{DynamicResource Stroke}"
|
||||||
|
Visibility="{Binding IsSplit, Converter={StaticResource BoolVis}}"
|
||||||
|
DragCompleted="OnSplitDragCompleted"/>
|
||||||
|
<Border Grid.Column="2"
|
||||||
|
DataContext="{Binding Right}"
|
||||||
|
Style="{StaticResource PaneChrome}"
|
||||||
|
PreviewMouseDown="OnPaneChromeMouseDown"
|
||||||
|
Visibility="{Binding DataContext.IsSplit, RelativeSource={RelativeSource AncestorType=Grid}, Converter={StaticResource BoolVis}}">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<ContentControl DockPanel.Dock="Top" Content="{Binding}" ContentTemplate="{StaticResource PaneAddressBar}"
|
||||||
|
Focusable="False"/>
|
||||||
|
<Border DockPanel.Dock="Top" Background="{DynamicResource Banner}" Padding="10,8"
|
||||||
|
Visibility="{Binding ShowIndexBanner, Converter={StaticResource BoolVis}}">
|
||||||
|
<DockPanel>
|
||||||
|
<Button DockPanel.Dock="Right" Content="Build index" Click="OnBuildIndex"/>
|
||||||
|
<TextBlock Text="{Binding IndexBannerText}" TextWrapping="Wrap" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource Fg}"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<Grid>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewDetails}}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
|
<GridViewColumn Header="Date modified" Width="148" DisplayMemberBinding="{Binding ModifiedLabel}"/>
|
||||||
|
<GridViewColumn Header="Type" Width="100" DisplayMemberBinding="{Binding TypeLabel}"/>
|
||||||
|
<GridViewColumn Header="Size" Width="110" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
local:ListViewLayout.StretchFirstColumn="True"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewList}}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Name" Width="120" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
<ListView ItemsSource="{Binding Items}"
|
||||||
|
ItemTemplate="{StaticResource PreviewTile}"
|
||||||
|
ItemContainerStyle="{StaticResource IconListItem}"
|
||||||
|
MouseDoubleClick="OnItemDoubleClick"
|
||||||
|
SelectionChanged="OnSelectionChanged"
|
||||||
|
AllowDrop="True"
|
||||||
|
PreviewMouseLeftButtonDown="OnListMouseDown"
|
||||||
|
Drop="OnListDrop"
|
||||||
|
DragOver="OnListDragOver"
|
||||||
|
GotFocus="OnPaneFocus"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
HorizontalContentAlignment="Left"
|
||||||
|
Visibility="{Binding ViewMode, Converter={StaticResource ViewPreview}}">
|
||||||
|
<ListView.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel Width="{Binding ActualWidth, RelativeSource={RelativeSource AncestorType=ScrollViewer}}"/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListView.ItemsPanel>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</TabControl.ContentTemplate>
|
||||||
|
</TabControl>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Search.IsOpen, Converter={StaticResource BoolVis}}">
|
||||||
|
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="40" Padding="16">
|
||||||
|
<DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" LastChildFill="True" Margin="0,0,0,12">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseSearch" Margin="8,0,0,0"/>
|
||||||
|
<TextBlock Text="Search" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,8"/>
|
||||||
|
<TextBox Grid.Column="1" Margin="0,0,16,8" Text="{Binding Search.Text, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
KeyDown="OnSearchKeyDown"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="Scope" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,8"/>
|
||||||
|
<ComboBox Grid.Column="3" Margin="0,0,0,8"
|
||||||
|
ItemsSource="{Binding Search.ScopeChoices}"
|
||||||
|
DisplayMemberPath="Label"
|
||||||
|
SelectedValuePath="Kind"
|
||||||
|
SelectedValue="{Binding Search.Scope}"/>
|
||||||
|
<TextBlock Grid.Row="1" Text="Extension" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal" Margin="0,0,16,0">
|
||||||
|
<TextBox Width="90" Text="{Binding Search.Extension, UpdateSourceTrigger=PropertyChanged}"/>
|
||||||
|
<CheckBox Content="Files" Margin="16,0,8,0" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
|
||||||
|
IsChecked="{Binding Search.FilesOnly}"/>
|
||||||
|
<CheckBox Content="Folders" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"
|
||||||
|
IsChecked="{Binding Search.FoldersOnly}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Row="1" Grid.Column="2" Text="Size" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<StackPanel Grid.Row="1" Grid.Column="3" Orientation="Horizontal">
|
||||||
|
<TextBox Width="90" Margin="0,0,8,0" Text="{Binding Search.MinSizeText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
ToolTip="Minimum size, e.g. 10MB"/>
|
||||||
|
<TextBox Width="90" Text="{Binding Search.MaxSizeText, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
ToolTip="Maximum size, e.g. 1GB"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Search" Command="{Binding SearchCommand}"/>
|
||||||
|
<TextBlock Text="{Binding Search.Status}" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" IsIndeterminate="True"
|
||||||
|
Visibility="{Binding Search.IsBusy, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<ListView ItemsSource="{Binding Search.Results}" MouseDoubleClick="OnSearchDoubleClick"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||||
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}">
|
||||||
|
<ListView.View>
|
||||||
|
<GridView>
|
||||||
|
<GridViewColumn Header="Name" Width="220" CellTemplate="{StaticResource NameWithIcon}"/>
|
||||||
|
<GridViewColumn Header="Path" Width="420" DisplayMemberBinding="{Binding FullPath}"/>
|
||||||
|
<GridViewColumn Header="Size" Width="100" DisplayMemberBinding="{Binding SizeLabel}"/>
|
||||||
|
</GridView>
|
||||||
|
</ListView.View>
|
||||||
|
</ListView>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Background="#99000000" Visibility="{Binding Analysis.IsOpen, Converter={StaticResource BoolVis}}">
|
||||||
|
<Border Background="{DynamicResource Panel}" BorderBrush="{DynamicResource Stroke}" BorderThickness="1" Margin="24" Padding="16">
|
||||||
|
<DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,12">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Close" Command="{Binding Analysis.CloseCommand}"/>
|
||||||
|
<TextBlock Text="Storage" FontSize="18" FontWeight="SemiBold" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Location" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<ComboBox DockPanel.Dock="Left" ItemsSource="{Binding Analysis.Scopes}" SelectedItem="{Binding Analysis.SelectedScope}"
|
||||||
|
DisplayMemberPath="Label" Width="220" Margin="0,0,16,0"/>
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="View" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||||
|
<ComboBox DockPanel.Dock="Left" ItemsSource="{Binding Analysis.Pages}" SelectedItem="{Binding Analysis.Page}" Width="150" Margin="0,0,16,0"/>
|
||||||
|
<TextBlock Text="{Binding Analysis.Status}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
||||||
|
</DockPanel>
|
||||||
|
<WrapPanel DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||||
|
<Button Content="Open" Command="{Binding OpenStorageHereCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
|
||||||
|
<Button Content="Open in other pane" Command="{Binding OpenStorageOtherCommand}" Margin="0,0,6,0"
|
||||||
|
IsEnabled="{Binding Analysis.CanAct}"
|
||||||
|
Visibility="{Binding ActiveTab.IsSplit, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<Button Content="Open in new tab" Command="{Binding OpenStorageTabCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
|
||||||
|
<Button Content="Search here" Command="{Binding SearchStorageCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
|
||||||
|
<Button Content="Rescan" Command="{Binding RescanStorageCommand}" Margin="0,0,6,0" IsEnabled="{Binding Analysis.CanAct}"/>
|
||||||
|
<Button Content="Copy path" Command="{Binding CopyStoragePathCommand}" IsEnabled="{Binding Analysis.CanAct}"/>
|
||||||
|
</WrapPanel>
|
||||||
|
<ProgressBar DockPanel.Dock="Top" Margin="0,0,0,8" Height="4" IsIndeterminate="True"
|
||||||
|
IsHitTestVisible="False"
|
||||||
|
Visibility="{Binding Analysis.IsBusy, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<Grid>
|
||||||
|
<Grid Visibility="{Binding Analysis.IsTree, Converter={StaticResource BoolVis}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Margin="6,0,22,6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="170"/>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
<ColumnDefinition Width="88"/>
|
||||||
|
<ColumnDefinition Width="72"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="Contents" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="Relative size" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="Size" HorizontalAlignment="Right" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="State" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
</Grid>
|
||||||
|
<ListView Grid.Row="1" ItemsSource="{Binding Analysis.VisibleNodes}"
|
||||||
|
SelectedItem="{Binding Analysis.SelectedNode}"
|
||||||
|
MouseDoubleClick="OnStorageTreeDoubleClick"
|
||||||
|
ItemContainerStyle="{StaticResource PlainListItem}"
|
||||||
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
ScrollViewer.CanContentScroll="True">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="0,2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="170"/>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
<ColumnDefinition Width="88"/>
|
||||||
|
<ColumnDefinition Width="72"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="{Binding Indent, Converter={StaticResource Indent}}">
|
||||||
|
<Grid Width="18" Height="18" Margin="0,0,6,0">
|
||||||
|
<Button Width="18" Height="18" Padding="0"
|
||||||
|
Command="{Binding DataContext.Analysis.ToggleNodeCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
Visibility="{Binding CanExpand, Converter={StaticResource BoolVis}}">
|
||||||
|
<TextBlock FontSize="11" Foreground="{DynamicResource FgMuted}">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Text" Value="▸"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsExpanded}" Value="True">
|
||||||
|
<Setter Property="Text" Value="▾"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock FontFamily="{StaticResource Symbol}" Text="{Binding Glyph}" FontSize="14"
|
||||||
|
Foreground="{DynamicResource Accent}" Margin="0,0,8,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Fg}" TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" ToolTip="{Binding FullPath}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding CountLabel}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
||||||
|
<ProgressBar Grid.Column="2" Style="{StaticResource StorageSizeBar}" Value="{Binding Fraction}"
|
||||||
|
ToolTip="{Binding SizeLabel}"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding SizeLabel}" HorizontalAlignment="Right" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding State}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding HasState, Converter={StaticResource BoolVis}}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
<ListView.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Open" Command="{Binding DataContext.OpenStorageHereCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Open in other pane" Command="{Binding DataContext.OpenStorageOtherCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
Visibility="{Binding DataContext.ActiveTab.IsSplit, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Open in new tab" Command="{Binding DataContext.OpenStorageTabCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Search within this location" Command="{Binding DataContext.SearchStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Rescan this location" Command="{Binding DataContext.RescanStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyStoragePathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</ListView.ContextMenu>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
<Grid Visibility="{Binding Analysis.IsRanking, Converter={StaticResource BoolVis}}">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<Grid Margin="6,0,22,6">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="180"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="140"/>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="88"/>
|
||||||
|
<ColumnDefinition Width="72"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Name" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="Location" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="Contents" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="Relative size" Foreground="{DynamicResource FgMuted}" Margin="8,0"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="Size" HorizontalAlignment="Right" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="State" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}"/>
|
||||||
|
</Grid>
|
||||||
|
<ListView Grid.Row="1" ItemsSource="{Binding Analysis.Rows}"
|
||||||
|
SelectedItem="{Binding Analysis.SelectedRow}"
|
||||||
|
MouseDoubleClick="OnStorageRankDoubleClick"
|
||||||
|
ItemContainerStyle="{StaticResource PlainListItem}"
|
||||||
|
Background="{DynamicResource Panel}" Foreground="{DynamicResource Fg}"
|
||||||
|
VirtualizingPanel.IsVirtualizing="True"
|
||||||
|
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||||
|
ScrollViewer.CanContentScroll="True">
|
||||||
|
<ListView.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="0,2">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="180"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="140"/>
|
||||||
|
<ColumnDefinition Width="200"/>
|
||||||
|
<ColumnDefinition Width="88"/>
|
||||||
|
<ColumnDefinition Width="72"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Fg}" TextTrimming="CharacterEllipsis"
|
||||||
|
VerticalAlignment="Center" ToolTip="{Binding Path}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding ShortPath}" Foreground="{DynamicResource FgMuted}"
|
||||||
|
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Margin="8,0" ToolTip="{Binding Path}"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding CountLabel}" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"/>
|
||||||
|
<ProgressBar Grid.Column="3" Style="{StaticResource StorageSizeBar}" Value="{Binding Fraction}"
|
||||||
|
ToolTip="{Binding SizeLabel}"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding SizeLabel}" HorizontalAlignment="Right" Foreground="{DynamicResource Fg}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="{Binding State}" Margin="8,0,0,0" Foreground="{DynamicResource FgMuted}" VerticalAlignment="Center"
|
||||||
|
Visibility="{Binding HasState, Converter={StaticResource BoolVis}}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListView.ItemTemplate>
|
||||||
|
<ListView.ContextMenu>
|
||||||
|
<ContextMenu>
|
||||||
|
<MenuItem Header="Open" Command="{Binding DataContext.OpenStorageHereCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Open in other pane" Command="{Binding DataContext.OpenStorageOtherCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||||
|
Visibility="{Binding DataContext.ActiveTab.IsSplit, RelativeSource={RelativeSource AncestorType=Window}, Converter={StaticResource BoolVis}}"/>
|
||||||
|
<MenuItem Header="Open in new tab" Command="{Binding DataContext.OpenStorageTabCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Search within this location" Command="{Binding DataContext.SearchStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Rescan this location" Command="{Binding DataContext.RescanStorageCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
<MenuItem Header="Copy path" Command="{Binding DataContext.CopyStoragePathCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||||
|
</ContextMenu>
|
||||||
|
</ListView.ContextMenu>
|
||||||
|
</ListView>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</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">
|
||||||
|
<DockPanel>
|
||||||
|
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||||
|
<Button DockPanel.Dock="Right" Content="Close" Click="OnCloseDuplicates"/>
|
||||||
|
<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"/>
|
||||||
|
<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>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
525
src/Explorer.App/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Controls.Primitives;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Presentation;
|
||||||
|
using Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private Point _dragStart;
|
||||||
|
private bool _dragPending;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (DataContext is MainViewModel vm)
|
||||||
|
{
|
||||||
|
vm.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(MainViewModel.Theme))
|
||||||
|
{
|
||||||
|
ApplyTheme(vm.Theme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainViewModel Vm => (MainViewModel)DataContext;
|
||||||
|
|
||||||
|
private void ApplyTheme(string theme)
|
||||||
|
{
|
||||||
|
var dicts = System.Windows.Application.Current.Resources.MergedDictionaries;
|
||||||
|
dicts.Clear();
|
||||||
|
var uri = theme == "Light"
|
||||||
|
? new Uri("Themes/Light.xaml", UriKind.Relative)
|
||||||
|
: new Uri("Themes/Dark.xaml", UriKind.Relative);
|
||||||
|
dicts.Add(new ResourceDictionary { Source = uri });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.ChangedButton != MouseButton.Left)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.ClickCount == 2)
|
||||||
|
{
|
||||||
|
ToggleMaximized();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DragMove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
|
||||||
|
|
||||||
|
private void OnMaxRestore(object sender, RoutedEventArgs e) => ToggleMaximized();
|
||||||
|
|
||||||
|
private void OnCloseWindow(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private void ToggleMaximized()
|
||||||
|
{
|
||||||
|
WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
|
||||||
|
MaxRestoreButton.Content = WindowState == WindowState.Maximized ? "❐" : "☐";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPaneChromeMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is FrameworkElement { DataContext: ExplorerPaneViewModel pane })
|
||||||
|
{
|
||||||
|
Vm.ActiveTab.Activate(pane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPathKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
Vm.GoCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPathHistorySelected(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not ComboBox combo || e.AddedItems.Count == 0 || e.AddedItems[0] is not string path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (NavigationTreeViewModel.PathsEqual(path, Vm.ActivePane.CurrentPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (combo.IsDropDownOpen || combo.IsKeyboardFocusWithin)
|
||||||
|
{
|
||||||
|
Vm.PathText = path;
|
||||||
|
Vm.GoCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSearchKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
Vm.SearchCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnTreeSelected(object sender, RoutedPropertyChangedEventArgs<object> e)
|
||||||
|
{
|
||||||
|
if (Vm.Tree.IsRevealing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.NewValue is NavNodeViewModel node)
|
||||||
|
{
|
||||||
|
await Vm.TreeSelectAsync(node).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnTreeExpanded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is TreeViewItem { DataContext: NavNodeViewModel node })
|
||||||
|
{
|
||||||
|
await Vm.Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTabChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Tabs.SelectedItem is ExplorerTabViewModel tab)
|
||||||
|
{
|
||||||
|
Vm.ActiveTab = tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPaneSplitLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Grid grid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySplitFromGrid(grid);
|
||||||
|
if (Equals(grid.Tag, "split-wired"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.Tag = "split-wired";
|
||||||
|
ExplorerTabViewModel? current = null;
|
||||||
|
PropertyChangedEventHandler? handler = null;
|
||||||
|
|
||||||
|
void Wire(ExplorerTabViewModel? tab)
|
||||||
|
{
|
||||||
|
if (current is not null && handler is not null)
|
||||||
|
{
|
||||||
|
current.PropertyChanged -= handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = tab;
|
||||||
|
if (tab is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler = (_, args) =>
|
||||||
|
{
|
||||||
|
if (args.PropertyName is nameof(ExplorerTabViewModel.IsSplit)
|
||||||
|
or nameof(ExplorerTabViewModel.SplitRatio))
|
||||||
|
{
|
||||||
|
ApplySplitLayout(grid, tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tab.PropertyChanged += handler;
|
||||||
|
ApplySplitLayout(grid, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
Wire(grid.DataContext as ExplorerTabViewModel);
|
||||||
|
grid.DataContextChanged += (_, _) => Wire(grid.DataContext as ExplorerTabViewModel);
|
||||||
|
grid.Unloaded += (_, _) => Wire(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSplitDragCompleted(object sender, DragCompletedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not GridSplitter splitter || splitter.Parent is not Grid grid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.DataContext is not ExplorerTabViewModel tab || !tab.IsSplit)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftWidth = grid.ColumnDefinitions[0].ActualWidth;
|
||||||
|
var rightWidth = grid.ColumnDefinitions[2].ActualWidth;
|
||||||
|
var total = leftWidth + rightWidth;
|
||||||
|
if (total < 8)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tab.SetSplitRatio(leftWidth / total);
|
||||||
|
ApplySplitLayout(grid, tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplySplitFromGrid(Grid grid)
|
||||||
|
{
|
||||||
|
if (grid.DataContext is ExplorerTabViewModel tab)
|
||||||
|
{
|
||||||
|
ApplySplitLayout(grid, tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ApplySplitLayout(Grid grid, ExplorerTabViewModel tab)
|
||||||
|
{
|
||||||
|
var left = grid.ColumnDefinitions[0];
|
||||||
|
var mid = grid.ColumnDefinitions[1];
|
||||||
|
var right = grid.ColumnDefinitions[2];
|
||||||
|
if (tab.IsSplit)
|
||||||
|
{
|
||||||
|
var ratio = Math.Clamp(tab.SplitRatio, ExplorerTabViewModel.MinSplitRatio, ExplorerTabViewModel.MaxSplitRatio);
|
||||||
|
left.Width = new GridLength(ratio, GridUnitType.Star);
|
||||||
|
left.MinWidth = 140;
|
||||||
|
mid.Width = new GridLength(6);
|
||||||
|
mid.MinWidth = 6;
|
||||||
|
right.Width = new GridLength(1.0 - ratio, GridUnitType.Star);
|
||||||
|
right.MinWidth = 140;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
left.Width = new GridLength(1, GridUnitType.Star);
|
||||||
|
left.MinWidth = 0;
|
||||||
|
mid.Width = new GridLength(0);
|
||||||
|
mid.MinWidth = 0;
|
||||||
|
right.Width = new GridLength(0);
|
||||||
|
right.MinWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var splitter in grid.Children.OfType<GridSplitter>())
|
||||||
|
{
|
||||||
|
splitter.IsHitTestVisible = tab.IsSplit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCloseTab(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is Button { Tag: ExplorerTabViewModel tab })
|
||||||
|
{
|
||||||
|
Vm.CloseTab(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnItemDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is ListView { SelectedItem: FolderItemViewModel item })
|
||||||
|
{
|
||||||
|
ActivatePaneFromList((ListView)sender);
|
||||||
|
await Vm.ActivePane.OpenItemAsync(item).ConfigureAwait(true);
|
||||||
|
Vm.PathText = Vm.ActivePane.CurrentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not ListView list)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivatePaneFromList(list);
|
||||||
|
Vm.ActivePane.SelectedItems.Clear();
|
||||||
|
foreach (FolderItemViewModel item in list.SelectedItems)
|
||||||
|
{
|
||||||
|
Vm.ActivePane.SelectedItems.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vm.RefreshCloudActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPaneFocus(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is ListView list)
|
||||||
|
{
|
||||||
|
ActivatePaneFromList(list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ActivatePaneFromList(ListView list)
|
||||||
|
{
|
||||||
|
var tab = Vm.ActiveTab;
|
||||||
|
if (list.ItemsSource == tab.Right.Items)
|
||||||
|
{
|
||||||
|
tab.Activate(tab.Right);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
tab.Activate(tab.Left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnListMouseDown(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
_dragStart = e.GetPosition(null);
|
||||||
|
_dragPending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnListDragOver(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
e.Effects = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 ? DragDropEffects.Move : DragDropEffects.Copy;
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnListDrop(object sender, DragEventArgs e)
|
||||||
|
{
|
||||||
|
if (!e.Data.GetDataPresent(DataFormats.FileDrop) || sender is not ListView list)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivatePaneFromList(list);
|
||||||
|
var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;
|
||||||
|
var move = (e.KeyStates & DragDropKeyStates.ShiftKey) != 0 || e.AllowedEffects == DragDropEffects.Move;
|
||||||
|
await Vm.DropAsync(files, Vm.ActivePane.CurrentPath, move).ConfigureAwait(true);
|
||||||
|
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPreviewMouseMove(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnPreviewMouseMove(e);
|
||||||
|
if (!_dragPending || e.LeftButton != MouseButtonState.Pressed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pos = e.GetPosition(null);
|
||||||
|
if (Math.Abs(pos.X - _dragStart.X) < SystemParameters.MinimumHorizontalDragDistance
|
||||||
|
&& Math.Abs(pos.Y - _dragStart.Y) < SystemParameters.MinimumVerticalDragDistance)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dragPending = false;
|
||||||
|
var paths = Vm.ActivePane.SelectedItems.Select(i => i.FullPath).ToArray();
|
||||||
|
if (paths.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new DataObject(DataFormats.FileDrop, paths);
|
||||||
|
DragDrop.DoDragDrop(this, data, DragDropEffects.Copy | DragDropEffects.Move);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnSearchDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is ListView { SelectedItem: FolderItemViewModel item })
|
||||||
|
{
|
||||||
|
Vm.Search.IsOpen = false;
|
||||||
|
if (item.IsDirectory)
|
||||||
|
{
|
||||||
|
await Vm.ActivePane.NavigateAsync(item.FullPath).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Vm.ActivePane.NavigateAsync(PathRules.Parent(item.FullPath)).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vm.PathText = Vm.ActivePane.CurrentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnStorageTreeDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.OriginalSource is DependencyObject origin && IsInsideButton(origin))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Vm.Analysis.CanAct)
|
||||||
|
{
|
||||||
|
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsInsideButton(DependencyObject origin)
|
||||||
|
{
|
||||||
|
for (var current = origin; current is not null;)
|
||||||
|
{
|
||||||
|
if (current is Button)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current is System.Windows.Media.Visual
|
||||||
|
? System.Windows.Media.VisualTreeHelper.GetParent(current)
|
||||||
|
: LogicalTreeHelper.GetParent(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnStorageRankDoubleClick(object sender, MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (Vm.Analysis.CanAct)
|
||||||
|
{
|
||||||
|
await Vm.OpenStorageHereAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
private void OnCtxOpen(object sender, RoutedEventArgs e) => Vm.OpenSelectedCommand.Execute(null);
|
||||||
|
|
||||||
|
private void OnCtxRename(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var item = Vm.ActivePane.SelectedItems.FirstOrDefault();
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = PromptWindow.Ask(this, "Rename", "New name:", item.Name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(name) && name != item.Name)
|
||||||
|
{
|
||||||
|
Vm.RenameSelected(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAddNetwork(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var path = PromptWindow.Ask(this, "Add network location", "Network path (\\\\server\\share):", @"\\");
|
||||||
|
if (!string.IsNullOrWhiteSpace(path) && path != @"\\")
|
||||||
|
{
|
||||||
|
Vm.PromptUnc = path;
|
||||||
|
Vm.AddNetworkCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnAddOneDrive(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var picker = new Microsoft.Win32.OpenFolderDialog
|
||||||
|
{
|
||||||
|
Title = "Add OneDrive folder",
|
||||||
|
Multiselect = false
|
||||||
|
};
|
||||||
|
if (picker.ShowDialog(this) != true || string.IsNullOrWhiteSpace(picker.FolderName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Vm.AddCloudFolderAsync(picker.FolderName).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||||
|
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
|
||||||
|
if (e.Key == Key.F5)
|
||||||
|
{
|
||||||
|
await Vm.RefreshAsync().ConfigureAwait(true);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.F2)
|
||||||
|
{
|
||||||
|
OnCtxRename(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (ctrl && e.Key == Key.C)
|
||||||
|
{
|
||||||
|
Vm.Copy();
|
||||||
|
}
|
||||||
|
else if (ctrl && e.Key == Key.X)
|
||||||
|
{
|
||||||
|
Vm.Cut();
|
||||||
|
}
|
||||||
|
else if (ctrl && e.Key == Key.V)
|
||||||
|
{
|
||||||
|
await Vm.PasteAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Delete)
|
||||||
|
{
|
||||||
|
await Vm.DeleteAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (ctrl && e.Key == Key.T)
|
||||||
|
{
|
||||||
|
await Vm.NewTabAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (ctrl && e.Key == Key.W)
|
||||||
|
{
|
||||||
|
Vm.CloseTab(Vm.ActiveTab);
|
||||||
|
}
|
||||||
|
else if (alt && e.Key == Key.Left)
|
||||||
|
{
|
||||||
|
await Vm.BackAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (alt && e.Key == Key.Right)
|
||||||
|
{
|
||||||
|
await Vm.ForwardAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (alt && e.Key == Key.Up)
|
||||||
|
{
|
||||||
|
await Vm.UpAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.Enter)
|
||||||
|
{
|
||||||
|
await Vm.OpenSelectedAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/Explorer.App/PromptWindow.xaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Window x:Class="Explorer.App.PromptWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Explorer Workbench"
|
||||||
|
Icon="pack://application:,,,/Assets/explorer-workbench.ico"
|
||||||
|
Height="220" Width="520"
|
||||||
|
MinHeight="200" MinWidth="420"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource Bg}" Foreground="{DynamicResource Fg}"
|
||||||
|
ResizeMode="NoResize">
|
||||||
|
<DockPanel Margin="20">
|
||||||
|
<TextBlock x:Name="Message" DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,16"
|
||||||
|
FontSize="13" Foreground="{DynamicResource Fg}"/>
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
|
||||||
|
<Button Content="OK" MinWidth="88" Height="32" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
|
||||||
|
<Button Content="Cancel" MinWidth="88" Height="32" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox x:Name="Input" MinHeight="36" FontSize="14" VerticalContentAlignment="Center"
|
||||||
|
Padding="10,8"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
30
src/Explorer.App/PromptWindow.xaml.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public partial class PromptWindow : Window
|
||||||
|
{
|
||||||
|
public PromptWindow(string title, string message, string initial)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
Title = title;
|
||||||
|
Message.Text = message;
|
||||||
|
Input.Text = initial;
|
||||||
|
Input.SelectAll();
|
||||||
|
Loaded += (_, _) => Input.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value => Input.Text;
|
||||||
|
|
||||||
|
private void OnOk(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
DialogResult = true;
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Ask(Window owner, string title, string message, string initial)
|
||||||
|
{
|
||||||
|
var dlg = new PromptWindow(title, message, initial) { Owner = owner };
|
||||||
|
return dlg.ShowDialog() == true ? dlg.Value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/Explorer.App/Services/WpfClipboard.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.IO;
|
||||||
|
using System.Windows;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.App.Services;
|
||||||
|
|
||||||
|
public sealed class WpfClipboard : IOsClipboard
|
||||||
|
{
|
||||||
|
public void SetFiles(IReadOnlyList<string> paths, bool cut)
|
||||||
|
{
|
||||||
|
var list = new StringCollection();
|
||||||
|
foreach (var p in paths)
|
||||||
|
{
|
||||||
|
list.Add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = new DataObject();
|
||||||
|
data.SetFileDropList(list);
|
||||||
|
data.SetData("Preferred DropEffect", new MemoryStream([(byte)(cut ? DragDropEffects.Move : DragDropEffects.Copy), 0, 0, 0]));
|
||||||
|
Clipboard.SetDataObject(data, copy: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut)
|
||||||
|
{
|
||||||
|
paths = [];
|
||||||
|
cut = false;
|
||||||
|
if (!Clipboard.ContainsFileDropList())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = Clipboard.GetFileDropList().Cast<string>().Where(s => s is not null).Cast<string>().ToList();
|
||||||
|
paths = list;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Clipboard.GetData("Preferred DropEffect") is MemoryStream ms)
|
||||||
|
{
|
||||||
|
var b = ms.ToArray();
|
||||||
|
cut = b.Length > 0 && b[0] == (byte)DragDropEffects.Move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
cut = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.Count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
159
src/Explorer.App/ShellIconConverter.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using System.Windows.Interop;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using System.Windows.Media.Imaging;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Presentation;
|
||||||
|
|
||||||
|
namespace Explorer.App;
|
||||||
|
|
||||||
|
public sealed class ShellIconConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var large = parameter is string p && p.Equals("large", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
FolderItemViewModel item => ShellIconCache.Get(item.FullPath, item.IsDirectory, large, item.MayHydrateOnRead),
|
||||||
|
FileSystemItem fs => ShellIconCache.Get(fs.FullPath, fs.IsDirectory, large, AttributeFlags.MayHydrateOnRead(fs.Attributes)),
|
||||||
|
string path => ShellIconCache.Get(path, Directory.Exists(path), large),
|
||||||
|
_ => ShellIconCache.Get(null, true, large)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ShellIconCache
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, ImageSource> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static ImageSource Get(string? path, bool isDirectory, bool large = false, bool avoidHydration = false)
|
||||||
|
{
|
||||||
|
var key = (large ? "L:" : "S:") + (avoidHydration ? "A:" : "") + CacheKey(path, isDirectory);
|
||||||
|
return Cache.GetOrAdd(key, _ => Load(path, isDirectory, large, avoidHydration));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CacheKey(string? path, bool isDirectory)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
return isDirectory ? "dir:generic" : "file:generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
return PathRules.IsDriveRoot(path) || (path.Length <= 3 && path.Contains(':', StringComparison.Ordinal))
|
||||||
|
? "drive:" + path.TrimEnd('\\').ToUpperInvariant()
|
||||||
|
: "dir:generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
if (ext.Equals(".exe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ext.Equals(".ico", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| ext.Equals(".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return "path:" + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "ext:" + (string.IsNullOrEmpty(ext) ? ".file" : ext.ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageSource Load(string? path, bool isDirectory, bool large, bool avoidHydration = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var info = new ShFileInfo();
|
||||||
|
uint flags = ShgfiIcon | (large ? ShgfiLargeIcon : ShgfiSmallIcon);
|
||||||
|
uint attrs = isDirectory ? FileAttributeDirectory : FileAttributeNormal;
|
||||||
|
string psz;
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
psz = isDirectory ? "folder" : "file";
|
||||||
|
flags |= ShgfiUseFileAttributes;
|
||||||
|
}
|
||||||
|
else if (isDirectory && PathRules.IsDriveRoot(path))
|
||||||
|
{
|
||||||
|
psz = PathRules.EnsureDirectoryTrailingSlashIfRoot(path);
|
||||||
|
}
|
||||||
|
else if (isDirectory)
|
||||||
|
{
|
||||||
|
psz = path;
|
||||||
|
flags |= ShgfiUseFileAttributes;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
psz = path;
|
||||||
|
var ext = Path.GetExtension(path);
|
||||||
|
if (avoidHydration
|
||||||
|
|| !ext.Equals(".exe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !ext.Equals(".lnk", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !ext.Equals(".ico", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& File.Exists(path) == false)
|
||||||
|
{
|
||||||
|
flags |= ShgfiUseFileAttributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SHGetFileInfo(psz, attrs, ref info, (uint)Marshal.SizeOf<ShFileInfo>(), flags);
|
||||||
|
if (info.hIcon == 0)
|
||||||
|
{
|
||||||
|
return Fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = Imaging.CreateBitmapSourceFromHIcon(info.hIcon, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
|
||||||
|
source.Freeze();
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DestroyIcon(info.hIcon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImageSource Fallback()
|
||||||
|
{
|
||||||
|
var bmp = BitmapSource.Create(16, 16, 96, 96, PixelFormats.Bgra32, null, new byte[16 * 16 * 4], 16 * 4);
|
||||||
|
bmp.Freeze();
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const uint ShgfiIcon = 0x100;
|
||||||
|
private const uint ShgfiLargeIcon = 0x0;
|
||||||
|
private const uint ShgfiSmallIcon = 0x1;
|
||||||
|
private const uint ShgfiUseFileAttributes = 0x10;
|
||||||
|
private const uint FileAttributeDirectory = 0x10;
|
||||||
|
private const uint FileAttributeNormal = 0x80;
|
||||||
|
|
||||||
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
private static extern nint SHGetFileInfo(string pszPath, uint dwFileAttributes, ref ShFileInfo psfi, uint cbFileInfo, uint uFlags);
|
||||||
|
|
||||||
|
[DllImport("user32.dll", SetLastError = true)]
|
||||||
|
private static extern bool DestroyIcon(nint hIcon);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
private struct ShFileInfo
|
||||||
|
{
|
||||||
|
public nint hIcon;
|
||||||
|
public int iIcon;
|
||||||
|
public uint dwAttributes;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
|
||||||
|
public string szDisplayName;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)]
|
||||||
|
public string szTypeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Explorer.App/Themes/Dark.xaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<SolidColorBrush x:Key="Bg" Color="#1C1C1C"/>
|
||||||
|
<SolidColorBrush x:Key="Panel" Color="#252526"/>
|
||||||
|
<SolidColorBrush x:Key="Fill" Color="#2D2D30"/>
|
||||||
|
<SolidColorBrush x:Key="FillHover" Color="#3E3E42"/>
|
||||||
|
<SolidColorBrush x:Key="InputBg" Color="#1E1E1E"/>
|
||||||
|
<SolidColorBrush x:Key="Fg" Color="#F3F3F3"/>
|
||||||
|
<SolidColorBrush x:Key="FgMuted" Color="#C8C8C8"/>
|
||||||
|
<SolidColorBrush x:Key="Stroke" Color="#3F3F46"/>
|
||||||
|
<SolidColorBrush x:Key="Accent" Color="#60CDFF"/>
|
||||||
|
<SolidColorBrush x:Key="Danger" Color="#E81123"/>
|
||||||
|
<SolidColorBrush x:Key="Banner" Color="#264F78"/>
|
||||||
|
<SolidColorBrush x:Key="ActivePane" Color="#60CDFF"/>
|
||||||
|
<SolidColorBrush x:Key="ActivePaneFill" Color="#1E2A32"/>
|
||||||
|
<SolidColorBrush x:Key="ListSelection" Color="#264F78"/>
|
||||||
|
<SolidColorBrush x:Key="TreeSelection" Color="#2B4B63"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollTrack" Color="#252526"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumb" Color="#5A5A5E"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumbHover" Color="#7A7A7E"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#9A9A9E"/>
|
||||||
|
</ResourceDictionary>
|
||||||
22
src/Explorer.App/Themes/Light.xaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<SolidColorBrush x:Key="Bg" Color="#F3F3F3"/>
|
||||||
|
<SolidColorBrush x:Key="Panel" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="Fill" Color="#E8E8E8"/>
|
||||||
|
<SolidColorBrush x:Key="FillHover" Color="#DADADA"/>
|
||||||
|
<SolidColorBrush x:Key="InputBg" Color="#FFFFFF"/>
|
||||||
|
<SolidColorBrush x:Key="Fg" Color="#1A1A1A"/>
|
||||||
|
<SolidColorBrush x:Key="FgMuted" Color="#5A5A5A"/>
|
||||||
|
<SolidColorBrush x:Key="Stroke" Color="#D0D0D0"/>
|
||||||
|
<SolidColorBrush x:Key="Accent" Color="#0078D4"/>
|
||||||
|
<SolidColorBrush x:Key="Danger" Color="#C42B1C"/>
|
||||||
|
<SolidColorBrush x:Key="Banner" Color="#D6E8F7"/>
|
||||||
|
<SolidColorBrush x:Key="ActivePane" Color="#0078D4"/>
|
||||||
|
<SolidColorBrush x:Key="ActivePaneFill" Color="#E8F4FC"/>
|
||||||
|
<SolidColorBrush x:Key="ListSelection" Color="#CDE6F7"/>
|
||||||
|
<SolidColorBrush x:Key="TreeSelection" Color="#D6E8F7"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollTrack" Color="#EDEDED"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumb" Color="#B0B0B0"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumbHover" Color="#8A8A8A"/>
|
||||||
|
<SolidColorBrush x:Key="ScrollThumbPressed" Color="#6A6A6A"/>
|
||||||
|
</ResourceDictionary>
|
||||||
16
src/Explorer.App/app.manifest
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="Explorer.App"/>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
10
src/Explorer.Application/AppOptions.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class AppOptions
|
||||||
|
{
|
||||||
|
public int TombstoneRetentionDays { get; set; } = AppConstants.DefaultTombstoneRetentionDays;
|
||||||
|
public bool SkipHidden { get; set; } = true;
|
||||||
|
public bool SkipSystem { get; set; } = true;
|
||||||
|
}
|
||||||
151
src/Explorer.Application/BrowseService.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class BrowseService
|
||||||
|
{
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
|
||||||
|
public BrowseService(
|
||||||
|
IFileSystemEnumerator enumerator,
|
||||||
|
IVolumeService volumes,
|
||||||
|
IIndexStore store,
|
||||||
|
SourceManager sources,
|
||||||
|
StorageProviderRegistry providers)
|
||||||
|
{
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_volumes = volumes;
|
||||||
|
_store = store;
|
||||||
|
_sources = sources;
|
||||||
|
_providers = providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FolderListing> ListThisPcAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var items = new List<FileSystemItem>();
|
||||||
|
foreach (var source in sources.OrderBy(s => s.Kind).ThenBy(s => s.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
IndexEntry? root = null;
|
||||||
|
if (source.IsIndexed)
|
||||||
|
{
|
||||||
|
root = await _store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.Add(new FileSystemItem
|
||||||
|
{
|
||||||
|
FullPath = source.LastRootPath ?? source.DisplayName,
|
||||||
|
Name = source.Status == SourceStatus.Offline
|
||||||
|
? $"{source.DisplayName} (Offline)"
|
||||||
|
: source.DisplayName,
|
||||||
|
IsDirectory = true,
|
||||||
|
SizeBytes = root?.AggregateSize ?? 0,
|
||||||
|
Attributes = AttributeFlags.Directory
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FolderListing { Path = "This PC", Items = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FolderListing> ListAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var source = await _sources.FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
var reachable = _volumes.IsPathReachable(path);
|
||||||
|
|
||||||
|
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 new FolderListing { Path = path, IsOffline = false, Items = listing, Error = error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source is { IsIndexed: true })
|
||||||
|
{
|
||||||
|
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 new FolderListing { Path = path, IsOffline = true, Items = items };
|
||||||
|
}
|
||||||
|
|
||||||
|
return new FolderListing { Path = path, IsOffline = true, Error = "Path not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Explorer.Application/CloudPlaceStore.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class CloudPlaceStore
|
||||||
|
{
|
||||||
|
public const string FileName = "cloud-places.txt";
|
||||||
|
|
||||||
|
private readonly IAppEnvironment _env;
|
||||||
|
|
||||||
|
public CloudPlaceStore(IAppEnvironment env) => _env = env;
|
||||||
|
|
||||||
|
public IReadOnlyList<ProviderPlace> Load()
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_env.DataDirectory, FileName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Parse(File.ReadAllLines(file));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ProviderPlace> Add(string providerId, string path, string? displayName = null)
|
||||||
|
{
|
||||||
|
var places = Load().ToList();
|
||||||
|
var trimmed = path.Trim().TrimEnd('\\');
|
||||||
|
if (string.IsNullOrWhiteSpace(trimmed))
|
||||||
|
{
|
||||||
|
return places;
|
||||||
|
}
|
||||||
|
|
||||||
|
places.RemoveAll(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
|
||||||
|
var label = string.IsNullOrWhiteSpace(displayName) ? Path.GetFileName(trimmed) : displayName.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(label))
|
||||||
|
{
|
||||||
|
label = "OneDrive";
|
||||||
|
}
|
||||||
|
|
||||||
|
places.Add(new ProviderPlace(providerId, label, trimmed));
|
||||||
|
Save(places);
|
||||||
|
return places;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(IEnumerable<ProviderPlace> places)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_env.DataDirectory);
|
||||||
|
File.WriteAllLines(
|
||||||
|
Path.Combine(_env.DataDirectory, FileName),
|
||||||
|
places.Select(p => $"{p.ProviderId}|{Escape(p.DisplayName)}|{p.Path}"));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// places are convenience-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<ProviderPlace> Merge(IEnumerable<ProviderPlace> discovered, IEnumerable<ProviderPlace> manual)
|
||||||
|
{
|
||||||
|
var list = new List<ProviderPlace>();
|
||||||
|
foreach (var place in discovered.Concat(manual))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(place.Path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = place.Path.TrimEnd('\\');
|
||||||
|
if (list.Exists(p => p.Path.Equals(path, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(place with { Path = path });
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IReadOnlyList<ProviderPlace> Parse(IEnumerable<string> lines)
|
||||||
|
{
|
||||||
|
var list = new List<ProviderPlace>();
|
||||||
|
foreach (var raw in lines)
|
||||||
|
{
|
||||||
|
var line = raw.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = line.Split('|', 3);
|
||||||
|
if (parts.Length < 3 || string.IsNullOrWhiteSpace(parts[2]))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(new ProviderPlace(parts[0], Unescape(parts[1]), parts[2].TrimEnd('\\')));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string value) => value.Replace('|', '/');
|
||||||
|
private static string Unescape(string value) => value;
|
||||||
|
}
|
||||||
15
src/Explorer.Application/Explorer.Application.csproj
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Application</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
49
src/Explorer.Application/HydrationGuard.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public interface IHydrationGuard
|
||||||
|
{
|
||||||
|
bool WouldHydrateOnRead(FileSystemItem item);
|
||||||
|
bool WouldHydrateOnRead(int attributes, Domain.CloudAvailability? availability);
|
||||||
|
Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HydrationGuard : IHydrationGuard
|
||||||
|
{
|
||||||
|
private readonly StorageProviderRegistry _registry;
|
||||||
|
|
||||||
|
public HydrationGuard(StorageProviderRegistry registry) => _registry = registry;
|
||||||
|
|
||||||
|
public bool WouldHydrateOnRead(FileSystemItem item)
|
||||||
|
{
|
||||||
|
if (item.Cloud is { MayHydrateOnRead: true } or { Availability: Domain.CloudAvailability.OnlineOnly })
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WouldHydrateOnRead(item.Attributes, item.Cloud?.Availability);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool WouldHydrateOnRead(int attributes, Domain.CloudAvailability? availability)
|
||||||
|
{
|
||||||
|
if (availability is Domain.CloudAvailability.OnlineOnly or Domain.CloudAvailability.Syncing)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AttributeFlags.MayHydrateOnRead(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var state = await _registry.GetStateAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (state is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.MayHydrateOnRead || state.Availability == Plugin.Abstractions.CloudAvailability.OnlineOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/Explorer.Application/PathHistoryStore.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class PathHistoryStore
|
||||||
|
{
|
||||||
|
public const int MaxEntries = 25;
|
||||||
|
public const string FileName = "path-history.txt";
|
||||||
|
|
||||||
|
private readonly IAppEnvironment _env;
|
||||||
|
|
||||||
|
public PathHistoryStore(IAppEnvironment env) => _env = env;
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Load()
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_env.DataDirectory, FileName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return RememberMany(File.ReadAllLines(file));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_env.DataDirectory);
|
||||||
|
File.WriteAllLines(Path.Combine(_env.DataDirectory, FileName), RememberMany(paths));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// history is convenience-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> Remember(IEnumerable<string> existing, string path)
|
||||||
|
{
|
||||||
|
var list = existing.ToList();
|
||||||
|
return RememberMany(list.Prepend(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> RememberMany(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
foreach (var raw in paths)
|
||||||
|
{
|
||||||
|
var path = raw.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.Exists(p => p.Equals(path, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.Count > MaxEntries)
|
||||||
|
{
|
||||||
|
list.RemoveRange(MaxEntries, list.Count - MaxEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
||||||
295
src/Explorer.Application/SourceManager.cs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class SourceManager
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly IAppEnvironment _env;
|
||||||
|
private readonly IClock _clock;
|
||||||
|
private readonly ILogger<SourceManager> _logger;
|
||||||
|
|
||||||
|
public SourceManager(
|
||||||
|
IIndexStore store,
|
||||||
|
IVolumeService volumes,
|
||||||
|
IAppEnvironment env,
|
||||||
|
IClock clock,
|
||||||
|
ILogger<SourceManager> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_volumes = volumes;
|
||||||
|
_env = env;
|
||||||
|
_clock = clock;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _store.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await _store.ScanJobs.InterruptRunningAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await _store.Excludes.EnsureDefaultsAsync(DefaultExcludes.Create(), cancellationToken).ConfigureAwait(false);
|
||||||
|
await RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Source>> RefreshOnlineStateAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var known = (await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false)).ToList();
|
||||||
|
var online = _volumes.EnumerateOnlineVolumes();
|
||||||
|
var seenIds = new HashSet<long>();
|
||||||
|
|
||||||
|
foreach (var fp in online)
|
||||||
|
{
|
||||||
|
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||||
|
Source source;
|
||||||
|
if (match.Source is not null && !match.Ambiguous)
|
||||||
|
{
|
||||||
|
source = match.Source;
|
||||||
|
source.LastRootPath = fp.RootPath;
|
||||||
|
source.DisplayName = fp.DisplayName ?? source.DisplayName;
|
||||||
|
source.Label = fp.Label ?? source.Label;
|
||||||
|
source.Filesystem = fp.Filesystem ?? source.Filesystem;
|
||||||
|
source.CapacityBytes = fp.CapacityBytes ?? source.CapacityBytes;
|
||||||
|
source.VolumeGuid = fp.VolumeGuid ?? source.VolumeGuid;
|
||||||
|
source.VolumeSerial = fp.VolumeSerial ?? source.VolumeSerial;
|
||||||
|
source.Kind = fp.Kind;
|
||||||
|
source.LastSeenUtc = _clock.UtcNow;
|
||||||
|
source.Status = source.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online;
|
||||||
|
source.LastError = null;
|
||||||
|
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (source.IsIndexed)
|
||||||
|
{
|
||||||
|
await _store.Entries.MarkSourceOnlinePresentAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
source = new Source
|
||||||
|
{
|
||||||
|
StableKey = Guid.NewGuid().ToString("N"),
|
||||||
|
Kind = fp.Kind,
|
||||||
|
DisplayName = fp.DisplayName ?? fp.RootPath,
|
||||||
|
VolumeGuid = fp.VolumeGuid,
|
||||||
|
VolumeSerial = fp.VolumeSerial,
|
||||||
|
Filesystem = fp.Filesystem,
|
||||||
|
Label = fp.Label,
|
||||||
|
CapacityBytes = fp.CapacityBytes,
|
||||||
|
DeviceInstanceId = fp.DeviceInstanceId,
|
||||||
|
LastRootPath = fp.RootPath,
|
||||||
|
Status = SourceStatus.Online,
|
||||||
|
LastSeenUtc = _clock.UtcNow
|
||||||
|
};
|
||||||
|
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||||
|
known.Add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIds.Add(source.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var source in known)
|
||||||
|
{
|
||||||
|
if (seenIds.Contains(source.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reachable = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
|
||||||
|
if (reachable)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.Status != SourceStatus.Offline)
|
||||||
|
{
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Offline, null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _store.Entries.MarkSourceOfflineAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var unc in LoadRecents())
|
||||||
|
{
|
||||||
|
if (known.Any(s => s.LastRootPath is not null
|
||||||
|
&& PathRules.CanonicalUncRoot(s.LastRootPath)
|
||||||
|
.Equals(PathRules.CanonicalUncRoot(unc), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await AddUncAsync(unc, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Source> AddUncAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var root = PathRules.CanonicalUncRoot(path);
|
||||||
|
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var fp = new VolumeFingerprint
|
||||||
|
{
|
||||||
|
Kind = SourceKind.Smb,
|
||||||
|
RootPath = root,
|
||||||
|
DisplayName = root,
|
||||||
|
Filesystem = "SMB"
|
||||||
|
};
|
||||||
|
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||||
|
if (match.Source is not null)
|
||||||
|
{
|
||||||
|
var existing = match.Source;
|
||||||
|
existing.LastRootPath = root;
|
||||||
|
existing.LastSeenUtc = _clock.UtcNow;
|
||||||
|
existing.Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline;
|
||||||
|
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||||
|
RememberUnc(root);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = new Source
|
||||||
|
{
|
||||||
|
StableKey = Guid.NewGuid().ToString("N"),
|
||||||
|
Kind = SourceKind.Smb,
|
||||||
|
DisplayName = root,
|
||||||
|
Filesystem = "SMB",
|
||||||
|
LastRootPath = root,
|
||||||
|
Status = _volumes.IsPathReachable(root) ? SourceStatus.Online : SourceStatus.Offline,
|
||||||
|
LastSeenUtc = _clock.UtcNow
|
||||||
|
};
|
||||||
|
source.Id = await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||||
|
RememberUnc(root);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Source?> FindByPathAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var normalized = PathRules.FromExtended(path);
|
||||||
|
Source? best = null;
|
||||||
|
var bestLen = -1;
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
if (source.LastRootPath is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = PathRules.FromExtended(source.LastRootPath).TrimEnd('\\');
|
||||||
|
var candidate = normalized.TrimEnd('\\');
|
||||||
|
if (candidate.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| candidate.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (root.Length == 2 && root[1] == ':' && candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
if (root.Length > bestLen)
|
||||||
|
{
|
||||||
|
best = source;
|
||||||
|
bestLen = root.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Source?> EnsureForPathAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await FindByPathAsync(path, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
existing.LastSeenUtc = _clock.UtcNow;
|
||||||
|
existing.Status = _volumes.IsPathReachable(existing.LastRootPath ?? path)
|
||||||
|
? (existing.Status == SourceStatus.Scanning ? SourceStatus.Scanning : SourceStatus.Online)
|
||||||
|
: SourceStatus.Offline;
|
||||||
|
await _store.Sources.UpsertAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fp = _volumes.Probe(path);
|
||||||
|
if (fp is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var known = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var match = VolumeIdentityMatcher.Match(fp, known);
|
||||||
|
if (match.Source is not null && !match.Ambiguous)
|
||||||
|
{
|
||||||
|
var source = match.Source;
|
||||||
|
source.LastRootPath = fp.RootPath;
|
||||||
|
source.DisplayName = fp.DisplayName ?? source.DisplayName;
|
||||||
|
source.Kind = fp.Kind;
|
||||||
|
source.Filesystem = fp.Filesystem ?? source.Filesystem;
|
||||||
|
source.Label = fp.Label ?? source.Label;
|
||||||
|
source.LastSeenUtc = _clock.UtcNow;
|
||||||
|
source.Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline;
|
||||||
|
await _store.Sources.UpsertAsync(source, cancellationToken).ConfigureAwait(false);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fp.Kind == SourceKind.Smb && PathRules.IsUnc(fp.RootPath))
|
||||||
|
{
|
||||||
|
return await AddUncAsync(fp.RootPath, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = new Source
|
||||||
|
{
|
||||||
|
StableKey = Guid.NewGuid().ToString("N"),
|
||||||
|
Kind = fp.Kind,
|
||||||
|
DisplayName = fp.DisplayName ?? fp.RootPath,
|
||||||
|
VolumeGuid = fp.VolumeGuid,
|
||||||
|
VolumeSerial = fp.VolumeSerial,
|
||||||
|
Filesystem = fp.Filesystem,
|
||||||
|
Label = fp.Label,
|
||||||
|
CapacityBytes = fp.CapacityBytes,
|
||||||
|
LastRootPath = fp.RootPath,
|
||||||
|
Status = _volumes.IsPathReachable(fp.RootPath) ? SourceStatus.Online : SourceStatus.Offline,
|
||||||
|
LastSeenUtc = _clock.UtcNow
|
||||||
|
};
|
||||||
|
created.Id = await _store.Sources.UpsertAsync(created, cancellationToken).ConfigureAwait(false);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> LoadRecents()
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ReadAllLines(file)
|
||||||
|
.Where(l => !string.IsNullOrWhiteSpace(l))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed reading recents");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RememberUnc(string root)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var file = Path.Combine(_env.DataDirectory, "recents.txt");
|
||||||
|
var lines = LoadRecents().ToList();
|
||||||
|
lines.RemoveAll(l => l.Equals(root, StringComparison.OrdinalIgnoreCase));
|
||||||
|
lines.Insert(0, root);
|
||||||
|
File.WriteAllLines(file, lines.Take(30));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed writing recents");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
src/Explorer.Application/StorageProviderRegistry.cs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Application;
|
||||||
|
|
||||||
|
public sealed class StorageProviderRegistry
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IStorageProvider> _providers;
|
||||||
|
private readonly HashSet<string> _disabled = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private readonly ILogger<StorageProviderRegistry> _logger;
|
||||||
|
|
||||||
|
public StorageProviderRegistry(IEnumerable<IStorageProvider> providers, ILogger<StorageProviderRegistry> logger)
|
||||||
|
{
|
||||||
|
_providers = providers.ToList();
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<IStorageProvider> Providers => _providers;
|
||||||
|
|
||||||
|
public void SetEnabled(string providerId, bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
_disabled.Remove(providerId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_disabled.Add(providerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IStorageProvider? Find(string path)
|
||||||
|
{
|
||||||
|
foreach (var provider in _providers)
|
||||||
|
{
|
||||||
|
if (_disabled.Contains(provider.Manifest.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (provider.TryMatchRoot(path))
|
||||||
|
{
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Provider {Id} failed matching {Path}", provider.Manifest.Id, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<FileSystemItem>> EnrichAsync(
|
||||||
|
IReadOnlyList<FileSystemItem> items,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (items.Count == 0 || _providers.Count == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups = new Dictionary<IStorageProvider, List<int>>();
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var provider = Find(items[i].FullPath);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.TryGetValue(provider, out var list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
groups[provider] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.Add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var copy = items.ToArray();
|
||||||
|
foreach (var (provider, indexes) in groups)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var paths = indexes.Select(i => copy[i].FullPath).ToList();
|
||||||
|
var states = await provider.GetItemStatesAsync(paths, cancellationToken).ConfigureAwait(false);
|
||||||
|
var byPath = states.ToDictionary(s => s.Path, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var index in indexes)
|
||||||
|
{
|
||||||
|
if (byPath.TryGetValue(copy[index].FullPath, out var state))
|
||||||
|
{
|
||||||
|
copy[index] = CloudPresenceMapper.Apply(copy[index], state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Provider {Id} failed enriching items", provider.Manifest.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasCapability(string path, ProviderCapability capability)
|
||||||
|
{
|
||||||
|
var provider = Find(path);
|
||||||
|
return provider is not null && (provider.GetCapabilities() & capability) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ProviderPlace> GetPlaces()
|
||||||
|
{
|
||||||
|
var places = new List<ProviderPlace>();
|
||||||
|
foreach (var provider in _providers)
|
||||||
|
{
|
||||||
|
if (_disabled.Contains(provider.Manifest.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
places.AddRange(provider.GetPlaces());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Provider {Id} failed listing places", provider.Manifest.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return places;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProviderActionResult> InvokeAsync(
|
||||||
|
ProviderAction action,
|
||||||
|
IReadOnlyList<string> paths,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (paths.Count == 0)
|
||||||
|
{
|
||||||
|
return new ProviderActionResult(ProviderActionStatus.Failed, "Nothing selected.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider = Find(paths[0]);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return new ProviderActionResult(ProviderActionStatus.Unsupported, "No cloud provider is available for this location.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await provider.TryInvokeAsync(new ProviderActionRequest(action, paths), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Provider {Id} failed invoking {Action}", provider.Manifest.Id, action);
|
||||||
|
return new ProviderActionResult(ProviderActionStatus.Failed, "The cloud provider could not complete this action.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProviderItemState?> GetStateAsync(string path, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var provider = Find(path);
|
||||||
|
if (provider is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var states = await provider.GetItemStatesAsync([path], cancellationToken).ConfigureAwait(false);
|
||||||
|
return states.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Provider {Id} failed reading state for {Path}", provider.Manifest.Id, path);
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
|
||||||
|
public static CloudPresence ToPresence(ProviderItemState state)
|
||||||
|
=> new(
|
||||||
|
state.ProviderId,
|
||||||
|
(Domain.CloudAvailability)(int)state.Availability,
|
||||||
|
state.LogicalSizeBytes,
|
||||||
|
state.AllocatedSizeBytes,
|
||||||
|
state.MayHydrateOnRead,
|
||||||
|
state.StatusText);
|
||||||
|
|
||||||
|
public static void ApplyToEntry(IndexEntry entry, CloudPresence? cloud)
|
||||||
|
{
|
||||||
|
if (cloud is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.AllocatedSizeBytes = cloud.AllocatedSizeBytes;
|
||||||
|
entry.CloudAvailability = cloud.Availability;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/Explorer.Contracts/Explorer.Contracts.csproj
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Contracts</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
29
src/Explorer.Contracts/SourceDto.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Contracts;
|
||||||
|
|
||||||
|
public sealed record SourceDto(
|
||||||
|
long Id,
|
||||||
|
string DisplayName,
|
||||||
|
string? RootPath,
|
||||||
|
string Kind,
|
||||||
|
string Status,
|
||||||
|
DateTimeOffset? LastSeenUtc,
|
||||||
|
DateTimeOffset? LastIndexedUtc,
|
||||||
|
long? CapacityBytes,
|
||||||
|
bool IsIndexed);
|
||||||
|
|
||||||
|
public static class SourceMapping
|
||||||
|
{
|
||||||
|
public static SourceDto ToDto(Source source) => new(
|
||||||
|
source.Id,
|
||||||
|
source.DisplayName,
|
||||||
|
source.LastRootPath,
|
||||||
|
source.Kind.ToString(),
|
||||||
|
source.Status.ToString(),
|
||||||
|
source.LastSeenUtc,
|
||||||
|
source.LastIndexedUtc,
|
||||||
|
source.CapacityBytes,
|
||||||
|
source.IsIndexed);
|
||||||
|
}
|
||||||
180
src/Explorer.Domain/Abstractions/IIndexStore.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
namespace Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
public interface IIndexStore
|
||||||
|
{
|
||||||
|
Task OpenAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task CloseAsync();
|
||||||
|
Task<string> QuickCheckAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
ISourceStore Sources { get; }
|
||||||
|
IEntryStore Entries { get; }
|
||||||
|
IExcludeStore Excludes { get; }
|
||||||
|
IScanJobStore ScanJobs { get; }
|
||||||
|
ITransferStore Transfers { get; }
|
||||||
|
ISearchStore Search { get; }
|
||||||
|
IAnalysisStore Analysis { get; }
|
||||||
|
IHistoryStore History { get; }
|
||||||
|
IHashStore Hashes { get; }
|
||||||
|
|
||||||
|
Task RunWriteAsync(Func<IIndexStore, Task> work, CancellationToken cancellationToken = default);
|
||||||
|
Task<T> RunWriteAsync<T>(Func<IIndexStore, Task<T>> work, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISourceStore
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default);
|
||||||
|
Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default);
|
||||||
|
Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IEntryStore
|
||||||
|
{
|
||||||
|
Task<IndexEntry?> GetAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IndexEntry?> GetRootAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IndexEntry?> GetByPathAsync(long sourceId, string pathRel, CancellationToken cancellationToken = default);
|
||||||
|
Task<IndexEntry?> GetByParentNameAsync(long sourceId, long? parentId, string nameNorm, CancellationToken cancellationToken = default);
|
||||||
|
Task<IndexEntry?> GetByFileIdAsync(long sourceId, long fileId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<IndexEntry>> GetChildrenAsync(long sourceId, long? parentId, EntryStatus? status = EntryStatus.Present, CancellationToken cancellationToken = default);
|
||||||
|
Task UpsertBatchAsync(IReadOnlyList<IndexEntry> entries, CancellationToken cancellationToken = default);
|
||||||
|
Task<long> UpsertAsync(IndexEntry entry, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAggregatesAsync(long id, long aggregateSize, int files, int dirs, CancellationToken cancellationToken = default);
|
||||||
|
Task ApplySizeDeltaToAncestorsAsync(long? parentId, long sizeDelta, int fileDelta, int dirDelta, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkMissingAsDeletedAsync(long sourceId, long generation, DateTimeOffset utc, string? pathRelPrefix, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default);
|
||||||
|
Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default);
|
||||||
|
Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default);
|
||||||
|
Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IExcludeStore
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<ExcludeRule>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<long> AddAsync(ExcludeRule rule, CancellationToken cancellationToken = default);
|
||||||
|
Task RemoveAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
Task EnsureDefaultsAsync(IReadOnlyList<ExcludeRule> defaults, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IScanJobStore
|
||||||
|
{
|
||||||
|
Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default);
|
||||||
|
Task InterruptRunningAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default);
|
||||||
|
Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ITransferStore
|
||||||
|
{
|
||||||
|
Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default);
|
||||||
|
Task UpdateAsync(TransferJob job, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISearchStore
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SearchRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? Extension { get; init; }
|
||||||
|
public bool? IsDirectory { get; init; }
|
||||||
|
public long? MinSize { get; init; }
|
||||||
|
public long? MaxSize { get; init; }
|
||||||
|
public DateTimeOffset? CreatedAfter { get; init; }
|
||||||
|
public DateTimeOffset? CreatedBefore { get; init; }
|
||||||
|
public DateTimeOffset? ModifiedAfter { get; init; }
|
||||||
|
public DateTimeOffset? ModifiedBefore { get; init; }
|
||||||
|
public IReadOnlyList<long>? SourceIds { get; init; }
|
||||||
|
public string? PathRelPrefix { get; init; }
|
||||||
|
public bool DirectChildrenOnly { get; init; }
|
||||||
|
public bool IncludeOffline { get; init; } = true;
|
||||||
|
public bool IncludeDeleted { get; init; }
|
||||||
|
public int Skip { get; init; }
|
||||||
|
public int Take { get; init; } = 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAnalysisStore
|
||||||
|
{
|
||||||
|
Task EnsureReadyAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(long? sourceId, long? parentId, int take, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<IndexEntry>> ChildrenBySizeAsync(long parentId, int take, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExtensionUsage
|
||||||
|
{
|
||||||
|
public required string Extension { get; init; }
|
||||||
|
public long TotalSize { get; init; }
|
||||||
|
public long FileCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SourceUsage
|
||||||
|
{
|
||||||
|
public long SourceId { get; init; }
|
||||||
|
public required string DisplayName { get; init; }
|
||||||
|
public long TotalSize { get; init; }
|
||||||
|
public long FileCount { get; init; }
|
||||||
|
public SourceStatus Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IHistoryStore
|
||||||
|
{
|
||||||
|
Task CaptureSourceSnapshotAsync(long sourceId, DateTimeOffset utc, CancellationToken cancellationToken = default);
|
||||||
|
Task CaptureDirectorySnapshotsAsync(long sourceId, DateTimeOffset utc, long minSize, int topN, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<SourceSnapshot>> GetSourceHistoryAsync(long sourceId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SourceSnapshot
|
||||||
|
{
|
||||||
|
public DateTimeOffset CapturedUtc { get; init; }
|
||||||
|
public long TotalSize { get; init; }
|
||||||
|
public long FileCount { get; init; }
|
||||||
|
public long DirCount { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IHashStore
|
||||||
|
{
|
||||||
|
Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default);
|
||||||
|
Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||||
|
Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default);
|
||||||
|
Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default);
|
||||||
|
Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class HashWorkItem
|
||||||
|
{
|
||||||
|
public long EntryId { get; init; }
|
||||||
|
public long SizeBytes { get; init; }
|
||||||
|
public required string State { get; init; }
|
||||||
|
public long SourceId { get; init; }
|
||||||
|
public required string PathRel { get; init; }
|
||||||
|
public string? RootPath { get; init; }
|
||||||
|
public long? FileId { get; init; }
|
||||||
|
public int Attributes { get; init; }
|
||||||
|
public CloudAvailability? CloudAvailability { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DuplicateGroup
|
||||||
|
{
|
||||||
|
public long SizeBytes { get; init; }
|
||||||
|
public byte[]? Hash { get; init; }
|
||||||
|
public IReadOnlyList<IndexEntry> Entries { get; init; } = [];
|
||||||
|
public bool SameFileId { get; init; }
|
||||||
|
}
|
||||||
7
src/Explorer.Domain/Abstractions/IOsClipboard.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
public interface IOsClipboard
|
||||||
|
{
|
||||||
|
void SetFiles(IReadOnlyList<string> paths, bool cut);
|
||||||
|
bool TryGetFiles(out IReadOnlyList<string> paths, out bool cut);
|
||||||
|
}
|
||||||
95
src/Explorer.Domain/Abstractions/Platform.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
namespace Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
public interface IClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SystemClock : IClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IAppEnvironment
|
||||||
|
{
|
||||||
|
string DataDirectory { get; }
|
||||||
|
string DatabasePath { get; }
|
||||||
|
string LogDirectory { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IVolumeService
|
||||||
|
{
|
||||||
|
IReadOnlyList<VolumeFingerprint> EnumerateOnlineVolumes();
|
||||||
|
VolumeFingerprint? Probe(string path);
|
||||||
|
bool IsPathReachable(string path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IFileSystemEnumerator
|
||||||
|
{
|
||||||
|
IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath);
|
||||||
|
FileSystemItem? GetItem(string path);
|
||||||
|
IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUsnJournal
|
||||||
|
{
|
||||||
|
bool TryQuery(string rootPath, out UsnJournalState state, out string? error);
|
||||||
|
IReadOnlyList<UsnRecord> Read(string rootPath, UsnJournalState from, int maxRecords, out UsnJournalState next, out UsnReadStatus status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UsnJournalState
|
||||||
|
{
|
||||||
|
public long JournalId { get; init; }
|
||||||
|
public long NextUsn { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UsnReadStatus
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
Unavailable,
|
||||||
|
JournalReset,
|
||||||
|
AccessDenied,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UsnRecord
|
||||||
|
{
|
||||||
|
public long FileReferenceNumber { get; init; }
|
||||||
|
public long ParentFileReferenceNumber { get; init; }
|
||||||
|
public long Usn { get; init; }
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
public int Reason { get; init; }
|
||||||
|
public int FileAttributes { get; init; }
|
||||||
|
public bool IsDirectory => (FileAttributes & AttributeFlags.Directory) != 0;
|
||||||
|
public bool IsCreate => (Reason & UsnReasons.FileCreate) != 0;
|
||||||
|
public bool IsDelete => (Reason & UsnReasons.FileDelete) != 0;
|
||||||
|
public bool IsRenameOld => (Reason & UsnReasons.RenameOldName) != 0;
|
||||||
|
public bool IsRenameNew => (Reason & UsnReasons.RenameNewName) != 0;
|
||||||
|
public bool IsDataChange => (Reason & (UsnReasons.DataOverwrite | UsnReasons.DataExtend | UsnReasons.DataTruncation | UsnReasons.BasicInfoChange)) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UsnReasons
|
||||||
|
{
|
||||||
|
public const int DataOverwrite = 0x00000001;
|
||||||
|
public const int DataExtend = 0x00000002;
|
||||||
|
public const int DataTruncation = 0x00000004;
|
||||||
|
public const int FileCreate = 0x00000100;
|
||||||
|
public const int FileDelete = 0x00000200;
|
||||||
|
public const int RenameOldName = 0x00001000;
|
||||||
|
public const int RenameNewName = 0x00002000;
|
||||||
|
public const int BasicInfoChange = 0x00008000;
|
||||||
|
public const int Close = unchecked((int)0x80000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IShellFileOperations
|
||||||
|
{
|
||||||
|
void Open(string path);
|
||||||
|
bool DeleteToRecycleBin(IReadOnlyList<string> paths, out string? error);
|
||||||
|
bool CopyFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
|
||||||
|
bool MoveFileWithProgress(string source, string destination, bool overwrite, IProgress<long>? progress, CancellationToken cancellationToken, out string? error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IIconService
|
||||||
|
{
|
||||||
|
byte[]? GetIconPng(string path, bool isDirectory, int size);
|
||||||
|
}
|
||||||
21
src/Explorer.Domain/AppConstants.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
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 DefaultTombstoneRetentionDays = 30;
|
||||||
|
public const int ScanBatchSize = 3000;
|
||||||
|
public const int SearchPageSize = 500;
|
||||||
|
public const int SearchHardLimit = 10_000;
|
||||||
|
public const int AnalysisTopN = 100;
|
||||||
|
public const int AnalysisTreeChildTake = 500;
|
||||||
|
public const long DirectoryHistoryThresholdBytes = 1L * 1024 * 1024 * 1024;
|
||||||
|
public const int DirectoryHistoryTopN = 200;
|
||||||
|
public const int PartialHashBytes = 64 * 1024;
|
||||||
|
public const int LocalScanParallelism = 4;
|
||||||
|
public const int NetworkScanParallelism = 1;
|
||||||
|
public const int ProgressHzMilliseconds = 100;
|
||||||
|
}
|
||||||
16
src/Explorer.Domain/DefaultExcludes.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public static class DefaultExcludes
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ExcludeRule> Create() =>
|
||||||
|
[
|
||||||
|
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\Windows", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\System Volume Information", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.Glob, Pattern = "$Recycle.Bin", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.PathPrefix, Pattern = @"C:\ProgramData\Microsoft", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.Glob, Pattern = "node_modules", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.Glob, Pattern = ".git", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.Glob, Pattern = "Temp", Scope = "global" },
|
||||||
|
new() { Kind = ExcludeKind.Glob, Pattern = "Cache", Scope = "global" }
|
||||||
|
];
|
||||||
|
}
|
||||||
163
src/Explorer.Domain/Entities.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public sealed class Source
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public required string StableKey { get; set; }
|
||||||
|
public SourceKind Kind { get; set; }
|
||||||
|
public required string DisplayName { get; set; }
|
||||||
|
public string? VolumeGuid { get; set; }
|
||||||
|
public long? VolumeSerial { get; set; }
|
||||||
|
public string? Filesystem { get; set; }
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public long? CapacityBytes { get; set; }
|
||||||
|
public string? DeviceInstanceId { get; set; }
|
||||||
|
public string? LastRootPath { get; set; }
|
||||||
|
public SourceStatus Status { get; set; } = SourceStatus.Offline;
|
||||||
|
public DateTimeOffset? LastSeenUtc { get; set; }
|
||||||
|
public DateTimeOffset? LastIndexedUtc { get; set; }
|
||||||
|
public long? UsnJournalId { get; set; }
|
||||||
|
public long? UsnNext { get; set; }
|
||||||
|
public long ScanGeneration { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public bool IsIndexed => LastIndexedUtc is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VolumeFingerprint
|
||||||
|
{
|
||||||
|
public SourceKind Kind { get; init; }
|
||||||
|
public string? VolumeGuid { get; init; }
|
||||||
|
public long? VolumeSerial { get; init; }
|
||||||
|
public string? Filesystem { get; init; }
|
||||||
|
public string? Label { get; init; }
|
||||||
|
public long? CapacityBytes { get; init; }
|
||||||
|
public string? DeviceInstanceId { get; init; }
|
||||||
|
public required string RootPath { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IndexEntry
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long SourceId { get; set; }
|
||||||
|
public long? ParentId { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public required string NameNorm { get; set; }
|
||||||
|
public string? Extension { get; set; }
|
||||||
|
public bool IsDirectory { get; set; }
|
||||||
|
public long SizeBytes { get; set; }
|
||||||
|
public long AggregateSize { get; set; }
|
||||||
|
public int ChildFileCount { get; set; }
|
||||||
|
public int ChildDirCount { get; set; }
|
||||||
|
public DateTimeOffset? CreatedUtc { get; set; }
|
||||||
|
public DateTimeOffset? ModifiedUtc { get; set; }
|
||||||
|
public DateTimeOffset LastSeenUtc { get; set; }
|
||||||
|
public DateTimeOffset? LastIndexedUtc { get; set; }
|
||||||
|
public int Attributes { get; set; }
|
||||||
|
public long? FileId { get; set; }
|
||||||
|
public long? ParentFileId { get; set; }
|
||||||
|
public int ReparseTag { get; set; }
|
||||||
|
public EntryStatus Status { get; set; } = EntryStatus.Present;
|
||||||
|
public DateTimeOffset? DeletedUtc { get; set; }
|
||||||
|
public required string PathRel { get; set; }
|
||||||
|
public byte[]? ContentHash { get; set; }
|
||||||
|
public HashState HashState { get; set; }
|
||||||
|
public long ScanGeneration { get; set; }
|
||||||
|
public long? AllocatedSizeBytes { get; set; }
|
||||||
|
public CloudAvailability? CloudAvailability { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExcludeRule
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public string Scope { get; set; } = "global";
|
||||||
|
public long? SourceId { get; set; }
|
||||||
|
public ExcludeKind Kind { get; set; }
|
||||||
|
public required string Pattern { get; set; }
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScanJob
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long SourceId { get; set; }
|
||||||
|
public ScanKind Kind { get; set; }
|
||||||
|
public ScanJobStatus Status { get; set; }
|
||||||
|
public DateTimeOffset? StartedUtc { get; set; }
|
||||||
|
public DateTimeOffset? FinishedUtc { get; set; }
|
||||||
|
public long FilesSeen { get; set; }
|
||||||
|
public long DirsSeen { get; set; }
|
||||||
|
public long BytesSeen { get; set; }
|
||||||
|
public string? ResumePath { get; set; }
|
||||||
|
public int ErrorCount { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public string? FolderPathRel { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScanError
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long JobId { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public string? Kind { get; set; }
|
||||||
|
public string? Message { get; set; }
|
||||||
|
public DateTimeOffset Utc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TransferJob
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public TransferOp Op { get; set; }
|
||||||
|
public required string SourcePath { get; set; }
|
||||||
|
public string? DestinationPath { get; set; }
|
||||||
|
public TransferStatus Status { get; set; }
|
||||||
|
public long? BytesTotal { get; set; }
|
||||||
|
public long BytesDone { get; set; }
|
||||||
|
public DateTimeOffset CreatedUtc { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public IReadOnlyList<string> AdditionalSources { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FileSystemItem
|
||||||
|
{
|
||||||
|
public required string FullPath { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public bool IsDirectory { get; init; }
|
||||||
|
public long SizeBytes { get; init; }
|
||||||
|
public DateTimeOffset? CreatedUtc { get; init; }
|
||||||
|
public DateTimeOffset? ModifiedUtc { get; init; }
|
||||||
|
public int Attributes { get; init; }
|
||||||
|
public long? FileId { get; init; }
|
||||||
|
public int ReparseTag { get; init; }
|
||||||
|
public long? AllocatedSizeBytes { get; init; }
|
||||||
|
public CloudPresence? Cloud { get; init; }
|
||||||
|
public bool IsReparsePoint => (Attributes & AttributeFlags.ReparsePoint) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CloudPresence(
|
||||||
|
string? ProviderId,
|
||||||
|
CloudAvailability Availability,
|
||||||
|
long LogicalSizeBytes,
|
||||||
|
long? AllocatedSizeBytes,
|
||||||
|
bool MayHydrateOnRead,
|
||||||
|
string? StatusText = null);
|
||||||
|
|
||||||
|
public sealed record ScanProgress
|
||||||
|
{
|
||||||
|
public long JobId { get; init; }
|
||||||
|
public long SourceId { get; init; }
|
||||||
|
public required string CurrentPath { get; init; }
|
||||||
|
public long FilesSeen { get; init; }
|
||||||
|
public long DirsSeen { get; init; }
|
||||||
|
public long BytesSeen { get; init; }
|
||||||
|
public int ErrorCount { get; init; }
|
||||||
|
public ScanJobStatus Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FolderListing
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public bool IsOffline { get; init; }
|
||||||
|
public IReadOnlyList<FileSystemItem> Items { get; init; } = [];
|
||||||
|
public string? Error { get; init; }
|
||||||
|
}
|
||||||
138
src/Explorer.Domain/Enums.cs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public enum SourceKind
|
||||||
|
{
|
||||||
|
NtfsLocal,
|
||||||
|
Removable,
|
||||||
|
Smb,
|
||||||
|
Nfs,
|
||||||
|
Cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SourceStatus
|
||||||
|
{
|
||||||
|
Online,
|
||||||
|
Offline,
|
||||||
|
Scanning,
|
||||||
|
Stale,
|
||||||
|
Error
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum EntryStatus
|
||||||
|
{
|
||||||
|
Present = 0,
|
||||||
|
Offline = 1,
|
||||||
|
Deleted = 2,
|
||||||
|
Unknown = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HashState
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Partial = 1,
|
||||||
|
Full = 2,
|
||||||
|
Skipped = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CloudAvailability
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
OnlineOnly = 1,
|
||||||
|
LocallyAvailable = 2,
|
||||||
|
Pinned = 3,
|
||||||
|
Syncing = 4,
|
||||||
|
Error = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExcludeKind
|
||||||
|
{
|
||||||
|
PathPrefix,
|
||||||
|
Glob,
|
||||||
|
Extension,
|
||||||
|
Attribute
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ScanKind
|
||||||
|
{
|
||||||
|
Full,
|
||||||
|
Incremental,
|
||||||
|
Folder,
|
||||||
|
Rebuild
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ScanJobStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Cancelled,
|
||||||
|
Failed,
|
||||||
|
Done,
|
||||||
|
Interrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TransferOp
|
||||||
|
{
|
||||||
|
Copy,
|
||||||
|
Move,
|
||||||
|
Delete,
|
||||||
|
Rename,
|
||||||
|
NewFolder
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TransferStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Running,
|
||||||
|
Cancelling,
|
||||||
|
Cancelled,
|
||||||
|
Failed,
|
||||||
|
Done
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FolderViewMode
|
||||||
|
{
|
||||||
|
Details,
|
||||||
|
List,
|
||||||
|
Preview
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SearchScopeKind
|
||||||
|
{
|
||||||
|
CurrentFolder,
|
||||||
|
CurrentTree,
|
||||||
|
Selected,
|
||||||
|
Sources,
|
||||||
|
AllKnown,
|
||||||
|
OfflineMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum IndexFreshness
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Current,
|
||||||
|
Scanning,
|
||||||
|
Stale,
|
||||||
|
Offline,
|
||||||
|
NotIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AttributeFlags
|
||||||
|
{
|
||||||
|
public const int ReadOnly = 1;
|
||||||
|
public const int Hidden = 2;
|
||||||
|
public const int System = 4;
|
||||||
|
public const int Directory = 16;
|
||||||
|
public const int Archive = 32;
|
||||||
|
public const int ReparsePoint = 1024;
|
||||||
|
public const int Offline = 0x1000;
|
||||||
|
public const int RecallOnOpen = 0x40000;
|
||||||
|
public const int Pinned = 0x80000;
|
||||||
|
public const int Unpinned = 0x100000;
|
||||||
|
public const int RecallOnDataAccess = 0x400000;
|
||||||
|
|
||||||
|
public static bool MayHydrateOnRead(int attributes)
|
||||||
|
=> (attributes & (RecallOnDataAccess | Offline)) != 0;
|
||||||
|
|
||||||
|
public static bool IsPinned(int attributes)
|
||||||
|
=> (attributes & Pinned) != 0 && (attributes & Unpinned) == 0;
|
||||||
|
}
|
||||||
89
src/Explorer.Domain/ExcludeEvaluator.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public sealed class ExcludeEvaluator
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<ExcludeRule> _rules;
|
||||||
|
private readonly bool _skipHidden;
|
||||||
|
private readonly bool _skipSystem;
|
||||||
|
|
||||||
|
public ExcludeEvaluator(IReadOnlyList<ExcludeRule> rules, bool skipHidden = true, bool skipSystem = true)
|
||||||
|
{
|
||||||
|
_rules = rules.Where(r => r.Enabled).ToList();
|
||||||
|
_skipHidden = skipHidden;
|
||||||
|
_skipSystem = skipSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool ShouldExclude(string fullPath, string name, bool isDirectory, int attributes, long? sourceId)
|
||||||
|
{
|
||||||
|
if (_skipHidden && (attributes & AttributeFlags.Hidden) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_skipSystem && (attributes & AttributeFlags.System) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var rule in _rules)
|
||||||
|
{
|
||||||
|
if (rule.SourceId is not null && sourceId is not null && rule.SourceId != sourceId)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (rule.Kind)
|
||||||
|
{
|
||||||
|
case ExcludeKind.PathPrefix:
|
||||||
|
if (fullPath.StartsWith(rule.Pattern, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ExcludeKind.Extension:
|
||||||
|
if (!isDirectory)
|
||||||
|
{
|
||||||
|
var ext = NameNormalizer.Extension(name);
|
||||||
|
if (string.Equals(ext, NameNormalizer.Normalize(rule.Pattern.TrimStart('.')), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ExcludeKind.Glob:
|
||||||
|
if (GlobMatch(name, rule.Pattern) || GlobMatch(fullPath, rule.Pattern))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ExcludeKind.Attribute:
|
||||||
|
if (int.TryParse(rule.Pattern, out var flag) && (attributes & flag) != 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GlobMatch(string text, string pattern)
|
||||||
|
{
|
||||||
|
if (pattern is "*" or "*.*")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rx = "^" + Regex.Escape(pattern)
|
||||||
|
.Replace("\\*", ".*", StringComparison.Ordinal)
|
||||||
|
.Replace("\\?", ".", StringComparison.Ordinal) + "$";
|
||||||
|
return Regex.IsMatch(text, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/Explorer.Domain/Explorer.Domain.csproj
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Domain</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
34
src/Explorer.Domain/NameNormalizer.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public static class NameNormalizer
|
||||||
|
{
|
||||||
|
public static string Normalize(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = name.Normalize(NormalizationForm.FormC);
|
||||||
|
return form.ToLower(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Extension(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = name.LastIndexOf('.');
|
||||||
|
if (idx <= 0 || idx == name.Length - 1)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Normalize(name[(idx + 1)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/Explorer.Domain/PathRules.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public static class PathRules
|
||||||
|
{
|
||||||
|
public const string ExtendedPrefix = @"\\?\";
|
||||||
|
public const string ExtendedUncPrefix = @"\\?\UNC\";
|
||||||
|
|
||||||
|
public static string ToExtended(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return ExtendedUncPrefix + path[2..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtendedPrefix + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string FromExtended(string path)
|
||||||
|
{
|
||||||
|
if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return @"\\" + path[ExtendedUncPrefix.Length..];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.StartsWith(ExtendedPrefix, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return path[ExtendedPrefix.Length..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsUnc(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path);
|
||||||
|
return p.StartsWith(@"\\", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string NormalizeDirectorySeparators(string path)
|
||||||
|
=> path.Replace('/', '\\');
|
||||||
|
|
||||||
|
public static string Combine(string root, string relative)
|
||||||
|
{
|
||||||
|
root = FromExtended(root).TrimEnd('\\');
|
||||||
|
relative = relative.Replace('/', '\\').TrimStart('\\');
|
||||||
|
if (string.IsNullOrEmpty(relative))
|
||||||
|
{
|
||||||
|
return EnsureDirectoryTrailingSlashIfRoot(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root + "\\" + relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string MakeRelative(string root, string fullPath)
|
||||||
|
{
|
||||||
|
var r = FromExtended(root).TrimEnd('\\');
|
||||||
|
var f = FromExtended(fullPath).TrimEnd('\\');
|
||||||
|
if (f.Equals(r, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.StartsWith(r + "\\", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return f[(r.Length + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CanonicalUncRoot(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path).TrimEnd('\\');
|
||||||
|
if (!p.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = p[2..].Split('\\', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length < 2)
|
||||||
|
{
|
||||||
|
return @"\\" + p[2..].ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
return @"\\" + parts[0].ToLowerInvariant() + "\\" + parts[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Parent(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path).TrimEnd('\\');
|
||||||
|
if (p.Length >= 2 && p[1] == ':' && p.Length <= 3)
|
||||||
|
{
|
||||||
|
return p.Length == 2 ? p + "\\" : p + (p.EndsWith('\\') ? "" : "\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsUnc(p))
|
||||||
|
{
|
||||||
|
var root = CanonicalUncRoot(p);
|
||||||
|
if (p.Equals(root, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = p.LastIndexOf('\\');
|
||||||
|
if (idx <= 0)
|
||||||
|
{
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idx == 2 && p[1] == ':')
|
||||||
|
{
|
||||||
|
return p[..3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return p[..idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsDriveRoot(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path).TrimEnd('\\');
|
||||||
|
return p.Length == 2 && p[1] == ':';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EnsureDirectoryTrailingSlashIfRoot(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path);
|
||||||
|
if (p.Length == 2 && p[1] == ':')
|
||||||
|
{
|
||||||
|
return p + "\\";
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetFileName(string path)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path).TrimEnd('\\');
|
||||||
|
var idx = p.LastIndexOf('\\');
|
||||||
|
return idx < 0 ? p : p[(idx + 1)..];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string JoinDisplay(string? root, string pathRel)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(root))
|
||||||
|
{
|
||||||
|
return pathRel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(pathRel) ? EnsureDirectoryTrailingSlashIfRoot(root) : Combine(root, pathRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ShortenDisplay(string path, int maxChars = 64)
|
||||||
|
{
|
||||||
|
var p = FromExtended(path);
|
||||||
|
if (p.Length <= maxChars)
|
||||||
|
{
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
var unc = IsUnc(p);
|
||||||
|
var parts = p.TrimEnd('\\').Split('\\', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length <= 2)
|
||||||
|
{
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
string prefix;
|
||||||
|
var start = 0;
|
||||||
|
if (unc)
|
||||||
|
{
|
||||||
|
prefix = @"\\" + parts[0] + "\\" + parts[1];
|
||||||
|
start = 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
prefix = parts.Length > 2 ? parts[0] + "\\" + parts[1] : parts[0];
|
||||||
|
start = parts.Length > 2 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaf = parts[^1];
|
||||||
|
var parent = parts.Length - 1 > start ? parts[^2] + "\\" + leaf : leaf;
|
||||||
|
var candidate = prefix + @"\…\" + parent;
|
||||||
|
if (candidate.Length <= maxChars)
|
||||||
|
{
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate = prefix + @"\…\" + leaf;
|
||||||
|
return candidate.Length <= maxChars ? candidate : prefix + @"\…";
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/Explorer.Domain/ReparsePolicy.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public static class ReparsePolicy
|
||||||
|
{
|
||||||
|
public const int IoReparseTagMountPoint = unchecked((int)0xA0000003);
|
||||||
|
public const int IoReparseTagSymlink = unchecked((int)0xA000000C);
|
||||||
|
|
||||||
|
public static bool ShouldRecurseIntoDirectory(FileSystemItem item)
|
||||||
|
{
|
||||||
|
if (!item.IsDirectory)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.IsReparsePoint)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsMountPoint(int reparseTag)
|
||||||
|
=> reparseTag == IoReparseTagMountPoint;
|
||||||
|
}
|
||||||
82
src/Explorer.Domain/VolumeIdentityMatcher.cs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
namespace Explorer.Domain;
|
||||||
|
|
||||||
|
public static class VolumeIdentityMatcher
|
||||||
|
{
|
||||||
|
public sealed record MatchResult(Source? Source, MatchStrength Strength, bool Ambiguous);
|
||||||
|
|
||||||
|
public enum MatchStrength
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Weak,
|
||||||
|
SerialCapacity,
|
||||||
|
Guid
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MatchResult Match(VolumeFingerprint fingerprint, IReadOnlyList<Source> known)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(fingerprint.VolumeGuid))
|
||||||
|
{
|
||||||
|
var guidHits = known
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s.VolumeGuid)
|
||||||
|
&& string.Equals(s.VolumeGuid, fingerprint.VolumeGuid, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (guidHits.Count == 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(guidHits[0], MatchStrength.Guid, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guidHits.Count > 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(guidHits[0], MatchStrength.Guid, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fingerprint.VolumeSerial is > 0)
|
||||||
|
{
|
||||||
|
var serialHits = known.Where(s =>
|
||||||
|
s.VolumeSerial == fingerprint.VolumeSerial
|
||||||
|
&& string.Equals(s.Filesystem, fingerprint.Filesystem, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& s.CapacityBytes == fingerprint.CapacityBytes)
|
||||||
|
.ToList();
|
||||||
|
if (serialHits.Count == 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(serialHits[0], MatchStrength.SerialCapacity, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialHits.Count == 0 && !string.IsNullOrEmpty(fingerprint.Label))
|
||||||
|
{
|
||||||
|
serialHits = known.Where(s =>
|
||||||
|
s.VolumeSerial == fingerprint.VolumeSerial
|
||||||
|
&& string.Equals(s.Label, fingerprint.Label, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& s.CapacityBytes == fingerprint.CapacityBytes)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialHits.Count == 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(serialHits[0], MatchStrength.Weak, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serialHits.Count > 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(serialHits[0], MatchStrength.Weak, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fingerprint.Kind is SourceKind.Smb or SourceKind.Nfs)
|
||||||
|
{
|
||||||
|
var unc = PathRules.CanonicalUncRoot(fingerprint.RootPath);
|
||||||
|
var uncHits = known.Where(s =>
|
||||||
|
s.Kind == fingerprint.Kind
|
||||||
|
&& s.LastRootPath is not null
|
||||||
|
&& string.Equals(PathRules.CanonicalUncRoot(s.LastRootPath), unc, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (uncHits.Count == 1)
|
||||||
|
{
|
||||||
|
return new MatchResult(uncHits[0], MatchStrength.SerialCapacity, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MatchResult(null, MatchStrength.None, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Explorer.FileOperations/Explorer.FileOperations.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.FileOperations</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
70
src/Explorer.FileOperations/FileOperationService.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
|
||||||
|
namespace Explorer.FileOperations;
|
||||||
|
|
||||||
|
public sealed class FileOperationService
|
||||||
|
{
|
||||||
|
private readonly TransferQueue _queue;
|
||||||
|
private readonly IShellFileOperations _shell;
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
|
||||||
|
public FileOperationService(TransferQueue queue, IShellFileOperations shell, IFileSystemEnumerator enumerator)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
_shell = shell;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Open(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
_shell.Open(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
|
=> _queue.EnqueueCopyAsync(sources, destinationDirectory, cancellationToken);
|
||||||
|
|
||||||
|
public Task MoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
|
=> _queue.EnqueueMoveAsync(sources, destinationDirectory, cancellationToken);
|
||||||
|
|
||||||
|
public Task DeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||||
|
=> _queue.EnqueueDeleteAsync(paths, cancellationToken);
|
||||||
|
|
||||||
|
public void Rename(string path, string newName)
|
||||||
|
{
|
||||||
|
var parent = PathRules.Parent(path);
|
||||||
|
var dest = Path.Combine(parent, newName);
|
||||||
|
var src = PathRules.ToExtended(path);
|
||||||
|
var dst = PathRules.ToExtended(dest);
|
||||||
|
if (Directory.Exists(src))
|
||||||
|
{
|
||||||
|
Directory.Move(src, dst);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
File.Move(src, dst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string NewFolder(string parent)
|
||||||
|
{
|
||||||
|
var baseName = "New folder";
|
||||||
|
var name = baseName;
|
||||||
|
var i = 2;
|
||||||
|
var dest = Path.Combine(parent, name);
|
||||||
|
while (Directory.Exists(PathRules.ToExtended(dest)) || File.Exists(PathRules.ToExtended(dest)))
|
||||||
|
{
|
||||||
|
name = $"{baseName} ({i++})";
|
||||||
|
dest = Path.Combine(parent, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(PathRules.ToExtended(dest));
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileSystemItem? GetItem(string path) => _enumerator.GetItem(path);
|
||||||
|
}
|
||||||
304
src/Explorer.FileOperations/TransferQueue.cs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.FileOperations;
|
||||||
|
|
||||||
|
public sealed class TransferQueue : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IShellFileOperations _shell;
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly ILogger<TransferQueue> _logger;
|
||||||
|
private readonly Channel<Work> _channel = Channel.CreateUnbounded<Work>();
|
||||||
|
private readonly List<TransferJob> _jobs = [];
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public event EventHandler? Changed;
|
||||||
|
public event EventHandler<TransferJob>? JobFinished;
|
||||||
|
|
||||||
|
public TransferQueue(
|
||||||
|
IShellFileOperations shell,
|
||||||
|
IFileSystemEnumerator enumerator,
|
||||||
|
IIndexStore store,
|
||||||
|
ILogger<TransferQueue> logger)
|
||||||
|
{
|
||||||
|
_shell = shell;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_store = store;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<TransferJob> Snapshot()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _jobs.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueCopyAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var src in sources)
|
||||||
|
{
|
||||||
|
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
||||||
|
await EnqueueAsync(new TransferJob
|
||||||
|
{
|
||||||
|
Op = TransferOp.Copy,
|
||||||
|
SourcePath = src,
|
||||||
|
DestinationPath = dest,
|
||||||
|
Status = TransferStatus.Queued,
|
||||||
|
CreatedUtc = DateTimeOffset.UtcNow
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueMoveAsync(IReadOnlyList<string> sources, string destinationDirectory, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var src in sources)
|
||||||
|
{
|
||||||
|
var dest = Path.Combine(destinationDirectory, PathRules.GetFileName(src));
|
||||||
|
await EnqueueAsync(new TransferJob
|
||||||
|
{
|
||||||
|
Op = TransferOp.Move,
|
||||||
|
SourcePath = src,
|
||||||
|
DestinationPath = dest,
|
||||||
|
Status = TransferStatus.Queued,
|
||||||
|
CreatedUtc = DateTimeOffset.UtcNow
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnqueueDeleteAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnqueueAsync(new TransferJob
|
||||||
|
{
|
||||||
|
Op = TransferOp.Delete,
|
||||||
|
SourcePath = string.Join("|", paths),
|
||||||
|
Status = TransferStatus.Queued,
|
||||||
|
CreatedUtc = DateTimeOffset.UtcNow,
|
||||||
|
AdditionalSources = paths.ToList()
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel(long jobId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
var job = _jobs.FirstOrDefault(j => j.Id == jobId);
|
||||||
|
if (job is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.Status is TransferStatus.Queued or TransferStatus.Running)
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Cancelling;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_jobs.Remove(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dismiss(long jobId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_jobs.RemoveAll(j => j.Id == jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnqueueAsync(TransferJob job, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
job.Id = await _store.Transfers.InsertAsync(job, cancellationToken).ConfigureAwait(false);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_jobs.Insert(0, job);
|
||||||
|
}
|
||||||
|
|
||||||
|
_channel.Writer.TryWrite(new Work(job, new CancellationTokenSource()));
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await foreach (var work in _channel.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var job = work.Job;
|
||||||
|
if (job.Status == TransferStatus.Cancelling)
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Cancelled;
|
||||||
|
await Persist(job).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = TransferStatus.Running;
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (job.Op)
|
||||||
|
{
|
||||||
|
case TransferOp.Copy:
|
||||||
|
await CopyOrMove(job, move: false, stoppingToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case TransferOp.Move:
|
||||||
|
await CopyOrMove(job, move: true, stoppingToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case TransferOp.Delete:
|
||||||
|
Delete(job);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.Status == TransferStatus.Cancelling)
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Cancelled;
|
||||||
|
}
|
||||||
|
else if (job.Status != TransferStatus.Failed && !string.IsNullOrEmpty(job.Error))
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Failed;
|
||||||
|
}
|
||||||
|
else if (job.Status != TransferStatus.Failed)
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Transfer failed {Op} {Src}", job.Op, job.SourcePath);
|
||||||
|
job.Status = TransferStatus.Failed;
|
||||||
|
job.Error = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Persist(job).ConfigureAwait(false);
|
||||||
|
if (job.Status is TransferStatus.Done or TransferStatus.Cancelled)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_jobs.Remove(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
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 != TransferStatus.Failed && job.Status != TransferStatus.Cancelling)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(PathRules.ToExtended(src), recursive: true); } catch { /* remaining files */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.BytesTotal = item.SizeBytes;
|
||||||
|
var progress = new Progress<long>(b =>
|
||||||
|
{
|
||||||
|
job.BytesDone = b;
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
});
|
||||||
|
var ok = move
|
||||||
|
? _shell.MoveFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out var error)
|
||||||
|
: _shell.CopyFileWithProgress(src, dst, overwrite: false, progress, stoppingToken, out error);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
job.Status = error == "Cancelled" ? TransferStatus.Cancelling : TransferStatus.Failed;
|
||||||
|
job.Error = error;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.BytesDone = job.BytesTotal ?? job.BytesDone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CopyDirectory(string src, string dst, bool move, TransferJob job, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(PathRules.ToExtended(dst));
|
||||||
|
var stack = new Stack<(string From, string To)>();
|
||||||
|
stack.Push((src, dst));
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
stoppingToken.ThrowIfCancellationRequested();
|
||||||
|
if (job.Status == TransferStatus.Cancelling)
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.BytesTotal = (job.BytesTotal ?? 0) + child.SizeBytes;
|
||||||
|
var ok = move
|
||||||
|
? _shell.MoveFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out var err)
|
||||||
|
: _shell.CopyFileWithProgress(child.FullPath, childDest, false, null, stoppingToken, out err);
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
job.BytesDone += child.SizeBytes;
|
||||||
|
}
|
||||||
|
else if (err != "Cancelled")
|
||||||
|
{
|
||||||
|
job.Error = err;
|
||||||
|
}
|
||||||
|
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.CompletedTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Delete(TransferJob job)
|
||||||
|
{
|
||||||
|
var paths = job.AdditionalSources.Count > 0
|
||||||
|
? job.AdditionalSources
|
||||||
|
: job.SourcePath.Split('|', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (!_shell.DeleteToRecycleBin(paths, out var error))
|
||||||
|
{
|
||||||
|
job.Status = TransferStatus.Failed;
|
||||||
|
job.Error = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task Persist(TransferJob job) => _store.Transfers.UpdateAsync(job);
|
||||||
|
|
||||||
|
private readonly record struct Work(TransferJob Job, CancellationTokenSource Cts);
|
||||||
|
}
|
||||||
108
src/Explorer.Indexing/DirectoryWatcherHub.cs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
|
public sealed class DirectoryWatcherHub : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly IndexingCoordinator _indexing;
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly ILogger<DirectoryWatcherHub> _logger;
|
||||||
|
private readonly Dictionary<long, FileSystemWatcher> _watchers = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public DirectoryWatcherHub(
|
||||||
|
IVolumeService volumes,
|
||||||
|
IndexingCoordinator indexing,
|
||||||
|
IIndexStore store,
|
||||||
|
ILogger<DirectoryWatcherHub> logger)
|
||||||
|
{
|
||||||
|
_volumes = volumes;
|
||||||
|
_indexing = indexing;
|
||||||
|
_store = store;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sources = await _store.Sources.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
var online = source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath);
|
||||||
|
if (online && source.IsIndexed)
|
||||||
|
{
|
||||||
|
EnsureWatcher(source);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RemoveWatcher(source.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureWatcher(Source source)
|
||||||
|
{
|
||||||
|
if (_watchers.ContainsKey(source.Id) || source.LastRootPath is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var watcher = new FileSystemWatcher(source.LastRootPath)
|
||||||
|
{
|
||||||
|
IncludeSubdirectories = true,
|
||||||
|
NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName | NotifyFilters.Size | NotifyFilters.LastWrite | NotifyFilters.Attributes,
|
||||||
|
InternalBufferSize = 64 * 1024
|
||||||
|
};
|
||||||
|
var sourceId = source.Id;
|
||||||
|
void OnChange(object s, FileSystemEventArgs e)
|
||||||
|
{
|
||||||
|
var rel = PathRules.MakeRelative(source.LastRootPath!, PathRules.Parent(e.FullPath));
|
||||||
|
_indexing.EnqueueReconcile(sourceId, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher.Created += OnChange;
|
||||||
|
watcher.Changed += OnChange;
|
||||||
|
watcher.Deleted += OnChange;
|
||||||
|
watcher.Renamed += OnChange;
|
||||||
|
watcher.Error += (_, args) =>
|
||||||
|
{
|
||||||
|
_logger.LogWarning(args.GetException(), "Watcher overflow for source {Id}", sourceId);
|
||||||
|
_ = _store.Sources.UpdateStatusAsync(sourceId, SourceStatus.Stale, "Filesystem watcher overflow");
|
||||||
|
};
|
||||||
|
watcher.EnableRaisingEvents = true;
|
||||||
|
_watchers[source.Id] = watcher;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Watcher not available for {Path}", source.LastRootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveWatcher(long sourceId)
|
||||||
|
{
|
||||||
|
if (_watchers.Remove(sourceId, out var watcher))
|
||||||
|
{
|
||||||
|
watcher.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
foreach (var w in _watchers.Values)
|
||||||
|
{
|
||||||
|
w.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchers.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Explorer.Indexing/Explorer.Indexing.csproj
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Indexing</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
309
src/Explorer.Indexing/FilesystemScanner.cs
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
|
public sealed class FilesystemScanner
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
private readonly ILogger<FilesystemScanner> _logger;
|
||||||
|
|
||||||
|
public FilesystemScanner(
|
||||||
|
IIndexStore store,
|
||||||
|
IFileSystemEnumerator enumerator,
|
||||||
|
StorageProviderRegistry providers,
|
||||||
|
ILogger<FilesystemScanner> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_providers = providers;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ScanJob> ScanAsync(
|
||||||
|
Source source,
|
||||||
|
ScanKind kind,
|
||||||
|
string? folderPathRel,
|
||||||
|
IProgress<ScanProgress>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var generation = source.ScanGeneration + 1;
|
||||||
|
source.ScanGeneration = generation;
|
||||||
|
var job = new ScanJob
|
||||||
|
{
|
||||||
|
SourceId = source.Id,
|
||||||
|
Kind = kind,
|
||||||
|
Status = ScanJobStatus.Running,
|
||||||
|
StartedUtc = now,
|
||||||
|
FolderPathRel = folderPathRel
|
||||||
|
};
|
||||||
|
var pending = new List<IndexEntry>(AppConstants.ScanBatchSize);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _store.Sources.UpsertAsync(source, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Scanning, null, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _store.ScanJobs.InsertAsync(job, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var excludes = await _store.Excludes.GetAllAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var evaluator = new ExcludeEvaluator(excludes);
|
||||||
|
var root = source.LastRootPath ?? throw new InvalidOperationException("Source has no root path.");
|
||||||
|
var startRel = folderPathRel ?? "";
|
||||||
|
var startPath = PathRules.Combine(root, startRel);
|
||||||
|
var lastProgress = DateTime.UtcNow;
|
||||||
|
var visitedDirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var startItem = _enumerator.GetItem(startPath);
|
||||||
|
long? startParentId = null;
|
||||||
|
if (!string.IsNullOrEmpty(startRel))
|
||||||
|
{
|
||||||
|
var parentRel = PathRules.MakeRelative(root, PathRules.Parent(startPath));
|
||||||
|
var parent = await _store.Entries.GetByPathAsync(source.Id, parentRel, cancellationToken).ConfigureAwait(false);
|
||||||
|
startParentId = parent?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startEntry = CreateEntry(source, startParentId, startItem ?? DummyDir(startPath, startRel, root), startRel, now, generation);
|
||||||
|
startEntry.IsDirectory = true;
|
||||||
|
await _store.RunWriteAsync(async s =>
|
||||||
|
{
|
||||||
|
startEntry.Id = await s.Entries.UpsertAsync(startEntry, cancellationToken).ConfigureAwait(false);
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var stack = new Stack<Frame>();
|
||||||
|
stack.Push(new Frame
|
||||||
|
{
|
||||||
|
Path = startPath,
|
||||||
|
PathRel = startRel,
|
||||||
|
Id = startEntry.Id,
|
||||||
|
ParentId = startParentId
|
||||||
|
});
|
||||||
|
|
||||||
|
while (stack.Count > 0)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var frame = stack.Peek();
|
||||||
|
if (!frame.Expanded)
|
||||||
|
{
|
||||||
|
if (!visitedDirs.Add(frame.Path))
|
||||||
|
{
|
||||||
|
stack.Pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var children = await _providers.EnrichAsync(
|
||||||
|
_enumerator.EnumerateChildrenSafe(frame.Path, out var error),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
if (error is not null)
|
||||||
|
{
|
||||||
|
job.ErrorCount++;
|
||||||
|
job.LastError = error;
|
||||||
|
await _store.ScanJobs.AddErrorAsync(new ScanError
|
||||||
|
{
|
||||||
|
JobId = job.Id,
|
||||||
|
Path = frame.Path,
|
||||||
|
Kind = error.Contains("denied", StringComparison.OrdinalIgnoreCase) ? "AccessDenied" : "Io",
|
||||||
|
Message = error,
|
||||||
|
Utc = DateTimeOffset.UtcNow
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
frame.Expanded = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var childDirs = new List<Frame>();
|
||||||
|
foreach (var child in children)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (evaluator.ShouldExclude(child.FullPath, child.Name, child.IsDirectory, child.Attributes, source.Id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rel = PathRules.MakeRelative(root, child.FullPath);
|
||||||
|
var entry = CreateEntry(source, frame.Id, child, rel, DateTimeOffset.UtcNow, generation);
|
||||||
|
pending.Add(entry);
|
||||||
|
if (child.IsDirectory)
|
||||||
|
{
|
||||||
|
job.DirsSeen++;
|
||||||
|
if (ReparsePolicy.ShouldRecurseIntoDirectory(child))
|
||||||
|
{
|
||||||
|
childDirs.Add(new Frame
|
||||||
|
{
|
||||||
|
Path = child.FullPath,
|
||||||
|
PathRel = rel,
|
||||||
|
Parent = frame,
|
||||||
|
ParentId = frame.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
frame.Dirs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
job.FilesSeen++;
|
||||||
|
job.BytesSeen += child.SizeBytes;
|
||||||
|
frame.Size += child.SizeBytes;
|
||||||
|
frame.Files++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await FlushAsync(pending, childDirs, cancellationToken).ConfigureAwait(false);
|
||||||
|
frame.Expanded = true;
|
||||||
|
for (var i = childDirs.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
stack.Push(childDirs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _store.Entries.UpdateAggregatesAsync(frame.Id, frame.Size, frame.Files, frame.Dirs, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (frame.Parent is not null)
|
||||||
|
{
|
||||||
|
frame.Parent.Size += frame.Size;
|
||||||
|
frame.Parent.Dirs++;
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.Pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((DateTime.UtcNow - lastProgress).TotalMilliseconds >= AppConstants.ProgressHzMilliseconds)
|
||||||
|
{
|
||||||
|
lastProgress = DateTime.UtcNow;
|
||||||
|
progress?.Report(ToProgress(job, frame.Path));
|
||||||
|
job.ResumePath = frame.PathRel;
|
||||||
|
await _store.ScanJobs.UpdateAsync(job, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await FlushAsync(pending, [], cancellationToken).ConfigureAwait(false);
|
||||||
|
await _store.Entries.MarkMissingAsDeletedAsync(source.Id, generation, DateTimeOffset.UtcNow, string.IsNullOrEmpty(startRel) ? null : startRel, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, generation, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
job.Status = ScanJobStatus.Done;
|
||||||
|
job.FinishedUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _store.ScanJobs.UpdateAsync(job, cancellationToken).ConfigureAwait(false);
|
||||||
|
progress?.Report(ToProgress(job, startPath) with { Status = ScanJobStatus.Done });
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
await FlushAsync(pending, [], CancellationToken.None).ConfigureAwait(false);
|
||||||
|
job.Status = ScanJobStatus.Cancelled;
|
||||||
|
job.FinishedUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Scan cancelled", CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Scan failed for source {Id}", source.Id);
|
||||||
|
job.Status = ScanJobStatus.Failed;
|
||||||
|
job.LastError = ex.Message;
|
||||||
|
job.FinishedUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _store.ScanJobs.UpdateAsync(job, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Error, ex.Message, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlushAsync(List<IndexEntry> pending, List<Frame> dirs, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (pending.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _store.RunWriteAsync(async s =>
|
||||||
|
{
|
||||||
|
foreach (var entry in pending)
|
||||||
|
{
|
||||||
|
entry.Id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var d in dirs)
|
||||||
|
{
|
||||||
|
var match = pending.Find(e =>
|
||||||
|
e.IsDirectory && string.Equals(e.PathRel, d.PathRel, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (match is not null)
|
||||||
|
{
|
||||||
|
d.Id = match.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScanProgress ToProgress(ScanJob job, string path) => new()
|
||||||
|
{
|
||||||
|
JobId = job.Id,
|
||||||
|
SourceId = job.SourceId,
|
||||||
|
CurrentPath = path,
|
||||||
|
FilesSeen = job.FilesSeen,
|
||||||
|
DirsSeen = job.DirsSeen,
|
||||||
|
BytesSeen = job.BytesSeen,
|
||||||
|
ErrorCount = job.ErrorCount,
|
||||||
|
Status = job.Status
|
||||||
|
};
|
||||||
|
|
||||||
|
private static FileSystemItem DummyDir(string startPath, string startRel, string root) => new()
|
||||||
|
{
|
||||||
|
FullPath = startPath,
|
||||||
|
Name = string.IsNullOrEmpty(startRel) ? root.TrimEnd('\\') : PathRules.GetFileName(startPath),
|
||||||
|
IsDirectory = true,
|
||||||
|
SizeBytes = 0,
|
||||||
|
Attributes = AttributeFlags.Directory,
|
||||||
|
ReparseTag = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
private static IndexEntry CreateEntry(Source source, long? parentId, FileSystemItem item, string pathRel, DateTimeOffset now, long generation)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
SourceId = source.Id,
|
||||||
|
ParentId = parentId,
|
||||||
|
Name = item.Name,
|
||||||
|
NameNorm = NameNormalizer.Normalize(item.Name),
|
||||||
|
Extension = item.IsDirectory ? null : NameNormalizer.Extension(item.Name),
|
||||||
|
IsDirectory = item.IsDirectory,
|
||||||
|
SizeBytes = item.IsDirectory ? 0 : item.SizeBytes,
|
||||||
|
AggregateSize = item.IsDirectory ? 0 : item.SizeBytes,
|
||||||
|
CreatedUtc = item.CreatedUtc,
|
||||||
|
ModifiedUtc = item.ModifiedUtc,
|
||||||
|
LastSeenUtc = now,
|
||||||
|
LastIndexedUtc = now,
|
||||||
|
Attributes = item.Attributes,
|
||||||
|
FileId = item.FileId,
|
||||||
|
ReparseTag = item.ReparseTag,
|
||||||
|
Status = EntryStatus.Present,
|
||||||
|
PathRel = pathRel,
|
||||||
|
ScanGeneration = generation,
|
||||||
|
AllocatedSizeBytes = item.AllocatedSizeBytes ?? item.Cloud?.AllocatedSizeBytes,
|
||||||
|
CloudAvailability = item.Cloud?.Availability
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class Frame
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public required string PathRel { get; init; }
|
||||||
|
public long Id { get; set; }
|
||||||
|
public long? ParentId { get; init; }
|
||||||
|
public Frame? Parent { get; init; }
|
||||||
|
public bool Expanded { get; set; }
|
||||||
|
public long Size { get; set; }
|
||||||
|
public int Files { get; set; }
|
||||||
|
public int Dirs { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/Explorer.Indexing/FolderReconciler.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
|
public sealed class FolderReconciler
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
|
||||||
|
public FolderReconciler(IIndexStore store, IFileSystemEnumerator enumerator, StorageProviderRegistry providers)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_providers = providers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReconcileAsync(Source source, string pathRel, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(source.LastRootPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var full = PathRules.Combine(source.LastRootPath, pathRel);
|
||||||
|
var parent = await _store.Entries.GetByPathAsync(source.Id, pathRel, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (parent is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var live = await _providers.EnrichAsync(_enumerator.EnumerateChildrenSafe(full, out _), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var indexed = await _store.Entries.GetChildrenAsync(source.Id, parent.Id, EntryStatus.Present, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var liveNames = new HashSet<string>(live.Select(i => NameNormalizer.Normalize(i.Name)), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
await _store.RunWriteAsync(async s =>
|
||||||
|
{
|
||||||
|
foreach (var item in live)
|
||||||
|
{
|
||||||
|
var rel = PathRules.MakeRelative(source.LastRootPath, item.FullPath);
|
||||||
|
var entry = new IndexEntry
|
||||||
|
{
|
||||||
|
SourceId = source.Id,
|
||||||
|
ParentId = parent.Id,
|
||||||
|
Name = item.Name,
|
||||||
|
NameNorm = NameNormalizer.Normalize(item.Name),
|
||||||
|
Extension = item.IsDirectory ? null : NameNormalizer.Extension(item.Name),
|
||||||
|
IsDirectory = item.IsDirectory,
|
||||||
|
SizeBytes = item.IsDirectory ? 0 : item.SizeBytes,
|
||||||
|
AggregateSize = item.IsDirectory ? 0 : item.SizeBytes,
|
||||||
|
CreatedUtc = item.CreatedUtc,
|
||||||
|
ModifiedUtc = item.ModifiedUtc,
|
||||||
|
LastSeenUtc = now,
|
||||||
|
LastIndexedUtc = now,
|
||||||
|
Attributes = item.Attributes,
|
||||||
|
FileId = item.FileId,
|
||||||
|
ReparseTag = item.ReparseTag,
|
||||||
|
Status = EntryStatus.Present,
|
||||||
|
PathRel = rel,
|
||||||
|
ScanGeneration = source.ScanGeneration,
|
||||||
|
AllocatedSizeBytes = item.AllocatedSizeBytes ?? item.Cloud?.AllocatedSizeBytes,
|
||||||
|
CloudAvailability = item.Cloud?.Availability
|
||||||
|
};
|
||||||
|
|
||||||
|
var existing = indexed.FirstOrDefault(e => e.NameNorm == entry.NameNorm);
|
||||||
|
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
|
||||||
|
var id = await s.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!item.IsDirectory)
|
||||||
|
{
|
||||||
|
var delta = item.SizeBytes - oldSize;
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, item.SizeBytes, 1, 0, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (delta != 0)
|
||||||
|
{
|
||||||
|
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, delta, 0, 0, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (existing is null)
|
||||||
|
{
|
||||||
|
await s.Entries.ApplySizeDeltaToAncestorsAsync(parent.Id, 0, 0, 1, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var old in indexed)
|
||||||
|
{
|
||||||
|
if (!liveNames.Contains(old.NameNorm))
|
||||||
|
{
|
||||||
|
await s.Entries.TombstoneAsync(old.Id, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
src/Explorer.Indexing/IndexingCoordinator.cs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
|
public sealed class IndexingCoordinator : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly FilesystemScanner _scanner;
|
||||||
|
private readonly FolderReconciler _reconciler;
|
||||||
|
private readonly UsnChangeApplier _usn;
|
||||||
|
private readonly IUsnJournal _journal;
|
||||||
|
private readonly IVolumeService _volumes;
|
||||||
|
private readonly ILogger<IndexingCoordinator> _logger;
|
||||||
|
private readonly Channel<IndexWork> _work = Channel.CreateUnbounded<IndexWork>();
|
||||||
|
private readonly Dictionary<long, CancellationTokenSource> _running = new();
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
public event EventHandler<ScanProgress>? ProgressChanged;
|
||||||
|
|
||||||
|
public IndexingCoordinator(
|
||||||
|
IIndexStore store,
|
||||||
|
FilesystemScanner scanner,
|
||||||
|
FolderReconciler reconciler,
|
||||||
|
UsnChangeApplier usn,
|
||||||
|
IUsnJournal journal,
|
||||||
|
IVolumeService volumes,
|
||||||
|
ILogger<IndexingCoordinator> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_scanner = scanner;
|
||||||
|
_reconciler = reconciler;
|
||||||
|
_usn = usn;
|
||||||
|
_journal = journal;
|
||||||
|
_volumes = volumes;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueFullScan(long sourceId)
|
||||||
|
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Full, sourceId, null));
|
||||||
|
|
||||||
|
public void EnqueueFolderScan(long sourceId, string pathRel)
|
||||||
|
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Folder, sourceId, pathRel));
|
||||||
|
|
||||||
|
public void EnqueueReconcile(long sourceId, string pathRel)
|
||||||
|
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Reconcile, sourceId, pathRel));
|
||||||
|
|
||||||
|
public void EnqueueUsn(long sourceId)
|
||||||
|
=> _work.Writer.TryWrite(new IndexWork(WorkKind.Usn, sourceId, null));
|
||||||
|
|
||||||
|
public void Cancel(long sourceId)
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_running.TryGetValue(sourceId, out var cts))
|
||||||
|
{
|
||||||
|
cts.Cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await _store.ScanJobs.InterruptRunningAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
_ = Task.Run(() => PeriodicAsync(stoppingToken), stoppingToken);
|
||||||
|
|
||||||
|
await foreach (var item in _work.Reader.ReadAllAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await RunAsync(item, stoppingToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Indexing work cancelled for source {Id}", item.SourceId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Indexing work failed for source {Id}", item.SourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunAsync(IndexWork item, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var source = await _store.Sources.GetAsync(item.SourceId, stoppingToken).ConfigureAwait(false);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_running[item.SourceId] = linked;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (item.Kind)
|
||||||
|
{
|
||||||
|
case WorkKind.Full:
|
||||||
|
case WorkKind.Folder:
|
||||||
|
await _scanner.ScanAsync(
|
||||||
|
source,
|
||||||
|
item.Kind == WorkKind.Full ? ScanKind.Full : ScanKind.Folder,
|
||||||
|
item.PathRel,
|
||||||
|
new Progress<ScanProgress>(p => ProgressChanged?.Invoke(this, p)),
|
||||||
|
linked.Token).ConfigureAwait(false);
|
||||||
|
EnqueueUsn(source.Id);
|
||||||
|
break;
|
||||||
|
case WorkKind.Reconcile:
|
||||||
|
if (item.PathRel is not null)
|
||||||
|
{
|
||||||
|
await _reconciler.ReconcileAsync(source, item.PathRel, linked.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case WorkKind.Usn:
|
||||||
|
await _usn.ApplyAsync(source, _journal, linked.Token).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_running.Remove(item.SourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PeriodicAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||||
|
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var sources = await _store.Sources.GetAllAsync(stoppingToken).ConfigureAwait(false);
|
||||||
|
foreach (var source in sources.Where(s => s.IsIndexed && s.Status is SourceStatus.Online or SourceStatus.Stale))
|
||||||
|
{
|
||||||
|
if (source.LastRootPath is not null && _volumes.IsPathReachable(source.LastRootPath))
|
||||||
|
{
|
||||||
|
EnqueueUsn(source.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WorkKind { Full, Folder, Reconcile, Usn }
|
||||||
|
|
||||||
|
private readonly record struct IndexWork(WorkKind Kind, long SourceId, string? PathRel);
|
||||||
|
}
|
||||||
180
src/Explorer.Indexing/UsnChangeApplier.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Explorer.Indexing;
|
||||||
|
|
||||||
|
public sealed class UsnChangeApplier
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly IFileSystemEnumerator _enumerator;
|
||||||
|
private readonly ILogger<UsnChangeApplier> _logger;
|
||||||
|
|
||||||
|
public UsnChangeApplier(IIndexStore store, IFileSystemEnumerator enumerator, ILogger<UsnChangeApplier> logger)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_enumerator = enumerator;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UsnReadStatus> ApplyAsync(Source source, IUsnJournal journal, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (source.LastRootPath is null || source.Kind is not (SourceKind.NtfsLocal or SourceKind.Removable))
|
||||||
|
{
|
||||||
|
return UsnReadStatus.Unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!journal.TryQuery(source.LastRootPath, out var current, out _))
|
||||||
|
{
|
||||||
|
return UsnReadStatus.Unavailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
var from = new UsnJournalState
|
||||||
|
{
|
||||||
|
JournalId = source.UsnJournalId ?? 0,
|
||||||
|
NextUsn = source.UsnNext ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
if (from.JournalId != 0 && from.JournalId != current.JournalId)
|
||||||
|
{
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Change journal reset", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return UsnReadStatus.JournalReset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from.JournalId == 0)
|
||||||
|
{
|
||||||
|
await _store.Sources.UpdateUsnAsync(source.Id, current.JournalId, current.NextUsn, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return UsnReadStatus.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
var records = journal.Read(source.LastRootPath, from, 4000, out var next, out var status);
|
||||||
|
if (status is UsnReadStatus.JournalReset)
|
||||||
|
{
|
||||||
|
await _store.Sources.UpdateStatusAsync(source.Id, SourceStatus.Stale, "Change journal reset", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status != UsnReadStatus.Ok)
|
||||||
|
{
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
var coalesced = Coalesce(records);
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
await _store.RunWriteAsync(async s =>
|
||||||
|
{
|
||||||
|
foreach (var rec in coalesced)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyRecord(s, source, rec, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "USN apply failed for {Name}", rec.FileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.Sources.UpdateUsnAsync(source.Id, next.JournalId, next.NextUsn, cancellationToken).ConfigureAwait(false);
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return UsnReadStatus.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyRecord(IIndexStore store, Source source, UsnRecord rec, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = rec.FileReferenceNumber != 0
|
||||||
|
? await store.Entries.GetByFileIdAsync(source.Id, rec.FileReferenceNumber, cancellationToken).ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (rec.IsDelete)
|
||||||
|
{
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parent = rec.ParentFileReferenceNumber != 0
|
||||||
|
? await store.Entries.GetByFileIdAsync(source.Id, rec.ParentFileReferenceNumber, cancellationToken).ConfigureAwait(false)
|
||||||
|
: await store.Entries.GetRootAsync(source.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var parentRel = parent?.PathRel ?? "";
|
||||||
|
var rel = string.IsNullOrEmpty(parentRel) ? rec.FileName : parentRel + "\\" + rec.FileName;
|
||||||
|
var full = PathRules.Combine(source.LastRootPath!, rel);
|
||||||
|
var live = _enumerator.GetItem(full);
|
||||||
|
|
||||||
|
if (live is null)
|
||||||
|
{
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
await store.Entries.TombstoneAsync(existing.Id, now, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldSize = existing is { IsDirectory: false } ? existing.SizeBytes : 0;
|
||||||
|
var entry = new IndexEntry
|
||||||
|
{
|
||||||
|
SourceId = source.Id,
|
||||||
|
ParentId = parent?.Id,
|
||||||
|
Name = live.Name,
|
||||||
|
NameNorm = NameNormalizer.Normalize(live.Name),
|
||||||
|
Extension = live.IsDirectory ? null : NameNormalizer.Extension(live.Name),
|
||||||
|
IsDirectory = live.IsDirectory,
|
||||||
|
SizeBytes = live.IsDirectory ? 0 : live.SizeBytes,
|
||||||
|
AggregateSize = existing?.AggregateSize ?? (live.IsDirectory ? 0 : live.SizeBytes),
|
||||||
|
CreatedUtc = live.CreatedUtc,
|
||||||
|
ModifiedUtc = live.ModifiedUtc,
|
||||||
|
LastSeenUtc = now,
|
||||||
|
LastIndexedUtc = now,
|
||||||
|
Attributes = live.Attributes,
|
||||||
|
FileId = rec.FileReferenceNumber,
|
||||||
|
ParentFileId = rec.ParentFileReferenceNumber,
|
||||||
|
ReparseTag = live.ReparseTag,
|
||||||
|
Status = EntryStatus.Present,
|
||||||
|
PathRel = rel,
|
||||||
|
ScanGeneration = source.ScanGeneration
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing is not null && !string.Equals(existing.PathRel, rel, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await store.Entries.RenameSubtreePathAsync(source.Id, existing.PathRel, rel, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.Entries.UpsertAsync(entry, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!live.IsDirectory)
|
||||||
|
{
|
||||||
|
var delta = live.SizeBytes - oldSize;
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, live.SizeBytes, 1, 0, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (delta != 0)
|
||||||
|
{
|
||||||
|
await store.Entries.ApplySizeDeltaToAncestorsAsync(parent?.Id, delta, 0, 0, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<UsnRecord> Coalesce(IReadOnlyList<UsnRecord> records)
|
||||||
|
{
|
||||||
|
var map = new Dictionary<long, UsnRecord>();
|
||||||
|
foreach (var rec in records)
|
||||||
|
{
|
||||||
|
map[rec.FileReferenceNumber] = rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map.Values.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Plugin.Abstractions</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
94
src/Explorer.Plugin.Abstractions/ProviderContracts.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
namespace Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
|
public enum ProviderIsolation
|
||||||
|
{
|
||||||
|
InProcess = 0,
|
||||||
|
OutOfProcess = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum ProviderCapability
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
CloudState = 1,
|
||||||
|
Pin = 2,
|
||||||
|
Dehydrate = 4,
|
||||||
|
Quota = 8,
|
||||||
|
Sharing = 16,
|
||||||
|
VersionHistory = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CloudAvailability
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
OnlineOnly = 1,
|
||||||
|
LocallyAvailable = 2,
|
||||||
|
Pinned = 3,
|
||||||
|
Syncing = 4,
|
||||||
|
Error = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ProviderAction
|
||||||
|
{
|
||||||
|
Pin = 0,
|
||||||
|
Unpin = 1,
|
||||||
|
Dehydrate = 2,
|
||||||
|
ShowQuota = 3,
|
||||||
|
Share = 4,
|
||||||
|
VersionHistory = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ProviderActionStatus
|
||||||
|
{
|
||||||
|
Succeeded = 0,
|
||||||
|
Unsupported = 1,
|
||||||
|
Failed = 2,
|
||||||
|
RequiresUserConfirmation = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProviderManifest(
|
||||||
|
string Id,
|
||||||
|
string DisplayName,
|
||||||
|
string Version,
|
||||||
|
ProviderIsolation Isolation);
|
||||||
|
|
||||||
|
public sealed record ProviderItemState(
|
||||||
|
string ProviderId,
|
||||||
|
string Path,
|
||||||
|
CloudAvailability Availability,
|
||||||
|
long LogicalSizeBytes,
|
||||||
|
long? AllocatedSizeBytes,
|
||||||
|
bool MayHydrateOnOpen,
|
||||||
|
bool MayHydrateOnRead,
|
||||||
|
string? StatusText,
|
||||||
|
string? ErrorText);
|
||||||
|
|
||||||
|
public sealed record ProviderActionRequest(
|
||||||
|
ProviderAction Action,
|
||||||
|
IReadOnlyList<string> Paths);
|
||||||
|
|
||||||
|
public sealed record ProviderActionResult(
|
||||||
|
ProviderActionStatus Status,
|
||||||
|
string? Message = null);
|
||||||
|
|
||||||
|
public sealed record ProviderQuota(
|
||||||
|
string RootPath,
|
||||||
|
long? UsedBytes,
|
||||||
|
long? TotalBytes,
|
||||||
|
string? Label);
|
||||||
|
|
||||||
|
public sealed record ProviderPlace(
|
||||||
|
string ProviderId,
|
||||||
|
string DisplayName,
|
||||||
|
string Path);
|
||||||
|
|
||||||
|
public interface IStorageProvider
|
||||||
|
{
|
||||||
|
ProviderManifest Manifest { get; }
|
||||||
|
ProviderCapability GetCapabilities();
|
||||||
|
bool TryMatchRoot(string path);
|
||||||
|
IReadOnlyList<ProviderPlace> GetPlaces() => [];
|
||||||
|
Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken = default);
|
||||||
|
Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default);
|
||||||
|
Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
10
src/Explorer.Plugin.OneDrive/Explorer.Plugin.OneDrive.csproj
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<RootNamespace>Explorer.Plugin.OneDrive</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Windows\Explorer.Windows.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
111
src/Explorer.Plugin.OneDrive/OneDrivePlaceDiscovery.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Plugin.OneDrive;
|
||||||
|
|
||||||
|
public static class OneDrivePlaceDiscovery
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ProviderPlace> Discover()
|
||||||
|
{
|
||||||
|
var found = new List<ProviderPlace>();
|
||||||
|
void Add(string providerPath, string label)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(providerPath))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = providerPath.Trim().TrimEnd('\\');
|
||||||
|
if (trimmed.Length == 0
|
||||||
|
|| found.Exists(p => p.Path.Equals(trimmed, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
found.Add(new ProviderPlace(OneDriveStorageProvider.ProviderId, label, trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var accounts = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\OneDrive\Accounts");
|
||||||
|
if (accounts is not null)
|
||||||
|
{
|
||||||
|
foreach (var name in accounts.GetSubKeyNames())
|
||||||
|
{
|
||||||
|
using var key = accounts.OpenSubKey(name);
|
||||||
|
if (key is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var folder = key.GetValue("UserFolder") as string;
|
||||||
|
if (string.IsNullOrWhiteSpace(folder))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(folder, BuildLabel(
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
key.GetValue("DisplayName") as string,
|
||||||
|
FirstNonEmpty(key.GetValue("UserName") as string, key.GetValue("UserDisplayName") as string)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// registry is optional
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(Environment.GetEnvironmentVariable("OneDriveConsumer") ?? "", "OneDrive");
|
||||||
|
Add(Environment.GetEnvironmentVariable("OneDrive") ?? "", "OneDrive");
|
||||||
|
Add(Environment.GetEnvironmentVariable("OneDriveCommercial") ?? "", "OneDrive - Work");
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildLabel(string accountKey, string userFolder, string? displayName, string? userName)
|
||||||
|
{
|
||||||
|
var folderName = Path.GetFileName(userFolder.TrimEnd('\\'));
|
||||||
|
var user = FirstNonEmpty(userName, displayName);
|
||||||
|
var personal = accountKey.Equals("Personal", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(user))
|
||||||
|
{
|
||||||
|
if (personal)
|
||||||
|
{
|
||||||
|
return $"{user} - Personal";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(displayName)
|
||||||
|
&& !displayName.Equals(user, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return $"{user} - {displayName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(folderName)
|
||||||
|
&& folderName.StartsWith("OneDrive", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& folderName.Length > "OneDrive".Length)
|
||||||
|
{
|
||||||
|
return folderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(accountKey) ? user : $"{user} - {accountKey}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(folderName)
|
||||||
|
&& !folderName.Equals("OneDrive", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return folderName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
return $"OneDrive - {displayName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "OneDrive";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonEmpty(params string?[] values)
|
||||||
|
=> values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||||
|
}
|
||||||
101
src/Explorer.Plugin.OneDrive/OneDrivePlaceholderState.cs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Windows;
|
||||||
|
|
||||||
|
namespace Explorer.Plugin.OneDrive;
|
||||||
|
|
||||||
|
public static class OneDrivePlaceholderState
|
||||||
|
{
|
||||||
|
public const int Offline = 0x1000;
|
||||||
|
public const int RecallOnOpen = 0x40000;
|
||||||
|
public const int Pinned = 0x80000;
|
||||||
|
public const int Unpinned = 0x100000;
|
||||||
|
public const int RecallOnDataAccess = 0x400000;
|
||||||
|
|
||||||
|
public static ProviderItemState FromLocalSignals(
|
||||||
|
string providerId,
|
||||||
|
string path,
|
||||||
|
int attributes,
|
||||||
|
int reparseTag,
|
||||||
|
long logicalSize,
|
||||||
|
long? allocatedSize,
|
||||||
|
uint? placeholderState)
|
||||||
|
{
|
||||||
|
var availability = MapAvailability(attributes, placeholderState);
|
||||||
|
var hydrateOnRead = availability == CloudAvailability.OnlineOnly
|
||||||
|
|| (attributes & (RecallOnDataAccess | Offline)) != 0
|
||||||
|
|| (placeholderState is uint cf && (cf & CloudFilesNative.PlaceholderPartial) != 0
|
||||||
|
&& (attributes & RecallOnDataAccess) != 0);
|
||||||
|
var hydrateOnOpen = hydrateOnRead || (attributes & RecallOnOpen) != 0;
|
||||||
|
|
||||||
|
return new ProviderItemState(
|
||||||
|
providerId,
|
||||||
|
path,
|
||||||
|
availability,
|
||||||
|
logicalSize,
|
||||||
|
allocatedSize,
|
||||||
|
hydrateOnOpen,
|
||||||
|
hydrateOnRead,
|
||||||
|
StatusText(availability),
|
||||||
|
availability == CloudAvailability.Error ? "Cloud placeholder reported an error" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CloudAvailability MapAvailability(int attributes, uint? placeholderState)
|
||||||
|
{
|
||||||
|
if (placeholderState == CloudFilesNative.PlaceholderInvalid)
|
||||||
|
{
|
||||||
|
return CloudAvailability.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & Pinned) != 0 && (attributes & Unpinned) == 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.Pinned;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & RecallOnDataAccess) != 0 || (attributes & Offline) != 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.OnlineOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderState is uint cf)
|
||||||
|
{
|
||||||
|
if ((cf & CloudFilesNative.PlaceholderPartial) != 0
|
||||||
|
&& (cf & CloudFilesNative.PlaceholderInSync) == 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.Syncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((cf & CloudFilesNative.Placeholder) != 0
|
||||||
|
&& (cf & CloudFilesNative.PlaceholderPartial) != 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.OnlineOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & Unpinned) != 0 && (attributes & RecallOnDataAccess) == 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.LocallyAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderState is uint known && (known & CloudFilesNative.Placeholder) != 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.LocallyAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((attributes & (Pinned | Unpinned | RecallOnDataAccess | Offline | RecallOnOpen)) != 0)
|
||||||
|
{
|
||||||
|
return CloudAvailability.LocallyAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CloudAvailability.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string StatusText(CloudAvailability availability) => availability switch
|
||||||
|
{
|
||||||
|
CloudAvailability.OnlineOnly => "Online-only",
|
||||||
|
CloudAvailability.LocallyAvailable => "Available on this device",
|
||||||
|
CloudAvailability.Pinned => "Always available on this device",
|
||||||
|
CloudAvailability.Syncing => "Syncing",
|
||||||
|
CloudAvailability.Error => "Sync error",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
}
|
||||||
140
src/Explorer.Plugin.OneDrive/OneDriveStorageProvider.cs
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
using Explorer.Windows;
|
||||||
|
|
||||||
|
namespace Explorer.Plugin.OneDrive;
|
||||||
|
|
||||||
|
public sealed class OneDriveStorageProvider : IStorageProvider
|
||||||
|
{
|
||||||
|
public const string ProviderId = "onedrive";
|
||||||
|
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private IReadOnlyList<ProviderPlace>? _places;
|
||||||
|
|
||||||
|
public ProviderManifest Manifest { get; } = new(
|
||||||
|
ProviderId,
|
||||||
|
"OneDrive",
|
||||||
|
"1.0.0",
|
||||||
|
ProviderIsolation.InProcess);
|
||||||
|
|
||||||
|
public ProviderCapability GetCapabilities()
|
||||||
|
=> ProviderCapability.CloudState | ProviderCapability.Pin | ProviderCapability.Dehydrate;
|
||||||
|
|
||||||
|
public bool TryMatchRoot(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var root in GetPlaces())
|
||||||
|
{
|
||||||
|
if (IsUnder(root.Path, path))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CloudFilesNative.IsCloudSyncRoot(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<ProviderItemState>> GetItemStatesAsync(
|
||||||
|
IReadOnlyList<string> paths,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var results = new List<ProviderItemState>(paths.Count);
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!TryMatchRoot(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attrs = (int)File.GetAttributes(path);
|
||||||
|
var isDir = (attrs & (int)FileAttributes.Directory) != 0;
|
||||||
|
long logical = 0;
|
||||||
|
if (!isDir)
|
||||||
|
{
|
||||||
|
try { logical = new FileInfo(path).Length; } catch { /* locked */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var allocated = CloudFilesNative.TryGetAllocatedSize(path);
|
||||||
|
var placeholder = CloudFilesNative.TryGetPlaceholderState(attrs, 0);
|
||||||
|
results.Add(OneDrivePlaceholderState.FromLocalSignals(
|
||||||
|
ProviderId, path, attrs, 0, logical, allocated, placeholder));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// fail-open for this item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<ProviderItemState>>(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProviderActionResult> TryInvokeAsync(ProviderActionRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var capabilities = GetCapabilities();
|
||||||
|
var needed = request.Action switch
|
||||||
|
{
|
||||||
|
ProviderAction.Pin or ProviderAction.Unpin => ProviderCapability.Pin,
|
||||||
|
ProviderAction.Dehydrate => ProviderCapability.Dehydrate,
|
||||||
|
_ => ProviderCapability.None
|
||||||
|
};
|
||||||
|
|
||||||
|
if (needed == ProviderCapability.None || (capabilities & needed) == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Unsupported, "This action is not available."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var pin = request.Action switch
|
||||||
|
{
|
||||||
|
ProviderAction.Pin => CloudFilesNative.PinPinned,
|
||||||
|
ProviderAction.Unpin or ProviderAction.Dehydrate => CloudFilesNative.PinUnpinned,
|
||||||
|
_ => CloudFilesNative.PinUnspecified
|
||||||
|
};
|
||||||
|
|
||||||
|
var failed = 0;
|
||||||
|
foreach (var path in request.Paths)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (!CloudFilesNative.TrySetPinState(path, pin))
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed == 0)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Succeeded));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed == request.Paths.Count)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, "OneDrive could not change the pin state."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(new ProviderActionResult(ProviderActionStatus.Failed, $"OneDrive could not change the pin state for {failed} item(s)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ProviderQuota?> TryGetQuotaAsync(string rootPath, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult<ProviderQuota?>(null);
|
||||||
|
|
||||||
|
public IReadOnlyList<ProviderPlace> GetPlaces()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
return _places ??= OneDrivePlaceDiscovery.Discover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUnder(string root, string path)
|
||||||
|
{
|
||||||
|
var a = root.TrimEnd('\\');
|
||||||
|
var b = path.TrimEnd('\\');
|
||||||
|
return b.Equals(a, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| b.StartsWith(a + "\\", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/Explorer.Presentation/Explorer.Presentation.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Presentation</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Analysis\Explorer.Analysis.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Plugin.Abstractions\Explorer.Plugin.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Search\Explorer.Search.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
26
src/Explorer.Presentation/Formatters.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Explorer.Presentation;
|
||||||
|
|
||||||
|
public static class Formatters
|
||||||
|
{
|
||||||
|
public static string Size(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes < 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||||
|
double v = bytes;
|
||||||
|
var u = 0;
|
||||||
|
while (v >= 1024 && u < units.Length - 1)
|
||||||
|
{
|
||||||
|
v /= 1024;
|
||||||
|
u++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return u == 0 ? $"{bytes} B" : $"{v:0.##} {units[u]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Date(DateTimeOffset? value)
|
||||||
|
=> value is null ? "" : value.Value.ToLocalTime().ToString("g");
|
||||||
|
}
|
||||||
50
src/Explorer.Presentation/NavigationHistory.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace Explorer.Presentation;
|
||||||
|
|
||||||
|
public sealed class NavigationHistory
|
||||||
|
{
|
||||||
|
private readonly List<string> _items = [];
|
||||||
|
private int _index = -1;
|
||||||
|
|
||||||
|
public bool CanGoBack => _index > 0;
|
||||||
|
public bool CanGoForward => _index >= 0 && _index < _items.Count - 1;
|
||||||
|
public string? Current => _index >= 0 && _index < _items.Count ? _items[_index] : null;
|
||||||
|
|
||||||
|
public void Navigate(string path)
|
||||||
|
{
|
||||||
|
if (_index >= 0 && _index < _items.Count
|
||||||
|
&& string.Equals(_items[_index], path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_index < _items.Count - 1 && _index >= 0)
|
||||||
|
{
|
||||||
|
_items.RemoveRange(_index + 1, _items.Count - _index - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_items.Add(path);
|
||||||
|
_index = _items.Count - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Back()
|
||||||
|
{
|
||||||
|
if (!CanGoBack)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_index--;
|
||||||
|
return _items[_index];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Forward()
|
||||||
|
{
|
||||||
|
if (!CanGoForward)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_index++;
|
||||||
|
return _items[_index];
|
||||||
|
}
|
||||||
|
}
|
||||||
660
src/Explorer.Presentation/ViewModels/AnalysisViewModel.cs
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Analysis;
|
||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed record StorageScopeItem(Source? Source, string Label)
|
||||||
|
{
|
||||||
|
public override string ToString() => Label;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class StorageNodeViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private bool _isExpanded;
|
||||||
|
[ObservableProperty] private bool _childrenLoaded;
|
||||||
|
[ObservableProperty] private double _fraction;
|
||||||
|
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string FullPath { get; init; }
|
||||||
|
public required string PathRel { get; init; }
|
||||||
|
public required long SourceId { get; init; }
|
||||||
|
public long? EntryId { get; init; }
|
||||||
|
public long Size { get; init; }
|
||||||
|
public int FileCount { get; init; }
|
||||||
|
public int DirCount { get; init; }
|
||||||
|
public int Depth { get; init; }
|
||||||
|
public bool IsDirectory { get; init; } = true;
|
||||||
|
public bool IsSource { get; init; }
|
||||||
|
public string? State { get; init; }
|
||||||
|
public bool HasState => !string.IsNullOrEmpty(State);
|
||||||
|
public string SizeLabel => Formatters.Size(Size);
|
||||||
|
public string CountLabel => FileCount == 0 && DirCount == 0
|
||||||
|
? ""
|
||||||
|
: $"{FileCount:N0} files · {DirCount:N0} folders";
|
||||||
|
public bool CanExpand => IsDirectory && EntryId is not null;
|
||||||
|
public double Indent => Depth * 16;
|
||||||
|
public string Glyph => IsSource ? "\uEDA2" : "\uE8B7";
|
||||||
|
public ObservableCollection<StorageNodeViewModel> Children { get; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class AnalysisRowViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required long Size { get; init; }
|
||||||
|
public required double Fraction { get; init; }
|
||||||
|
public string? Path { get; init; }
|
||||||
|
public string? PathRel { get; init; }
|
||||||
|
public string? ShortPath { get; init; }
|
||||||
|
public long? EntryId { get; init; }
|
||||||
|
public long? SourceId { get; init; }
|
||||||
|
public bool IsDirectory { get; init; }
|
||||||
|
public int FileCount { get; init; }
|
||||||
|
public int DirCount { get; init; }
|
||||||
|
public string? State { get; init; }
|
||||||
|
public bool HasState => !string.IsNullOrEmpty(State);
|
||||||
|
public string SizeLabel => Formatters.Size(Size);
|
||||||
|
public string CountLabel => FileCount == 0 && DirCount == 0
|
||||||
|
? ""
|
||||||
|
: FileCount > 0 && DirCount == 0
|
||||||
|
? $"{FileCount:N0} files"
|
||||||
|
: $"{FileCount:N0} files · {DirCount:N0} folders";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed partial class AnalysisViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
public const string PageTree = "Tree";
|
||||||
|
public const string PageFolders = "Biggest folders";
|
||||||
|
public const string PageFiles = "Biggest files";
|
||||||
|
public const string PageTypes = "By file type";
|
||||||
|
public const string PageSources = "By source";
|
||||||
|
|
||||||
|
private readonly AnalysisService _analysis;
|
||||||
|
private bool _suppressReload;
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
private int _loadVersion;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isOpen;
|
||||||
|
[ObservableProperty] private string _page = PageTree;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
[ObservableProperty] private StorageScopeItem? _selectedScope;
|
||||||
|
[ObservableProperty] private StorageNodeViewModel? _selectedNode;
|
||||||
|
[ObservableProperty] private AnalysisRowViewModel? _selectedRow;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
|
||||||
|
public AnalysisViewModel(AnalysisService analysis)
|
||||||
|
{
|
||||||
|
_analysis = analysis;
|
||||||
|
Scopes = [];
|
||||||
|
TreeRoots = [];
|
||||||
|
VisibleNodes = [];
|
||||||
|
Rows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string[] Pages { get; } = [PageTree, PageFolders, PageFiles, PageTypes, PageSources];
|
||||||
|
public ObservableCollection<StorageScopeItem> Scopes { get; }
|
||||||
|
public ObservableCollection<StorageNodeViewModel> TreeRoots { get; }
|
||||||
|
public ObservableCollection<StorageNodeViewModel> VisibleNodes { get; }
|
||||||
|
public ObservableCollection<AnalysisRowViewModel> Rows { get; }
|
||||||
|
public bool IsTree => Page == PageTree;
|
||||||
|
public bool IsRanking => !IsTree;
|
||||||
|
public bool CanAct
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var path = SelectedPath;
|
||||||
|
return !string.IsNullOrWhiteSpace(path) && path != "This PC";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? SelectedPath => IsTree
|
||||||
|
? SelectedNode?.FullPath
|
||||||
|
: SelectedRow?.Path;
|
||||||
|
|
||||||
|
public long? SelectedSourceId => IsTree
|
||||||
|
? SelectedNode?.SourceId
|
||||||
|
: SelectedRow?.SourceId;
|
||||||
|
|
||||||
|
public string? SelectedPathRel => IsTree ? SelectedNode?.PathRel : SelectedRow?.PathRel;
|
||||||
|
public bool SelectedIsDirectory => IsTree
|
||||||
|
? SelectedNode?.IsDirectory != false
|
||||||
|
: SelectedRow?.IsDirectory != false;
|
||||||
|
public bool SelectedIsSource => IsTree && SelectedNode?.IsSource == true
|
||||||
|
|| (!IsTree && SelectedRow is { IsDirectory: true, PathRel: "" or null } && Page == PageSources);
|
||||||
|
|
||||||
|
public string? SelectedNavigatePath
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var path = SelectedPath;
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectedIsDirectory ? path : PathRules.Parent(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SelectedScanPathRel
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var rel = SelectedPathRel;
|
||||||
|
if (string.IsNullOrEmpty(rel))
|
||||||
|
{
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SelectedIsDirectory)
|
||||||
|
{
|
||||||
|
return rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel.Contains('\\') ? PathRules.Parent(rel) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenAsync()
|
||||||
|
{
|
||||||
|
IsOpen = true;
|
||||||
|
await RefreshScopesAsync().ConfigureAwait(true);
|
||||||
|
await ReloadAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
IsOpen = false;
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task ReloadAsync()
|
||||||
|
{
|
||||||
|
if (_suppressReload)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts?.Dispose();
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
_loadCts = cts;
|
||||||
|
var version = Interlocked.Increment(ref _loadVersion);
|
||||||
|
var ct = cts.Token;
|
||||||
|
|
||||||
|
IsBusy = true;
|
||||||
|
Status = "Reading index…";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Page == PageTree)
|
||||||
|
{
|
||||||
|
await LoadTreeAsync(ct).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await LoadRankingAsync(ct).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
if (version == _loadVersion)
|
||||||
|
{
|
||||||
|
Status = "Could not read the index.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (version == _loadVersion)
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
NotifySelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task ToggleNodeAsync(StorageNodeViewModel? node)
|
||||||
|
{
|
||||||
|
if (node is null || !node.CanExpand)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.IsExpanded)
|
||||||
|
{
|
||||||
|
node.IsExpanded = false;
|
||||||
|
RemoveVisibleDescendants(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.ChildrenLoaded)
|
||||||
|
{
|
||||||
|
var ct = _loadCts?.Token ?? CancellationToken.None;
|
||||||
|
await LoadChildrenAsync(node, ct).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loadCts?.IsCancellationRequested == true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
node.IsExpanded = true;
|
||||||
|
InsertVisibleChildren(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ApplySiblingFractions(IReadOnlyList<StorageNodeViewModel> siblings)
|
||||||
|
{
|
||||||
|
var max = siblings.Count == 0 ? 1L : Math.Max(1, siblings.Max(s => s.Size));
|
||||||
|
foreach (var sibling in siblings)
|
||||||
|
{
|
||||||
|
sibling.Fraction = sibling.Size / (double)max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshScopesAsync()
|
||||||
|
{
|
||||||
|
var previous = SelectedScope?.Source?.Id;
|
||||||
|
_suppressReload = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sources = await _analysis.GetKnownSourcesAsync().ConfigureAwait(true);
|
||||||
|
Scopes.Clear();
|
||||||
|
Scopes.Add(new StorageScopeItem(null, "All indexed locations"));
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
Scopes.Add(new StorageScopeItem(source, source.DisplayName));
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedScope = previous is long id
|
||||||
|
? Scopes.FirstOrDefault(s => s.Source?.Id == id) ?? Scopes[0]
|
||||||
|
: Scopes[0];
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressReload = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTreeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Status = "Preparing storage indexes…";
|
||||||
|
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Status = "Reading index…";
|
||||||
|
|
||||||
|
var scope = SelectedScope;
|
||||||
|
var page = await AnalysisService.RunOffUiAsync(async ct =>
|
||||||
|
{
|
||||||
|
var known = await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false);
|
||||||
|
var sources = FilterSources(known, scope).ToList();
|
||||||
|
var rootsBySource = (await _analysis.GetDirectoryRootsAsync(ct).ConfigureAwait(false))
|
||||||
|
.ToDictionary(r => r.SourceId);
|
||||||
|
var roots = new List<StorageNodeViewModel>(sources.Count);
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
rootsBySource.TryGetValue(source.Id, out var root);
|
||||||
|
var path = source.LastRootPath ?? source.DisplayName;
|
||||||
|
roots.Add(new StorageNodeViewModel
|
||||||
|
{
|
||||||
|
Name = source.DisplayName,
|
||||||
|
FullPath = path,
|
||||||
|
PathRel = "",
|
||||||
|
SourceId = source.Id,
|
||||||
|
EntryId = root?.Id,
|
||||||
|
Size = root?.AggregateSize ?? 0,
|
||||||
|
FileCount = root?.ChildFileCount ?? 0,
|
||||||
|
DirCount = root?.ChildDirCount ?? 0,
|
||||||
|
Depth = 0,
|
||||||
|
IsDirectory = true,
|
||||||
|
IsSource = true,
|
||||||
|
State = SourceState(source)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySiblingFractions(roots);
|
||||||
|
return roots;
|
||||||
|
}, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
TreeRoots.Clear();
|
||||||
|
VisibleNodes.Clear();
|
||||||
|
foreach (var root in page)
|
||||||
|
{
|
||||||
|
TreeRoots.Add(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
RebuildVisible();
|
||||||
|
Status = page.Count == 0
|
||||||
|
? "Nothing indexed yet."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadChildrenAsync(StorageNodeViewModel node, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (node.EntryId is not long parentId)
|
||||||
|
{
|
||||||
|
node.ChildrenLoaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var created = await AnalysisService.RunOffUiAsync(async ct =>
|
||||||
|
{
|
||||||
|
var children = await _analysis.LargestDirectoriesAsync(
|
||||||
|
node.SourceId, parentId, AppConstants.AnalysisTreeChildTake, ct).ConfigureAwait(false);
|
||||||
|
var source = (await _analysis.GetKnownSourcesAsync(ct).ConfigureAwait(false))
|
||||||
|
.FirstOrDefault(s => s.Id == node.SourceId);
|
||||||
|
var state = source is null ? node.State : SourceState(source);
|
||||||
|
var rows = new List<StorageNodeViewModel>(children.Count);
|
||||||
|
foreach (var entry in children)
|
||||||
|
{
|
||||||
|
rows.Add(new StorageNodeViewModel
|
||||||
|
{
|
||||||
|
Name = entry.Name,
|
||||||
|
FullPath = PathRules.JoinDisplay(source?.LastRootPath ?? node.FullPath, entry.PathRel),
|
||||||
|
PathRel = entry.PathRel,
|
||||||
|
SourceId = entry.SourceId,
|
||||||
|
EntryId = entry.Id,
|
||||||
|
Size = entry.AggregateSize,
|
||||||
|
FileCount = entry.ChildFileCount,
|
||||||
|
DirCount = entry.ChildDirCount,
|
||||||
|
Depth = node.Depth + 1,
|
||||||
|
IsDirectory = true,
|
||||||
|
State = state is "Indexed" or null or "" ? null : state
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplySiblingFractions(rows);
|
||||||
|
return rows;
|
||||||
|
}, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
node.Children.Clear();
|
||||||
|
foreach (var child in created)
|
||||||
|
{
|
||||||
|
node.Children.Add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.ChildrenLoaded = true;
|
||||||
|
if (created.Count == AppConstants.AnalysisTreeChildTake)
|
||||||
|
{
|
||||||
|
Status = $"Showing the {AppConstants.AnalysisTreeChildTake:N0} largest folders in this directory.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadRankingAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var page = Page;
|
||||||
|
var scope = SelectedScope;
|
||||||
|
var sourceId = scope?.Source?.Id;
|
||||||
|
if (scope?.Source is { IsIndexed: false })
|
||||||
|
{
|
||||||
|
Rows.Clear();
|
||||||
|
Status = "This location has not been indexed yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = "Preparing storage indexes…";
|
||||||
|
await _analysis.EnsureReadyAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Status = "Reading index…";
|
||||||
|
|
||||||
|
var built = await AnalysisService.RunOffUiAsync(
|
||||||
|
ct => BuildRankingRowsAsync(page, sourceId, ct),
|
||||||
|
cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Rows.Clear();
|
||||||
|
foreach (var row in built)
|
||||||
|
{
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = scope?.Source is { Status: SourceStatus.Stale }
|
||||||
|
? "Index may be out of date."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<AnalysisRowViewModel>> BuildRankingRowsAsync(
|
||||||
|
string page,
|
||||||
|
long? sourceId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sources = (await _analysis.GetKnownSourcesAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
.ToDictionary(s => s.Id);
|
||||||
|
IReadOnlyList<(string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)> items;
|
||||||
|
if (page == PageFolders)
|
||||||
|
{
|
||||||
|
var dirs = await _analysis.LargestDirectoriesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
items = dirs.Select(d => ToRank(d, sources, isDir: true)).ToList();
|
||||||
|
}
|
||||||
|
else if (page == PageFiles)
|
||||||
|
{
|
||||||
|
var files = await _analysis.LargestFilesAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
items = files.Select(d => ToRank(d, sources, isDir: false)).ToList();
|
||||||
|
}
|
||||||
|
else if (page == PageTypes)
|
||||||
|
{
|
||||||
|
var types = await _analysis.UsageByExtensionAsync(sourceId, null, AppConstants.AnalysisTopN, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
items = types.Select(t => (
|
||||||
|
string.IsNullOrEmpty(t.Extension) ? "(none)" : "." + t.Extension,
|
||||||
|
t.TotalSize,
|
||||||
|
(string?)null,
|
||||||
|
(string?)null,
|
||||||
|
(string?)null,
|
||||||
|
(long?)null,
|
||||||
|
sourceId,
|
||||||
|
false,
|
||||||
|
(int)t.FileCount,
|
||||||
|
0,
|
||||||
|
(string?)null)).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var usage = await _analysis.UsageBySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
items = usage.Select(u =>
|
||||||
|
{
|
||||||
|
sources.TryGetValue(u.SourceId, out var source);
|
||||||
|
var path = source?.LastRootPath ?? u.DisplayName;
|
||||||
|
var stateSource = source ?? new Source
|
||||||
|
{
|
||||||
|
StableKey = "",
|
||||||
|
DisplayName = u.DisplayName,
|
||||||
|
Status = u.Status
|
||||||
|
};
|
||||||
|
return (u.DisplayName, u.TotalSize, (string?)path, (string?)"", (string?)PathRules.ShortenDisplay(path),
|
||||||
|
(long?)null, (long?)u.SourceId, true, (int)u.FileCount, 0, SourceState(stateSource));
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var max = items.Count == 0 ? 1L : Math.Max(1, items.Max(i => i.Size));
|
||||||
|
return items.Select(item => new AnalysisRowViewModel
|
||||||
|
{
|
||||||
|
Name = item.Name,
|
||||||
|
Size = item.Size,
|
||||||
|
Fraction = item.Size / (double)max,
|
||||||
|
Path = item.Path,
|
||||||
|
PathRel = item.PathRel,
|
||||||
|
ShortPath = item.ShortPath,
|
||||||
|
EntryId = item.Id,
|
||||||
|
SourceId = item.SourceId,
|
||||||
|
IsDirectory = item.IsDir,
|
||||||
|
FileCount = item.Files,
|
||||||
|
DirCount = item.Dirs,
|
||||||
|
State = item.State
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Name, long Size, string? Path, string? PathRel, string? ShortPath, long? Id, long? SourceId, bool IsDir, int Files, int Dirs, string? State)
|
||||||
|
ToRank(IndexEntry entry, IReadOnlyDictionary<long, Source> sources, bool isDir)
|
||||||
|
{
|
||||||
|
sources.TryGetValue(entry.SourceId, out var source);
|
||||||
|
var full = PathRules.JoinDisplay(source?.LastRootPath, entry.PathRel);
|
||||||
|
var state = source is null ? null : SourceState(source);
|
||||||
|
if (state is "Indexed" or "")
|
||||||
|
{
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
entry.Name,
|
||||||
|
isDir ? entry.AggregateSize : entry.SizeBytes,
|
||||||
|
full,
|
||||||
|
entry.PathRel,
|
||||||
|
PathRules.ShortenDisplay(full),
|
||||||
|
entry.Id,
|
||||||
|
entry.SourceId,
|
||||||
|
isDir,
|
||||||
|
entry.ChildFileCount,
|
||||||
|
entry.ChildDirCount,
|
||||||
|
state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RebuildVisible()
|
||||||
|
{
|
||||||
|
VisibleNodes.Clear();
|
||||||
|
foreach (var root in TreeRoots)
|
||||||
|
{
|
||||||
|
AppendVisible(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendVisible(StorageNodeViewModel node)
|
||||||
|
{
|
||||||
|
VisibleNodes.Add(node);
|
||||||
|
if (!node.IsExpanded)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
AppendVisible(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InsertVisibleChildren(StorageNodeViewModel node)
|
||||||
|
{
|
||||||
|
var index = IndexOfVisible(node);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
RebuildVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var insertAt = index + 1;
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
VisibleNodes.Insert(insertAt++, child);
|
||||||
|
if (child.IsExpanded)
|
||||||
|
{
|
||||||
|
foreach (var nested in FlattenExpanded(child))
|
||||||
|
{
|
||||||
|
VisibleNodes.Insert(insertAt++, nested);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveVisibleDescendants(StorageNodeViewModel node)
|
||||||
|
{
|
||||||
|
var index = IndexOfVisible(node);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
RebuildVisible();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = index + 1;
|
||||||
|
while (next < VisibleNodes.Count && VisibleNodes[next].Depth > node.Depth)
|
||||||
|
{
|
||||||
|
VisibleNodes.RemoveAt(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int IndexOfVisible(StorageNodeViewModel node)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < VisibleNodes.Count; i++)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(VisibleNodes[i], node))
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<StorageNodeViewModel> FlattenExpanded(StorageNodeViewModel node)
|
||||||
|
{
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
{
|
||||||
|
yield return child;
|
||||||
|
if (!child.IsExpanded)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var nested in FlattenExpanded(child))
|
||||||
|
{
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Source> FilterSources(IReadOnlyList<Source> sources, StorageScopeItem? scope)
|
||||||
|
{
|
||||||
|
if (scope?.Source is { } one)
|
||||||
|
{
|
||||||
|
return [one];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources.Where(s => s.IsIndexed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? SourceState(Source source)
|
||||||
|
=> source.Status switch
|
||||||
|
{
|
||||||
|
SourceStatus.Offline => "Offline",
|
||||||
|
SourceStatus.Scanning => "Indexing",
|
||||||
|
SourceStatus.Stale => "Stale",
|
||||||
|
SourceStatus.Error => "Error",
|
||||||
|
_ => source.IsIndexed ? null : "Not indexed"
|
||||||
|
};
|
||||||
|
|
||||||
|
private void NotifySelection()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsTree));
|
||||||
|
OnPropertyChanged(nameof(IsRanking));
|
||||||
|
OnPropertyChanged(nameof(CanAct));
|
||||||
|
OnPropertyChanged(nameof(SelectedPath));
|
||||||
|
OnPropertyChanged(nameof(SelectedNavigatePath));
|
||||||
|
OnPropertyChanged(nameof(SelectedSourceId));
|
||||||
|
OnPropertyChanged(nameof(SelectedPathRel));
|
||||||
|
OnPropertyChanged(nameof(SelectedIsSource));
|
||||||
|
OnPropertyChanged(nameof(SelectedIsDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnPageChanged(string value)
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsTree));
|
||||||
|
OnPropertyChanged(nameof(IsRanking));
|
||||||
|
_ = ReloadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedScopeChanged(StorageScopeItem? value) => _ = ReloadAsync();
|
||||||
|
partial void OnSelectedNodeChanged(StorageNodeViewModel? value) => NotifySelection();
|
||||||
|
partial void OnSelectedRowChanged(AnalysisRowViewModel? value) => NotifySelection();
|
||||||
|
}
|
||||||
79
src/Explorer.Presentation/ViewModels/DuplicateViewModel.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class DuplicateViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isOpen;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
|
||||||
|
public DuplicateViewModel(IIndexStore store, SourceManager sources)
|
||||||
|
{
|
||||||
|
_store = store;
|
||||||
|
_sources = sources;
|
||||||
|
Groups = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<string> Groups { get; }
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenAsync()
|
||||||
|
{
|
||||||
|
IsOpen = true;
|
||||||
|
IsBusy = true;
|
||||||
|
Status = "Finding size collisions…";
|
||||||
|
Groups.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lines = await Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _store.Hashes.EnqueueSizeCollisionsAsync(null).ConfigureAwait(false);
|
||||||
|
var groups = await _store.Hashes.GetDuplicateGroupsAsync(null, null, 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;
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
Groups.Add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = Groups.Count == 0
|
||||||
|
? "No confirmed duplicates yet. Hashing continues in the background."
|
||||||
|
: $"{Groups.Count} duplicate groups";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
Status = "Could not load duplicates.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
src/Explorer.Presentation/ViewModels/ExplorerPaneViewModel.cs
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
using Explorer.Indexing;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class ExplorerPaneViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly BrowseService _browse;
|
||||||
|
private readonly FileOperationService _ops;
|
||||||
|
private readonly IndexingCoordinator _indexing;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
private readonly NavigationHistory _history = new();
|
||||||
|
private CancellationTokenSource? _loadCts;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _currentPath = "This PC";
|
||||||
|
[ObservableProperty] private bool _isOffline;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _statusMessage;
|
||||||
|
[ObservableProperty] private bool _showIndexBanner;
|
||||||
|
[ObservableProperty] private string? _indexBannerText;
|
||||||
|
[ObservableProperty] private Source? _currentSource;
|
||||||
|
[ObservableProperty] private FolderViewMode _viewMode = FolderViewMode.Details;
|
||||||
|
[ObservableProperty] private string _sortProperty = "Name";
|
||||||
|
[ObservableProperty] private bool _sortDescending;
|
||||||
|
[ObservableProperty] private bool _isActive;
|
||||||
|
|
||||||
|
public ExplorerPaneViewModel(
|
||||||
|
BrowseService browse,
|
||||||
|
FileOperationService ops,
|
||||||
|
IndexingCoordinator indexing,
|
||||||
|
SourceManager sources)
|
||||||
|
{
|
||||||
|
_browse = browse;
|
||||||
|
_ops = ops;
|
||||||
|
_indexing = indexing;
|
||||||
|
_sources = sources;
|
||||||
|
Items = [];
|
||||||
|
SelectedItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<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 => CurrentPath is not "This PC";
|
||||||
|
|
||||||
|
public async Task NavigateAsync(string path, bool addHistory = true)
|
||||||
|
{
|
||||||
|
_loadCts?.Cancel();
|
||||||
|
_loadCts = new CancellationTokenSource();
|
||||||
|
var ct = _loadCts.Token;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CurrentPath = path;
|
||||||
|
if (addHistory)
|
||||||
|
{
|
||||||
|
_history.Navigate(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(CanGoBack));
|
||||||
|
OnPropertyChanged(nameof(CanGoForward));
|
||||||
|
OnPropertyChanged(nameof(CanGoUp));
|
||||||
|
OnPropertyChanged(nameof(Breadcrumb));
|
||||||
|
|
||||||
|
if (path == "This PC")
|
||||||
|
{
|
||||||
|
await LoadThisPcAsync(ct).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var listing = await _browse.ListAsync(path, ct).ConfigureAwait(true);
|
||||||
|
IsOffline = listing.IsOffline;
|
||||||
|
StatusMessage = listing.Error;
|
||||||
|
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();
|
||||||
|
IEnumerable<FileSystemItem> ordered = listing.Items;
|
||||||
|
ordered = SortProperty switch
|
||||||
|
{
|
||||||
|
"Size" => SortDescending ? ordered.OrderByDescending(i => i.SizeBytes) : ordered.OrderBy(i => i.SizeBytes),
|
||||||
|
"Modified" => SortDescending ? ordered.OrderByDescending(i => i.ModifiedUtc) : ordered.OrderBy(i => i.ModifiedUtc),
|
||||||
|
"Type" => SortDescending ? ordered.OrderByDescending(i => i.IsDirectory) : ordered.OrderBy(i => i.IsDirectory),
|
||||||
|
_ => SortDescending
|
||||||
|
? ordered.OrderByDescending(i => i.IsDirectory).ThenByDescending(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
: ordered.OrderByDescending(i => i.IsDirectory).ThenBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var item in ordered)
|
||||||
|
{
|
||||||
|
Items.Add(new FolderItemViewModel(item, sizeFromIndex && item.IsDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CurrentSource is { IsIndexed: true, Status: SourceStatus.Online } src)
|
||||||
|
{
|
||||||
|
var rel = PathRules.MakeRelative(src.LastRootPath ?? path, path);
|
||||||
|
_indexing.EnqueueReconcile(src.Id, rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// superseded
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadThisPcAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IsOffline = false;
|
||||||
|
ShowIndexBanner = false;
|
||||||
|
CurrentSource = null;
|
||||||
|
Items.Clear();
|
||||||
|
var listing = await _browse.ListThisPcAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
foreach (var item in listing.Items)
|
||||||
|
{
|
||||||
|
Items.Add(new FolderItemViewModel(item, sizeFromIndex: item.SizeBytes > 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task BackAsync()
|
||||||
|
{
|
||||||
|
var path = _history.Back();
|
||||||
|
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task ForwardAsync()
|
||||||
|
{
|
||||||
|
var path = _history.Forward();
|
||||||
|
return path is null ? Task.CompletedTask : NavigateAsync(path, addHistory: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
|
||||||
|
=> segment is null ? Task.CompletedTask : NavigateAsync(segment.Path);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task UpAsync()
|
||||||
|
{
|
||||||
|
if (CurrentPath == "This PC")
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PathRules.IsDriveRoot(CurrentPath)
|
||||||
|
|| (PathRules.IsUnc(CurrentPath)
|
||||||
|
&& CurrentPath.Equals(PathRules.CanonicalUncRoot(CurrentPath), StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return NavigateAsync("This PC");
|
||||||
|
}
|
||||||
|
|
||||||
|
return NavigateAsync(PathRules.Parent(CurrentPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task RefreshAsync() => NavigateAsync(CurrentPath, addHistory: false);
|
||||||
|
|
||||||
|
public Task OpenItemAsync(FolderItemViewModel item)
|
||||||
|
{
|
||||||
|
if (item.IsDirectory)
|
||||||
|
{
|
||||||
|
return NavigateAsync(item.FullPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ops.Open([item.FullPath]);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildIndex()
|
||||||
|
{
|
||||||
|
if (CurrentSource is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_indexing.EnqueueFullScan(CurrentSource.Id);
|
||||||
|
ShowIndexBanner = false;
|
||||||
|
IndexBannerText = null;
|
||||||
|
StatusMessage = "Building index…";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RescanFolder()
|
||||||
|
{
|
||||||
|
if (CurrentSource is null || CurrentPath == "This PC")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rel = PathRules.MakeRelative(CurrentSource.LastRootPath ?? CurrentPath, CurrentPath);
|
||||||
|
_indexing.EnqueueFolderScan(CurrentSource.Id, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSortPropertyChanged(string value) => _ = RefreshAsync();
|
||||||
|
partial void OnSortDescendingChanged(bool value) => _ = RefreshAsync();
|
||||||
|
|
||||||
|
private static IReadOnlyList<BreadcrumbSegment> BuildBreadcrumb(string path)
|
||||||
|
{
|
||||||
|
if (path == "This PC")
|
||||||
|
{
|
||||||
|
return [new BreadcrumbSegment("This PC", "This PC", IsLast: true)];
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = new List<BreadcrumbSegment> { new("This PC", "This PC") };
|
||||||
|
var p = PathRules.FromExtended(path);
|
||||||
|
if (PathRules.IsUnc(p))
|
||||||
|
{
|
||||||
|
var root = PathRules.CanonicalUncRoot(p);
|
||||||
|
parts.Add(new BreadcrumbSegment(root, root));
|
||||||
|
if (p.Length > root.Length)
|
||||||
|
{
|
||||||
|
var acc = root;
|
||||||
|
foreach (var piece in p[(root.Length + 1)..].Split('\\', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
acc = PathRules.Combine(acc, piece);
|
||||||
|
parts.Add(new BreadcrumbSegment(piece, acc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var root = Path.GetPathRoot(p)?.TrimEnd('\\') ?? p;
|
||||||
|
parts.Add(new BreadcrumbSegment(root, root.EndsWith(':') ? root + "\\" : root));
|
||||||
|
var rest = p.Length > root.Length ? p[(root.Length)..].Trim('\\') : "";
|
||||||
|
if (!string.IsNullOrEmpty(rest))
|
||||||
|
{
|
||||||
|
var acc = root.EndsWith(':') ? root + "\\" : root;
|
||||||
|
foreach (var piece in rest.Split('\\', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
acc = PathRules.Combine(acc, piece);
|
||||||
|
parts.Add(new BreadcrumbSegment(piece, acc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.Count > 0)
|
||||||
|
{
|
||||||
|
parts[^1] = parts[^1] with { IsLast = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BreadcrumbSegment(string Label, string Path, bool IsLast = false);
|
||||||
73
src/Explorer.Presentation/ViewModels/ExplorerTabViewModel.cs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
using Explorer.Indexing;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class ExplorerTabViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly Func<ExplorerPaneViewModel> _paneFactory;
|
||||||
|
|
||||||
|
public const double DefaultSplitRatio = 0.5;
|
||||||
|
public const double MinSplitRatio = 0.18;
|
||||||
|
public const double MaxSplitRatio = 0.82;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _title = "This PC";
|
||||||
|
[ObservableProperty] private bool _isSplit;
|
||||||
|
[ObservableProperty] private ExplorerPaneViewModel _activePane;
|
||||||
|
[ObservableProperty] private double _splitRatio = DefaultSplitRatio;
|
||||||
|
|
||||||
|
public ExplorerTabViewModel(
|
||||||
|
BrowseService browse,
|
||||||
|
FileOperationService ops,
|
||||||
|
IndexingCoordinator indexing,
|
||||||
|
SourceManager sources)
|
||||||
|
{
|
||||||
|
_paneFactory = () => new ExplorerPaneViewModel(browse, ops, indexing, sources);
|
||||||
|
Left = _paneFactory();
|
||||||
|
Right = _paneFactory();
|
||||||
|
_activePane = Left;
|
||||||
|
Left.IsActive = true;
|
||||||
|
Left.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(ExplorerPaneViewModel.CurrentPath))
|
||||||
|
{
|
||||||
|
Title = Left.CurrentPath == "This PC" ? "This PC" : Path.GetFileName(Left.CurrentPath.TrimEnd('\\'));
|
||||||
|
if (string.IsNullOrEmpty(Title))
|
||||||
|
{
|
||||||
|
Title = Left.CurrentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExplorerPaneViewModel Left { get; }
|
||||||
|
public ExplorerPaneViewModel Right { get; }
|
||||||
|
|
||||||
|
public void Activate(ExplorerPaneViewModel pane)
|
||||||
|
{
|
||||||
|
ActivePane = pane;
|
||||||
|
Left.IsActive = pane == Left;
|
||||||
|
Right.IsActive = pane == Right;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetSplitRatio(double ratio)
|
||||||
|
=> SplitRatio = Math.Clamp(ratio, MinSplitRatio, MaxSplitRatio);
|
||||||
|
|
||||||
|
public void ToggleSplit()
|
||||||
|
{
|
||||||
|
IsSplit = !IsSplit;
|
||||||
|
if (IsSplit)
|
||||||
|
{
|
||||||
|
_ = Right.NavigateAsync(Left.CurrentPath);
|
||||||
|
Activate(Right);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Activate(Left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task OpenInitialAsync() => Left.NavigateAsync("This PC");
|
||||||
|
}
|
||||||
75
src/Explorer.Presentation/ViewModels/FolderItemViewModel.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation;
|
||||||
|
|
||||||
|
public sealed partial class FolderItemViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
|
||||||
|
public FolderItemViewModel(FileSystemItem item, bool sizeFromIndex)
|
||||||
|
{
|
||||||
|
Item = item;
|
||||||
|
SizeFromIndex = sizeFromIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FileSystemItem Item { get; }
|
||||||
|
public bool SizeFromIndex { get; }
|
||||||
|
public string Name => Item.Name;
|
||||||
|
public string FullPath => Item.FullPath;
|
||||||
|
public bool IsDirectory => Item.IsDirectory;
|
||||||
|
public string TypeLabel => Item.IsDirectory ? "File folder" : (Item.ExtensionDisplay());
|
||||||
|
public string SizeLabel => Item.IsDirectory && !SizeFromIndex && Item.SizeBytes == 0
|
||||||
|
? ""
|
||||||
|
: Formatters.Size(Item.SizeBytes);
|
||||||
|
public string ModifiedLabel => Formatters.Date(Item.ModifiedUtc);
|
||||||
|
public string CreatedLabel => Formatters.Date(Item.CreatedUtc);
|
||||||
|
public string IconGlyph => Item.IsDirectory ? "\uE8B7" : "\uE8A5";
|
||||||
|
public bool IsImage => !Item.IsDirectory && MediaKinds.IsImage(Item.Name);
|
||||||
|
public bool IsVideo => !Item.IsDirectory && MediaKinds.IsVideo(Item.Name);
|
||||||
|
public bool MayHydrateOnRead => Item.Cloud?.MayHydrateOnRead == true
|
||||||
|
|| AttributeFlags.MayHydrateOnRead(Item.Attributes);
|
||||||
|
public string CloudStatus => Item.Cloud?.StatusText ?? "";
|
||||||
|
public bool HasCloudStatus => !string.IsNullOrEmpty(CloudStatus);
|
||||||
|
public string SizeTooltip
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var logical = SizeLabel;
|
||||||
|
var allocated = Item.AllocatedSizeBytes ?? Item.Cloud?.AllocatedSizeBytes;
|
||||||
|
if (allocated is long disk && disk != Item.SizeBytes && !Item.IsDirectory)
|
||||||
|
{
|
||||||
|
return string.IsNullOrEmpty(CloudStatus)
|
||||||
|
? $"Size {logical} · On disk {Formatters.Size(disk)}"
|
||||||
|
: $"{CloudStatus} · Size {logical} · On disk {Formatters.Size(disk)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(CloudStatus) ? logical : $"{CloudStatus} · {logical}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class MediaKinds
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> Images = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tif", ".tiff", ".ico", ".jfif"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> Videos = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsImage(string name) => Images.Contains(Path.GetExtension(name));
|
||||||
|
public static bool IsVideo(string name) => Videos.Contains(Path.GetExtension(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
file static class ItemExt
|
||||||
|
{
|
||||||
|
public static string ExtensionDisplay(this FileSystemItem item)
|
||||||
|
{
|
||||||
|
var ext = NameNormalizer.Extension(item.Name);
|
||||||
|
return string.IsNullOrEmpty(ext) ? "File" : ext.ToUpperInvariant() + " file";
|
||||||
|
}
|
||||||
|
}
|
||||||
664
src/Explorer.Presentation/ViewModels/MainViewModel.cs
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
using Explorer.Indexing;
|
||||||
|
using Explorer.Search;
|
||||||
|
using Explorer.Analysis;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
using Explorer.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class MainViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly BrowseService _browse;
|
||||||
|
private readonly FileOperationService _ops;
|
||||||
|
private readonly IndexingCoordinator _indexing;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
private readonly PathHistoryStore _pathHistory;
|
||||||
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
|
private List<string> _clipboard = [];
|
||||||
|
private bool _clipboardIsCut;
|
||||||
|
|
||||||
|
[ObservableProperty] private ExplorerTabViewModel _activeTab = null!;
|
||||||
|
[ObservableProperty] private string _pathText = "";
|
||||||
|
[ObservableProperty] private string _theme = "Dark";
|
||||||
|
[ObservableProperty] private string _footer = "";
|
||||||
|
[ObservableProperty] private string? _promptUnc;
|
||||||
|
[ObservableProperty] private bool _showCloudPin;
|
||||||
|
[ObservableProperty] private bool _showCloudDehydrate;
|
||||||
|
|
||||||
|
private readonly IOsClipboard Clipboard;
|
||||||
|
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||||
|
|
||||||
|
public MainViewModel(
|
||||||
|
BrowseService browse,
|
||||||
|
FileOperationService ops,
|
||||||
|
IndexingCoordinator indexing,
|
||||||
|
SourceManager sources,
|
||||||
|
SearchService search,
|
||||||
|
AnalysisService analysis,
|
||||||
|
IIndexStore store,
|
||||||
|
TransferQueue transfers,
|
||||||
|
IOsClipboard clipboard,
|
||||||
|
PathHistoryStore pathHistory,
|
||||||
|
StorageProviderRegistry providers,
|
||||||
|
CloudPlaceStore cloudPlaces)
|
||||||
|
{
|
||||||
|
_browse = browse;
|
||||||
|
_ops = ops;
|
||||||
|
_indexing = indexing;
|
||||||
|
_sources = sources;
|
||||||
|
_pathHistory = pathHistory;
|
||||||
|
_providers = providers;
|
||||||
|
_cloudPlaces = cloudPlaces;
|
||||||
|
PathHistory = [];
|
||||||
|
Tree = new NavigationTreeViewModel(sources, browse, providers, cloudPlaces);
|
||||||
|
Search = new SearchViewModel(search, sources);
|
||||||
|
Analysis = new AnalysisViewModel(analysis);
|
||||||
|
Duplicates = new DuplicateViewModel(store, sources);
|
||||||
|
Transfers = new TransferQueueViewModel(transfers);
|
||||||
|
Tabs = [];
|
||||||
|
Clipboard = clipboard;
|
||||||
|
transfers.JobFinished += (_, job) =>
|
||||||
|
{
|
||||||
|
void Go() => _ = OnTransferFinishedAsync(job);
|
||||||
|
if (_ui is { } ctx)
|
||||||
|
{
|
||||||
|
ctx.Post(_ => Go(), null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Go();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_indexing.ProgressChanged += (_, p) =>
|
||||||
|
{
|
||||||
|
var text = p.Status == ScanJobStatus.Done
|
||||||
|
? $"Indexed {p.FilesSeen:N0} files"
|
||||||
|
: $"Indexing… {p.FilesSeen:N0} files · {p.CurrentPath}";
|
||||||
|
if (_ui is { } ctx)
|
||||||
|
{
|
||||||
|
ctx.Post(_ => Footer = text, null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Footer = text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<ExplorerTabViewModel> Tabs { get; }
|
||||||
|
public ObservableCollection<string> PathHistory { get; }
|
||||||
|
public NavigationTreeViewModel Tree { get; }
|
||||||
|
public SearchViewModel Search { get; }
|
||||||
|
public AnalysisViewModel Analysis { get; }
|
||||||
|
public DuplicateViewModel Duplicates { get; }
|
||||||
|
public TransferQueueViewModel Transfers { get; }
|
||||||
|
public ExplorerPaneViewModel ActivePane => ActiveTab.ActivePane;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await _sources.InitializeAsync().ConfigureAwait(true);
|
||||||
|
foreach (var path in _pathHistory.Load())
|
||||||
|
{
|
||||||
|
PathHistory.Add(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
await NewTabAsync().ConfigureAwait(true);
|
||||||
|
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
Footer = "Ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task NewTabAsync()
|
||||||
|
{
|
||||||
|
var tab = new ExplorerTabViewModel(_browse, _ops, _indexing, _sources);
|
||||||
|
WireTab(tab);
|
||||||
|
Tabs.Add(tab);
|
||||||
|
ActiveTab = tab;
|
||||||
|
await tab.OpenInitialAsync().ConfigureAwait(true);
|
||||||
|
PathText = tab.ActivePane.CurrentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CloseTab(ExplorerTabViewModel? tab)
|
||||||
|
{
|
||||||
|
tab ??= ActiveTab;
|
||||||
|
if (Tabs.Count <= 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = Tabs.IndexOf(tab);
|
||||||
|
Tabs.Remove(tab);
|
||||||
|
ActiveTab = Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void Split() => ActiveTab.ToggleSplit();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task GoBreadcrumbAsync(BreadcrumbSegment? segment)
|
||||||
|
{
|
||||||
|
if (segment is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ActivePane.NavigateAsync(segment.Path).ConfigureAwait(true);
|
||||||
|
PathText = ActivePane.CurrentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void SetView(string? mode)
|
||||||
|
=> ActivePane.ViewMode = mode?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"list" => FolderViewMode.List,
|
||||||
|
"preview" => FolderViewMode.Preview,
|
||||||
|
_ => FolderViewMode.Details
|
||||||
|
};
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CancelTransfer(TransferJob? job)
|
||||||
|
{
|
||||||
|
if (job is not null)
|
||||||
|
{
|
||||||
|
Transfers.Cancel(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task BackAsync() => ActivePane.BackAsync();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task ForwardAsync() => ActivePane.ForwardAsync();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task UpAsync() => ActivePane.UpAsync();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task RefreshAsync() => ActivePane.RefreshAsync();
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task GoAsync()
|
||||||
|
{
|
||||||
|
var path = PathText.Trim();
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PathRules.IsUnc(path))
|
||||||
|
{
|
||||||
|
await _sources.AddUncAsync(path).ConfigureAwait(true);
|
||||||
|
await Tree.ReloadAsync(path).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
RememberEnteredPath(ActivePane.CurrentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task TreeSelectAsync(NavNodeViewModel? node)
|
||||||
|
{
|
||||||
|
if (node is null || node.IsPlaceholder)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Tree.EnsureChildrenAsync(node).ConfigureAwait(true);
|
||||||
|
if (!NavigationTreeViewModel.PathsEqual(node.Path, ActivePane.CurrentPath))
|
||||||
|
{
|
||||||
|
await ActivePane.NavigateAsync(node.Path).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
PathText = node.Path;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task OpenSelectedAsync()
|
||||||
|
{
|
||||||
|
var item = ActivePane.SelectedItems.FirstOrDefault() ?? ActivePane.Items.FirstOrDefault();
|
||||||
|
return item is null ? Task.CompletedTask : ActivePane.OpenItemAsync(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void Copy()
|
||||||
|
{
|
||||||
|
_clipboard = SelectedPaths();
|
||||||
|
_clipboardIsCut = false;
|
||||||
|
CopyPathsToClipboard(_clipboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void Cut()
|
||||||
|
{
|
||||||
|
_clipboard = SelectedPaths();
|
||||||
|
_clipboardIsCut = true;
|
||||||
|
CopyPathsToClipboard(_clipboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task PasteAsync()
|
||||||
|
{
|
||||||
|
if (Clipboard.TryGetFiles(out var osFiles, out var cut) && osFiles.Count > 0)
|
||||||
|
{
|
||||||
|
_clipboard = osFiles.ToList();
|
||||||
|
_clipboardIsCut = cut;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_clipboard.Count == 0 || ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_clipboardIsCut)
|
||||||
|
{
|
||||||
|
await _ops.MoveAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
_clipboard = [];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _ops.CopyAsync(_clipboard, ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task DeleteAsync()
|
||||||
|
{
|
||||||
|
var paths = SelectedPaths();
|
||||||
|
return paths.Count == 0 ? Task.CompletedTask : _ops.DeleteAsync(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CopyPath()
|
||||||
|
{
|
||||||
|
var paths = SelectedPaths();
|
||||||
|
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
|
||||||
|
{
|
||||||
|
paths = [ActivePane.CurrentPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
CopyPathsToClipboard(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void NewFolder()
|
||||||
|
{
|
||||||
|
if (ActivePane.CurrentPath == "This PC" || ActivePane.IsOffline)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ops.NewFolder(ActivePane.CurrentPath);
|
||||||
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
|
_ = ActivePane.RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RenameSelected(string newName)
|
||||||
|
{
|
||||||
|
var item = ActivePane.SelectedItems.FirstOrDefault();
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ops.Rename(item.FullPath, newName);
|
||||||
|
EnqueueReconcile(ActivePane.CurrentPath);
|
||||||
|
_ = ActivePane.RefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task BuildIndexAsync()
|
||||||
|
{
|
||||||
|
var path = ActivePane.CurrentPath;
|
||||||
|
if (path == "This PC")
|
||||||
|
{
|
||||||
|
path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || path == "This PC")
|
||||||
|
{
|
||||||
|
Footer = "Select a drive or folder to index.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = await _sources.EnsureForPathAsync(path).ConfigureAwait(true);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
Footer = "This location could not be indexed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivePane.CurrentSource = source;
|
||||||
|
ActivePane.ShowIndexBanner = false;
|
||||||
|
ActivePane.IndexBannerText = null;
|
||||||
|
_indexing.EnqueueFullScan(source.Id);
|
||||||
|
Footer = $"Indexing {source.DisplayName}…";
|
||||||
|
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void RescanFolder() => ActivePane.RescanFolder();
|
||||||
|
|
||||||
|
public void RefreshCloudActions()
|
||||||
|
{
|
||||||
|
var path = ActivePane.SelectedItems.FirstOrDefault()?.FullPath ?? ActivePane.CurrentPath;
|
||||||
|
ShowCloudPin = _providers.HasCapability(path, ProviderCapability.Pin);
|
||||||
|
ShowCloudDehydrate = _providers.HasCapability(path, ProviderCapability.Dehydrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task PinCloudAsync() => InvokeCloudAsync(ProviderAction.Pin);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task UnpinCloudAsync() => InvokeCloudAsync(ProviderAction.Unpin);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task FreeUpCloudSpaceAsync() => InvokeCloudAsync(ProviderAction.Dehydrate);
|
||||||
|
|
||||||
|
private async Task InvokeCloudAsync(ProviderAction action)
|
||||||
|
{
|
||||||
|
var paths = SelectedPaths();
|
||||||
|
if (paths.Count == 0 && ActivePane.CurrentPath != "This PC")
|
||||||
|
{
|
||||||
|
paths = [ActivePane.CurrentPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _providers.InvokeAsync(action, paths).ConfigureAwait(true);
|
||||||
|
Footer = result.Message ?? (result.Status == ProviderActionStatus.Succeeded
|
||||||
|
? "Asked the cloud client to update these items."
|
||||||
|
: "Cloud action was not available.");
|
||||||
|
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CancelIndex()
|
||||||
|
{
|
||||||
|
if (ActivePane.CurrentSource is not null)
|
||||||
|
{
|
||||||
|
_indexing.Cancel(ActivePane.CurrentSource.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenStorageHereAsync()
|
||||||
|
{
|
||||||
|
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
PathText = ActivePane.CurrentPath;
|
||||||
|
Analysis.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenStorageOtherAsync()
|
||||||
|
{
|
||||||
|
if (!ActiveTab.IsSplit || !Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var other = ActiveTab.ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
||||||
|
ActiveTab.Activate(other);
|
||||||
|
await other.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
PathText = other.CurrentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task OpenStorageTabAsync()
|
||||||
|
{
|
||||||
|
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await NewTabAsync().ConfigureAwait(true);
|
||||||
|
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
PathText = ActivePane.CurrentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task SearchStorageAsync()
|
||||||
|
{
|
||||||
|
if (!Analysis.CanAct || Analysis.SelectedNavigatePath is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
PathText = ActivePane.CurrentPath;
|
||||||
|
Search.Scope = SearchScopeKind.CurrentTree;
|
||||||
|
Search.OpenWithoutSearch();
|
||||||
|
Analysis.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void RescanStorage()
|
||||||
|
{
|
||||||
|
if (Analysis.SelectedSourceId is not long id)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Analysis.SelectedIsSource)
|
||||||
|
{
|
||||||
|
_indexing.EnqueueFullScan(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_indexing.EnqueueFolderScan(id, Analysis.SelectedScanPathRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
Footer = "Queued an index rescan for this location.";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void CopyStoragePath()
|
||||||
|
{
|
||||||
|
if (Analysis.SelectedPath is { } path)
|
||||||
|
{
|
||||||
|
CopyPathsToClipboard([path]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task AddNetworkAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(PromptUnc))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sources.AddUncAsync(PromptUnc.Trim()).ConfigureAwait(true);
|
||||||
|
PromptUnc = "";
|
||||||
|
await Tree.ReloadAsync(ActivePane.CurrentPath).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddCloudFolderAsync(string path, string? displayName = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cloudPlaces.Add(OneDriveProviderId, path, displayName);
|
||||||
|
Footer = "Added OneDrive folder to the navigation tree.";
|
||||||
|
await Tree.ReloadAsync(path.Trim().TrimEnd('\\')).ConfigureAwait(true);
|
||||||
|
await ActivePane.NavigateAsync(path).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string OneDriveProviderId = "onedrive";
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public Task SearchAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Search.Text) && !Search.IsOpen)
|
||||||
|
{
|
||||||
|
Search.OpenWithoutSearch();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Search.RunAsync(ActivePane);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public void ToggleTheme() => Theme = Theme == "Dark" ? "Light" : "Dark";
|
||||||
|
|
||||||
|
public async Task DropAsync(IReadOnlyList<string> files, string targetDirectory, bool move)
|
||||||
|
{
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move)
|
||||||
|
{
|
||||||
|
await _ops.MoveAsync(files, targetDirectory).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _ops.CopyAsync(files, targetDirectory).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WireTab(ExplorerTabViewModel tab)
|
||||||
|
{
|
||||||
|
tab.Left.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
|
||||||
|
tab.Right.PropertyChanged += (_, e) => OnPaneProperty(tab, e.PropertyName);
|
||||||
|
tab.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(ExplorerTabViewModel.ActivePane) && tab == ActiveTab)
|
||||||
|
{
|
||||||
|
PathText = tab.ActivePane.CurrentPath;
|
||||||
|
OnPropertyChanged(nameof(ActivePane));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPaneProperty(ExplorerTabViewModel tab, string? propertyName)
|
||||||
|
{
|
||||||
|
if (tab != ActiveTab)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertyName == nameof(ExplorerPaneViewModel.CurrentPath))
|
||||||
|
{
|
||||||
|
PathText = tab.ActivePane.CurrentPath;
|
||||||
|
OnPropertyChanged(nameof(ActivePane));
|
||||||
|
RefreshCloudActions();
|
||||||
|
_ = Tree.RevealPathAsync(tab.ActivePane.CurrentPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTransferFinishedAsync(TransferJob job)
|
||||||
|
{
|
||||||
|
var dirs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
void Add(string? path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var part in path.Split('|', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var dir = Directory.Exists(part) ? part : PathRules.Parent(part);
|
||||||
|
if (!string.IsNullOrEmpty(dir))
|
||||||
|
{
|
||||||
|
dirs.Add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(job.SourcePath);
|
||||||
|
Add(job.DestinationPath);
|
||||||
|
foreach (var extra in job.AdditionalSources)
|
||||||
|
{
|
||||||
|
Add(extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var dir in dirs)
|
||||||
|
{
|
||||||
|
EnqueueReconcile(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ActivePane.RefreshAsync().ConfigureAwait(true);
|
||||||
|
if (ActiveTab.IsSplit)
|
||||||
|
{
|
||||||
|
var other = ActivePane == ActiveTab.Left ? ActiveTab.Right : ActiveTab.Left;
|
||||||
|
await other.RefreshAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnqueueReconcile(string path)
|
||||||
|
{
|
||||||
|
_ = ReconcileAsync(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReconcileAsync(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = await _sources.FindByPathAsync(path).ConfigureAwait(true);
|
||||||
|
if (source is not { IsIndexed: true } || source.LastRootPath is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rel = PathRules.MakeRelative(source.LastRootPath, path);
|
||||||
|
_indexing.EnqueueReconcile(source.Id, rel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// local errors stay out of the UI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RememberEnteredPath(string path)
|
||||||
|
{
|
||||||
|
var next = PathHistoryStore.Remember(PathHistory, path);
|
||||||
|
if (next.Count == PathHistory.Count
|
||||||
|
&& PathHistory.Count > 0
|
||||||
|
&& string.Equals(PathHistory[0], path, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PathHistory.Clear();
|
||||||
|
foreach (var item in next)
|
||||||
|
{
|
||||||
|
PathHistory.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pathHistory.Save(PathHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> SelectedPaths()
|
||||||
|
=> ActivePane.SelectedItems.Select(i => i.FullPath).ToList();
|
||||||
|
|
||||||
|
private void CopyPathsToClipboard(IReadOnlyList<string> paths)
|
||||||
|
{
|
||||||
|
if (paths.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Clipboard.SetFiles(paths, _clipboardIsCut);
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnActiveTabChanged(ExplorerTabViewModel value)
|
||||||
|
{
|
||||||
|
PathText = value.ActivePane.CurrentPath;
|
||||||
|
OnPropertyChanged(nameof(ActivePane));
|
||||||
|
}
|
||||||
|
}
|
||||||
362
src/Explorer.Presentation/ViewModels/NavigationTreeViewModel.cs
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class NavNodeViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private bool _isExpanded;
|
||||||
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
[ObservableProperty] private string _label = "";
|
||||||
|
[ObservableProperty] private string _path = "";
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
[ObservableProperty] private bool _isOffline;
|
||||||
|
[ObservableProperty] private bool _childrenLoaded;
|
||||||
|
|
||||||
|
public ObservableCollection<NavNodeViewModel> Children { get; } = [];
|
||||||
|
public string Glyph { get; init; } = "\uE8B7";
|
||||||
|
public bool IsPlaceholder { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NavigationTreeViewModel
|
||||||
|
{
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
private readonly BrowseService _browse;
|
||||||
|
private readonly StorageProviderRegistry _providers;
|
||||||
|
private readonly CloudPlaceStore _cloudPlaces;
|
||||||
|
|
||||||
|
public NavigationTreeViewModel(
|
||||||
|
SourceManager sources,
|
||||||
|
BrowseService browse,
|
||||||
|
StorageProviderRegistry providers,
|
||||||
|
CloudPlaceStore cloudPlaces)
|
||||||
|
{
|
||||||
|
_sources = sources;
|
||||||
|
_browse = browse;
|
||||||
|
_providers = providers;
|
||||||
|
_cloudPlaces = cloudPlaces;
|
||||||
|
Roots = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<NavNodeViewModel> Roots { get; }
|
||||||
|
|
||||||
|
public bool IsRevealing { get; private set; }
|
||||||
|
|
||||||
|
public async Task ReloadAsync(string? revealPath = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var expanded = new List<string>();
|
||||||
|
CollectExpanded(Roots, expanded);
|
||||||
|
var selected = FindSelected(Roots)?.Path;
|
||||||
|
var restore = revealPath ?? selected;
|
||||||
|
|
||||||
|
IsRevealing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Roots.Clear();
|
||||||
|
var thisPc = new NavNodeViewModel { Label = "This PC", Path = "This PC", Glyph = "\uE977", IsExpanded = true, ChildrenLoaded = true };
|
||||||
|
Roots.Add(thisPc);
|
||||||
|
|
||||||
|
var sources = await _sources.RefreshOnlineStateAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
foreach (var source in sources)
|
||||||
|
{
|
||||||
|
var node = new NavNodeViewModel
|
||||||
|
{
|
||||||
|
Label = source.DisplayName,
|
||||||
|
Path = source.LastRootPath ?? source.DisplayName,
|
||||||
|
Status = source.Status switch
|
||||||
|
{
|
||||||
|
SourceStatus.Offline => source.LastSeenUtc is null
|
||||||
|
? "Offline"
|
||||||
|
: $"Offline · Last seen {source.LastSeenUtc.Value.ToLocalTime():d}",
|
||||||
|
SourceStatus.Scanning => "Indexing",
|
||||||
|
SourceStatus.Stale => "May be out of date",
|
||||||
|
SourceStatus.Error => "Error",
|
||||||
|
_ => source.IsIndexed ? "Indexed" : ""
|
||||||
|
},
|
||||||
|
IsOffline = source.Status == SourceStatus.Offline,
|
||||||
|
Glyph = source.Kind == SourceKind.Removable ? "\uE88E" : source.Kind == SourceKind.Smb ? "\uE968" : "\uEDA2"
|
||||||
|
};
|
||||||
|
AddPlaceholder(node);
|
||||||
|
thisPc.Children.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var place in CloudPlaceStore.Merge(_providers.GetPlaces(), _cloudPlaces.Load())
|
||||||
|
.OrderBy(p => p.DisplayName, StringComparer.CurrentCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
var exists = Directory.Exists(place.Path);
|
||||||
|
var node = new NavNodeViewModel
|
||||||
|
{
|
||||||
|
Label = place.DisplayName,
|
||||||
|
Path = place.Path,
|
||||||
|
Glyph = "\uE753",
|
||||||
|
Status = exists ? "" : "Offline",
|
||||||
|
IsOffline = !exists
|
||||||
|
};
|
||||||
|
AddPlaceholder(node);
|
||||||
|
Roots.Add(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in expanded)
|
||||||
|
{
|
||||||
|
var node = FindByPath(Roots, path);
|
||||||
|
if (node is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await EnsureChildrenAsync(node).ConfigureAwait(true);
|
||||||
|
node.IsExpanded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(restore))
|
||||||
|
{
|
||||||
|
await RevealPathAsync(restore).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsRevealing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EnsureChildrenAsync(NavNodeViewModel node)
|
||||||
|
{
|
||||||
|
if (node.IsPlaceholder || node.ChildrenLoaded || node.Path == "This PC")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var listing = await _browse.ListAsync(node.Path).ConfigureAwait(true);
|
||||||
|
node.Children.Clear();
|
||||||
|
foreach (var dir in listing.Items.Where(i => i.IsDirectory).OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase).Take(200))
|
||||||
|
{
|
||||||
|
var child = new NavNodeViewModel
|
||||||
|
{
|
||||||
|
Label = dir.Name,
|
||||||
|
Path = dir.FullPath
|
||||||
|
};
|
||||||
|
AddPlaceholder(child);
|
||||||
|
node.Children.Add(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
node.ChildrenLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevealPathAsync(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || Roots.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nested = IsRevealing;
|
||||||
|
IsRevealing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (PathsEqual(path, "This PC"))
|
||||||
|
{
|
||||||
|
var thisPc = Roots.FirstOrDefault(r => r.Path == "This PC");
|
||||||
|
if (thisPc is not null)
|
||||||
|
{
|
||||||
|
thisPc.IsExpanded = true;
|
||||||
|
SelectOnly(thisPc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = FindBestRoot(Roots, path);
|
||||||
|
if (current is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
current.IsExpanded = true;
|
||||||
|
await EnsureChildrenAsync(current).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var remaining = PathRules.MakeRelative(current.Path, path);
|
||||||
|
if (!string.IsNullOrEmpty(remaining))
|
||||||
|
{
|
||||||
|
foreach (var segment in remaining.Split('\\', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var next = current.Children.FirstOrDefault(c =>
|
||||||
|
!c.IsPlaceholder && c.Label.Equals(segment, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (next is null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = next;
|
||||||
|
current.IsExpanded = true;
|
||||||
|
await EnsureChildrenAsync(current).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectOnly(current);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!nested)
|
||||||
|
{
|
||||||
|
IsRevealing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool PathsEqual(string a, string b)
|
||||||
|
{
|
||||||
|
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var na = PathRules.FromExtended(a).TrimEnd('\\');
|
||||||
|
var nb = PathRules.FromExtended(b).TrimEnd('\\');
|
||||||
|
if (na.Equals(nb, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
PathRules.EnsureDirectoryTrailingSlashIfRoot(na),
|
||||||
|
PathRules.EnsureDirectoryTrailingSlashIfRoot(nb),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NavNodeViewModel? FindBestRoot(IEnumerable<NavNodeViewModel> roots, string path)
|
||||||
|
{
|
||||||
|
var normalized = PathRules.FromExtended(path).TrimEnd('\\');
|
||||||
|
NavNodeViewModel? best = null;
|
||||||
|
var bestLength = -1;
|
||||||
|
|
||||||
|
void Consider(NavNodeViewModel node)
|
||||||
|
{
|
||||||
|
if (node.IsPlaceholder || node.Path == "This PC")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = PathRules.FromExtended(node.Path).TrimEnd('\\');
|
||||||
|
if (normalized.Equals(root, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| normalized.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (root.Length > bestLength)
|
||||||
|
{
|
||||||
|
best = node;
|
||||||
|
bestLength = root.Length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var root in roots)
|
||||||
|
{
|
||||||
|
if (root.Path == "This PC")
|
||||||
|
{
|
||||||
|
foreach (var drive in root.Children.Where(c => !c.IsPlaceholder))
|
||||||
|
{
|
||||||
|
Consider(drive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Consider(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddPlaceholder(NavNodeViewModel node)
|
||||||
|
{
|
||||||
|
if (node.Children.Count == 0)
|
||||||
|
{
|
||||||
|
node.Children.Add(new NavNodeViewModel { IsPlaceholder = true, ChildrenLoaded = true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectExpanded(IEnumerable<NavNodeViewModel> nodes, List<string> into)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.IsPlaceholder)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.IsExpanded)
|
||||||
|
{
|
||||||
|
into.Add(node.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectExpanded(node.Children, into);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NavNodeViewModel? FindSelected(IEnumerable<NavNodeViewModel> nodes)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.IsPlaceholder)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.IsSelected)
|
||||||
|
{
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
var child = FindSelected(node.Children);
|
||||||
|
if (child is not null)
|
||||||
|
{
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NavNodeViewModel? FindByPath(IEnumerable<NavNodeViewModel> nodes, string path)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.IsPlaceholder)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PathsEqual(node.Path, path))
|
||||||
|
{
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
var child = FindByPath(node.Children, path);
|
||||||
|
if (child is not null)
|
||||||
|
{
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectOnly(NavNodeViewModel target)
|
||||||
|
{
|
||||||
|
ClearSelection(Roots);
|
||||||
|
target.IsSelected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ClearSelection(IEnumerable<NavNodeViewModel> nodes)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
{
|
||||||
|
if (node.IsSelected)
|
||||||
|
{
|
||||||
|
node.IsSelected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearSelection(node.Children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/Explorer.Presentation/ViewModels/SearchViewModel.cs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Explorer.Application;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Search;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed record SearchScopeChoice(SearchScopeKind Kind, string Label);
|
||||||
|
|
||||||
|
public sealed partial class SearchViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly SearchService _search;
|
||||||
|
private readonly SourceManager _sources;
|
||||||
|
private CancellationTokenSource? _runCts;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _text = "";
|
||||||
|
[ObservableProperty] private SearchScopeKind _scope = SearchScopeKind.AllKnown;
|
||||||
|
[ObservableProperty] private string? _extension;
|
||||||
|
[ObservableProperty] private bool _foldersOnly;
|
||||||
|
[ObservableProperty] private bool _filesOnly;
|
||||||
|
[ObservableProperty] private string? _minSizeText;
|
||||||
|
[ObservableProperty] private string? _maxSizeText;
|
||||||
|
[ObservableProperty] private bool _isOpen;
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string _status = "";
|
||||||
|
|
||||||
|
public SearchViewModel(SearchService search, SourceManager sources)
|
||||||
|
{
|
||||||
|
_search = search;
|
||||||
|
_sources = sources;
|
||||||
|
Results = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<FolderItemViewModel> Results { get; }
|
||||||
|
|
||||||
|
public SearchScopeChoice[] ScopeChoices { get; } =
|
||||||
|
[
|
||||||
|
new(SearchScopeKind.CurrentFolder, "Current folder"),
|
||||||
|
new(SearchScopeKind.CurrentTree, "Current folder tree"),
|
||||||
|
new(SearchScopeKind.AllKnown, "All indexed locations"),
|
||||||
|
new(SearchScopeKind.OfflineMedia, "Offline media")
|
||||||
|
];
|
||||||
|
|
||||||
|
public void OpenWithoutSearch()
|
||||||
|
{
|
||||||
|
IsOpen = true;
|
||||||
|
IsBusy = false;
|
||||||
|
if (string.IsNullOrWhiteSpace(Status) || Status.StartsWith("Searching", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Status = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool? DirectoryFilter(bool foldersOnly, bool filesOnly)
|
||||||
|
=> foldersOnly == filesOnly ? null : foldersOnly;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
public async Task RunAsync(ExplorerPaneViewModel? pane, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_runCts?.Cancel();
|
||||||
|
_runCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
var ct = _runCts.Token;
|
||||||
|
|
||||||
|
IsOpen = true;
|
||||||
|
IsBusy = true;
|
||||||
|
Status = "Searching…";
|
||||||
|
Results.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var source = pane?.CurrentSource;
|
||||||
|
var sources = await _sources.RefreshOnlineStateAsync(ct).ConfigureAwait(true);
|
||||||
|
var indexed = sources.Where(s => s.IsIndexed).ToList();
|
||||||
|
var scope = Scope;
|
||||||
|
if (scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected
|
||||||
|
&& source is not { IsIndexed: true })
|
||||||
|
{
|
||||||
|
scope = SearchScopeKind.AllKnown;
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<long>? sourceIds = scope switch
|
||||||
|
{
|
||||||
|
SearchScopeKind.AllKnown => indexed.Count == 0 ? sources.Select(s => s.Id).ToList() : null,
|
||||||
|
SearchScopeKind.OfflineMedia => sources.Where(s => s.Status == SourceStatus.Offline).Select(s => s.Id).ToList(),
|
||||||
|
SearchScopeKind.Sources when source is not null => [source.Id],
|
||||||
|
_ when source is not null => [source.Id],
|
||||||
|
_ => indexed.Select(s => s.Id).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
string? prefix = null;
|
||||||
|
if (pane is not null && pane.CurrentPath != "This PC" && source is { IsIndexed: true, LastRootPath: not null }
|
||||||
|
&& scope is SearchScopeKind.CurrentFolder or SearchScopeKind.CurrentTree or SearchScopeKind.Selected)
|
||||||
|
{
|
||||||
|
prefix = PathRules.MakeRelative(source.LastRootPath, pane.CurrentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = new SearchQuery
|
||||||
|
{
|
||||||
|
Scope = scope,
|
||||||
|
Text = Text,
|
||||||
|
Extension = Extension,
|
||||||
|
IsDirectory = DirectoryFilter(FoldersOnly, FilesOnly),
|
||||||
|
MinSize = ParseSize(MinSizeText),
|
||||||
|
MaxSize = ParseSize(MaxSizeText),
|
||||||
|
SourceIds = sourceIds,
|
||||||
|
PathRelPrefix = prefix,
|
||||||
|
IncludeOffline = scope is SearchScopeKind.AllKnown or SearchScopeKind.OfflineMedia,
|
||||||
|
Take = AppConstants.SearchPageSize
|
||||||
|
};
|
||||||
|
|
||||||
|
var entries = await _search.SearchAsync(query, ct).ConfigureAwait(true);
|
||||||
|
var byId = sources.ToDictionary(s => s.Id);
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
byId.TryGetValue(entry.SourceId, out var src);
|
||||||
|
var full = src?.LastRootPath is null
|
||||||
|
? (src?.DisplayName ?? "") + "\\" + entry.PathRel
|
||||||
|
: PathRules.Combine(src.LastRootPath, entry.PathRel);
|
||||||
|
Results.Add(new FolderItemViewModel(new FileSystemItem
|
||||||
|
{
|
||||||
|
FullPath = full,
|
||||||
|
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
|
||||||
|
}, entry.IsDirectory));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Results.Count > 0)
|
||||||
|
{
|
||||||
|
Status = $"{Results.Count} results";
|
||||||
|
}
|
||||||
|
else if (indexed.Count == 0)
|
||||||
|
{
|
||||||
|
Status = "Nothing indexed yet. Index a drive or folder, then search again.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Status = "No results";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = "Cancelled";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long? ParseSize(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.Trim();
|
||||||
|
double mul = 1;
|
||||||
|
if (text.EndsWith("kb", StringComparison.OrdinalIgnoreCase)) { mul = 1024; text = text[..^2]; }
|
||||||
|
else if (text.EndsWith("mb", StringComparison.OrdinalIgnoreCase)) { mul = 1024 * 1024; text = text[..^2]; }
|
||||||
|
else if (text.EndsWith("gb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024; text = text[..^2]; }
|
||||||
|
else if (text.EndsWith("tb", StringComparison.OrdinalIgnoreCase)) { mul = 1024L * 1024 * 1024 * 1024; text = text[..^2]; }
|
||||||
|
return double.TryParse(text.Trim(), out var n) ? (long)(n * mul) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.FileOperations;
|
||||||
|
|
||||||
|
namespace Explorer.Presentation.ViewModels;
|
||||||
|
|
||||||
|
public sealed partial class TransferQueueViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly TransferQueue _queue;
|
||||||
|
private readonly SynchronizationContext? _ui = SynchronizationContext.Current;
|
||||||
|
|
||||||
|
public TransferQueueViewModel(TransferQueue queue)
|
||||||
|
{
|
||||||
|
_queue = queue;
|
||||||
|
Jobs = [];
|
||||||
|
_queue.Changed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_ui is { } ctx)
|
||||||
|
{
|
||||||
|
ctx.Post(_ => Reload(), null);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<TransferJob> Jobs { get; }
|
||||||
|
public bool HasJobs => Jobs.Count > 0;
|
||||||
|
|
||||||
|
public void Cancel(TransferJob job)
|
||||||
|
{
|
||||||
|
if (job.Status is TransferStatus.Queued or TransferStatus.Running or TransferStatus.Cancelling)
|
||||||
|
{
|
||||||
|
_queue.Cancel(job.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_queue.Dismiss(job.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reload()
|
||||||
|
{
|
||||||
|
Jobs.Clear();
|
||||||
|
foreach (var job in _queue.Snapshot().Where(IsVisible))
|
||||||
|
{
|
||||||
|
Jobs.Add(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasJobs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsVisible(TransferJob job)
|
||||||
|
=> job.Status is TransferStatus.Queued or TransferStatus.Running
|
||||||
|
or TransferStatus.Cancelling or TransferStatus.Failed;
|
||||||
|
}
|
||||||
12
src/Explorer.Search/Explorer.Search.csproj
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Search</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
65
src/Explorer.Search/SearchService.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Search;
|
||||||
|
|
||||||
|
public sealed class SearchQuery
|
||||||
|
{
|
||||||
|
public SearchScopeKind Scope { get; init; } = SearchScopeKind.CurrentTree;
|
||||||
|
public string? Text { get; init; }
|
||||||
|
public string? Extension { get; init; }
|
||||||
|
public bool? IsDirectory { get; init; }
|
||||||
|
public long? MinSize { get; init; }
|
||||||
|
public long? MaxSize { get; init; }
|
||||||
|
public DateTimeOffset? CreatedAfter { get; init; }
|
||||||
|
public DateTimeOffset? CreatedBefore { get; init; }
|
||||||
|
public DateTimeOffset? ModifiedAfter { get; init; }
|
||||||
|
public DateTimeOffset? ModifiedBefore { get; init; }
|
||||||
|
public IReadOnlyList<long>? SourceIds { get; init; }
|
||||||
|
public string? PathRelPrefix { get; init; }
|
||||||
|
public bool IncludeOffline { get; init; } = true;
|
||||||
|
public int Skip { get; init; }
|
||||||
|
public int Take { get; init; } = AppConstants.SearchPageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SearchService
|
||||||
|
{
|
||||||
|
private readonly IIndexStore _store;
|
||||||
|
|
||||||
|
public SearchService(IIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var text = query.Text?.Trim();
|
||||||
|
string? extension = query.Extension;
|
||||||
|
string? name = text;
|
||||||
|
if (!string.IsNullOrEmpty(text) && text.StartsWith("*.", StringComparison.Ordinal) && !text.Contains(' ', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
extension = text[2..];
|
||||||
|
name = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = new SearchRequest
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Extension = extension,
|
||||||
|
IsDirectory = query.IsDirectory,
|
||||||
|
MinSize = query.MinSize,
|
||||||
|
MaxSize = query.MaxSize,
|
||||||
|
CreatedAfter = query.CreatedAfter,
|
||||||
|
CreatedBefore = query.CreatedBefore,
|
||||||
|
ModifiedAfter = query.ModifiedAfter,
|
||||||
|
ModifiedBefore = query.ModifiedBefore,
|
||||||
|
SourceIds = query.SourceIds,
|
||||||
|
PathRelPrefix = query.Scope is SearchScopeKind.CurrentTree or SearchScopeKind.CurrentFolder or SearchScopeKind.Selected
|
||||||
|
? query.PathRelPrefix
|
||||||
|
: null,
|
||||||
|
DirectChildrenOnly = query.Scope == SearchScopeKind.CurrentFolder,
|
||||||
|
IncludeOffline = query.IncludeOffline || query.Scope == SearchScopeKind.OfflineMedia,
|
||||||
|
Skip = query.Skip,
|
||||||
|
Take = query.Take
|
||||||
|
};
|
||||||
|
|
||||||
|
return _store.Search.SearchAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
334
src/Explorer.Storage.Sqlite/AnalysisHistoryHashStores.cs
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class AnalysisStore : IAnalysisStore
|
||||||
|
{
|
||||||
|
private const string EntryColumns = """
|
||||||
|
id, source_id, parent_id, name, path_rel, is_dir, size_bytes, aggregate_size,
|
||||||
|
child_file_count, child_dir_count
|
||||||
|
""";
|
||||||
|
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public AnalysisStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task EnsureReadyAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _store.EnsureAnalysisIndexesAsync(cancellationToken);
|
||||||
|
|
||||||
|
public async Task<long> GetIndexStampAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleAsync<(long n, long gen, string? last)>(new CommandDefinition(
|
||||||
|
"SELECT COUNT(*) AS n, ifnull(SUM(scan_generation), 0) AS gen, ifnull(MAX(last_indexed_utc), '') AS last FROM sources",
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return HashCode.Combine(row.n, row.gen, row.last ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> GetDirectoryRootsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
|
||||||
|
$"""
|
||||||
|
SELECT {EntryColumns}
|
||||||
|
FROM entries
|
||||||
|
WHERE parent_id IS NULL AND is_dir=1
|
||||||
|
""",
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return Map(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> LargestDirectoriesAsync(long? sourceId, long? parentId, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = $"""
|
||||||
|
SELECT {EntryColumns}
|
||||||
|
FROM entries
|
||||||
|
WHERE is_dir=1 AND status=0
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||||
|
sql += parentId is not null ? " AND parent_id=@parentId" : " AND parent_id IS NOT NULL";
|
||||||
|
sql += " ORDER BY aggregate_size DESC LIMIT @take";
|
||||||
|
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
|
||||||
|
sql, new { sourceId, parentId, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return Map(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> LargestFilesAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = $"""
|
||||||
|
SELECT {EntryColumns}
|
||||||
|
FROM entries
|
||||||
|
WHERE is_dir=0 AND status=0
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||||
|
if (!string.IsNullOrEmpty(pathRelPrefix))
|
||||||
|
sql += " AND (path_rel=@pathRelPrefix OR path_rel LIKE @like ESCAPE '\\')";
|
||||||
|
sql += " ORDER BY size_bytes DESC LIMIT @take";
|
||||||
|
var like = string.IsNullOrEmpty(pathRelPrefix) ? null : pathRelPrefix.Replace("\\", "\\\\") + "\\\\%";
|
||||||
|
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
|
||||||
|
sql, new { sourceId, pathRelPrefix, like, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return Map(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExtensionUsage>> UsageByExtensionAsync(long? sourceId, string? pathRelPrefix, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = """
|
||||||
|
SELECT ifnull(extension, '') AS Extension, SUM(size_bytes) AS TotalSize, COUNT(*) AS FileCount
|
||||||
|
FROM entries
|
||||||
|
WHERE is_dir=0 AND status=0
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||||
|
if (!string.IsNullOrEmpty(pathRelPrefix))
|
||||||
|
sql += " AND (path_rel=@pathRelPrefix OR path_rel LIKE @like ESCAPE '\\')";
|
||||||
|
sql += " GROUP BY extension ORDER BY TotalSize DESC LIMIT @take";
|
||||||
|
var like = string.IsNullOrEmpty(pathRelPrefix) ? null : pathRelPrefix.Replace("\\", "\\\\") + "\\\\%";
|
||||||
|
var rows = await conn.QueryAsync<ExtensionUsage>(new CommandDefinition(
|
||||||
|
sql, new { sourceId, pathRelPrefix, like, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return rows.AsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SourceUsage>> UsageBySourceAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<SourceUsage>(new CommandDefinition(
|
||||||
|
"""
|
||||||
|
SELECT s.id AS SourceId, s.display_name AS DisplayName, s.status AS Status,
|
||||||
|
ifnull(r.aggregate_size, 0) AS TotalSize,
|
||||||
|
ifnull((
|
||||||
|
SELECT COUNT(*) FROM entries e
|
||||||
|
WHERE e.source_id=s.id AND e.is_dir=0 AND e.status=0
|
||||||
|
), 0) AS FileCount
|
||||||
|
FROM sources s
|
||||||
|
LEFT JOIN entries r ON r.source_id=s.id AND r.parent_id IS NULL
|
||||||
|
ORDER BY TotalSize DESC
|
||||||
|
""",
|
||||||
|
cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return rows.AsList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> ChildrenBySizeAsync(long parentId, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<AnalysisEntryRow>(new CommandDefinition(
|
||||||
|
$"""
|
||||||
|
SELECT {EntryColumns}
|
||||||
|
FROM entries
|
||||||
|
WHERE parent_id=@parentId AND status=0
|
||||||
|
ORDER BY CASE WHEN is_dir=1 THEN aggregate_size ELSE size_bytes END DESC
|
||||||
|
LIMIT @take
|
||||||
|
""",
|
||||||
|
new { parentId, take }, cancellationToken: cancellationToken)).ConfigureAwait(false);
|
||||||
|
return Map(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<IndexEntry> Map(IEnumerable<AnalysisEntryRow> rows)
|
||||||
|
=> rows.Select(r => r.ToModel()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AnalysisEntryRow
|
||||||
|
{
|
||||||
|
public long id { get; set; }
|
||||||
|
public long source_id { get; set; }
|
||||||
|
public long? parent_id { get; set; }
|
||||||
|
public string name { get; set; } = "";
|
||||||
|
public string path_rel { get; set; } = "";
|
||||||
|
public int is_dir { get; set; }
|
||||||
|
public long size_bytes { get; set; }
|
||||||
|
public long aggregate_size { get; set; }
|
||||||
|
public int child_file_count { get; set; }
|
||||||
|
public int child_dir_count { get; set; }
|
||||||
|
|
||||||
|
public IndexEntry ToModel() => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SourceId = source_id,
|
||||||
|
ParentId = parent_id,
|
||||||
|
Name = name,
|
||||||
|
NameNorm = name,
|
||||||
|
PathRel = path_rel,
|
||||||
|
IsDirectory = is_dir != 0,
|
||||||
|
SizeBytes = size_bytes,
|
||||||
|
AggregateSize = aggregate_size,
|
||||||
|
ChildFileCount = child_file_count,
|
||||||
|
ChildDirCount = child_dir_count,
|
||||||
|
LastSeenUtc = DateTimeOffset.UnixEpoch
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class HistoryStore : IHistoryStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public HistoryStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task CaptureSourceSnapshotAsync(long sourceId, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
INSERT INTO source_stats_history (source_id, captured_utc, total_size, file_count, dir_count)
|
||||||
|
SELECT @sourceId, @utc,
|
||||||
|
ifnull((SELECT aggregate_size FROM entries WHERE source_id=@sourceId AND parent_id IS NULL), 0),
|
||||||
|
(SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND is_dir=0 AND status=0),
|
||||||
|
(SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND is_dir=1 AND status=0)
|
||||||
|
""", new { sourceId, utc = utc.ToString("O") }), cancellationToken);
|
||||||
|
|
||||||
|
public Task CaptureDirectorySnapshotsAsync(long sourceId, DateTimeOffset utc, long minSize, int topN, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
INSERT INTO directory_stats_history (source_id, path_rel, captured_utc, aggregate_size, file_count)
|
||||||
|
SELECT source_id, path_rel, @utc, aggregate_size, child_file_count
|
||||||
|
FROM entries
|
||||||
|
WHERE source_id=@sourceId AND is_dir=1 AND status=0 AND aggregate_size >= @minSize
|
||||||
|
ORDER BY aggregate_size DESC
|
||||||
|
LIMIT @topN
|
||||||
|
""", new { sourceId, utc = utc.ToString("O"), minSize, topN }), cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SourceSnapshot>> GetSourceHistoryAsync(long sourceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<(string captured_utc, long total_size, long file_count, long dir_count)>(
|
||||||
|
"SELECT captured_utc, total_size, file_count, dir_count FROM source_stats_history WHERE source_id=@sourceId ORDER BY captured_utc",
|
||||||
|
new { sourceId }).ConfigureAwait(false);
|
||||||
|
return rows.Select(r => new SourceSnapshot
|
||||||
|
{
|
||||||
|
CapturedUtc = DateTimeOffset.Parse(r.captured_utc),
|
||||||
|
TotalSize = r.total_size,
|
||||||
|
FileCount = r.file_count,
|
||||||
|
DirCount = r.dir_count
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class HashStore : IHashStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public HashStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task EnqueueSizeCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn =>
|
||||||
|
{
|
||||||
|
var sql = """
|
||||||
|
INSERT OR IGNORE INTO hash_queue (entry_id, size_bytes, priority, state)
|
||||||
|
SELECT e.id, e.size_bytes, CASE WHEN e.size_bytes > 104857600 THEN 1 ELSE 0 END, 'Pending'
|
||||||
|
FROM entries e
|
||||||
|
WHERE e.is_dir=0 AND e.status=0 AND e.size_bytes > 0
|
||||||
|
AND e.size_bytes IN (
|
||||||
|
SELECT size_bytes FROM entries
|
||||||
|
WHERE is_dir=0 AND status=0 AND size_bytes > 0
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||||
|
sql += """
|
||||||
|
GROUP BY size_bytes HAVING COUNT(*) > 1
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND e.source_id=@sourceId";
|
||||||
|
return conn.ExecuteAsync(sql, new { sourceId });
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<HashWorkItem>> DequeueAsync(int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<HashWorkItem>("""
|
||||||
|
SELECT q.entry_id AS EntryId, q.size_bytes AS SizeBytes, q.state AS State,
|
||||||
|
e.source_id AS SourceId, e.path_rel AS PathRel, s.last_root_path AS RootPath, e.file_id AS FileId,
|
||||||
|
e.attributes AS Attributes, e.cloud_availability AS CloudAvailability
|
||||||
|
FROM hash_queue q
|
||||||
|
JOIN entries e ON e.id = q.entry_id
|
||||||
|
JOIN sources s ON s.id = e.source_id
|
||||||
|
WHERE q.state IN ('Pending','PartialDone')
|
||||||
|
ORDER BY q.priority, q.size_bytes
|
||||||
|
LIMIT @take
|
||||||
|
""", new { take }).ConfigureAwait(false);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompletePartialAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE entries SET content_hash=@hash, hash_state=1 WHERE id=@entryId",
|
||||||
|
new { entryId, hash }).ConfigureAwait(false);
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE hash_queue SET state='PartialDone' WHERE entry_id=@entryId",
|
||||||
|
new { entryId }).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task CompleteFullAsync(long entryId, byte[] hash, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE entries SET content_hash=@hash, hash_state=2 WHERE id=@entryId",
|
||||||
|
new { entryId, hash }).ConfigureAwait(false);
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE hash_queue SET state='Done' WHERE entry_id=@entryId",
|
||||||
|
new { entryId }).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task MarkUniquePartialAsync(long entryId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync(
|
||||||
|
"UPDATE hash_queue SET state='Unique' WHERE entry_id=@entryId",
|
||||||
|
new { entryId }), cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> HasPartialCollisionAsync(long entryId, long sizeBytes, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var count = await conn.ExecuteScalarAsync<long>("""
|
||||||
|
SELECT COUNT(*) FROM entries e
|
||||||
|
JOIN entries me ON me.id = @entryId
|
||||||
|
WHERE e.id != @entryId AND e.is_dir = 0 AND e.status = 0 AND e.size_bytes = @sizeBytes
|
||||||
|
AND e.hash_state >= 1 AND e.content_hash IS NOT NULL AND e.content_hash = me.content_hash
|
||||||
|
""", new { entryId, sizeBytes }).ConfigureAwait(false);
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MarkErrorAsync(long entryId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync(
|
||||||
|
"UPDATE hash_queue SET state='Error' WHERE entry_id=@entryId",
|
||||||
|
new { entryId }), cancellationToken);
|
||||||
|
|
||||||
|
public Task MarkSkippedAsync(long entryId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE entries SET hash_state=3 WHERE id=@entryId",
|
||||||
|
new { entryId }).ConfigureAwait(false);
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE hash_queue SET state='Skipped' WHERE entry_id=@entryId",
|
||||||
|
new { entryId }).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DuplicateGroup>> GetDuplicateGroupsAsync(long? sourceId, string? pathPrefix, int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = """
|
||||||
|
SELECT * FROM entries
|
||||||
|
WHERE is_dir=0 AND status=0 AND hash_state=2 AND content_hash IS NOT NULL
|
||||||
|
""";
|
||||||
|
if (sourceId is not null) sql += " AND source_id=@sourceId";
|
||||||
|
if (!string.IsNullOrEmpty(pathPrefix))
|
||||||
|
sql += " AND (path_rel=@pathPrefix OR path_rel LIKE @like ESCAPE '\\')";
|
||||||
|
sql += " ORDER BY content_hash, file_id";
|
||||||
|
var like = string.IsNullOrEmpty(pathPrefix) ? null : pathPrefix.Replace("\\", "\\\\") + "\\\\%";
|
||||||
|
var rows = (await conn.QueryAsync<EntryRow>(sql, new { sourceId, pathPrefix, like }).ConfigureAwait(false))
|
||||||
|
.Select(r => r.ToModel())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(e => Convert.ToHexString(e.ContentHash!))
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Take(take)
|
||||||
|
.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])
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Explorer.Storage.Sqlite/DapperSetup.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using Dapper;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class EnumStringHandler<T> : SqlMapper.TypeHandler<T> where T : struct, Enum
|
||||||
|
{
|
||||||
|
public override void SetValue(IDbDataParameter parameter, T value)
|
||||||
|
=> parameter.Value = value.ToString();
|
||||||
|
|
||||||
|
public override T Parse(object value)
|
||||||
|
=> Enum.Parse<T>(Convert.ToString(value) ?? "", ignoreCase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class DapperSetup
|
||||||
|
{
|
||||||
|
private static int _done;
|
||||||
|
|
||||||
|
public static void Ensure()
|
||||||
|
{
|
||||||
|
if (Interlocked.Exchange(ref _done, 1) == 1)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.ScanKind>());
|
||||||
|
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.ScanJobStatus>());
|
||||||
|
SqlMapper.AddTypeHandler(new EnumStringHandler<Explorer.Domain.SourceStatus>());
|
||||||
|
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class DateTimeOffsetHandler : SqlMapper.TypeHandler<DateTimeOffset?>
|
||||||
|
{
|
||||||
|
public override void SetValue(IDbDataParameter parameter, DateTimeOffset? value)
|
||||||
|
=> parameter.Value = value?.ToString("O") ?? (object)DBNull.Value;
|
||||||
|
|
||||||
|
public override DateTimeOffset? Parse(object value)
|
||||||
|
=> value is null or DBNull ? null : DateTimeOffset.Parse(Convert.ToString(value)!);
|
||||||
|
}
|
||||||
353
src/Explorer.Storage.Sqlite/EntryStore.cs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class EntryStore : IEntryStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
|
||||||
|
public EntryStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public async Task<IndexEntry?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>("SELECT * FROM entries WHERE id=@id", new { id }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IndexEntry?> GetRootAsync(long sourceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND parent_id IS NULL LIMIT 1",
|
||||||
|
new { sourceId }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IndexEntry?> GetByPathAsync(long sourceId, string pathRel, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND path_rel=@pathRel COLLATE NOCASE LIMIT 1",
|
||||||
|
new { sourceId, pathRel }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IndexEntry?> GetByParentNameAsync(long sourceId, long? parentId, string nameNorm, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) AND name_norm=@nameNorm LIMIT 1",
|
||||||
|
new { sourceId, parentId, nameNorm }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IndexEntry?> GetByFileIdAsync(long sourceId, long fileId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND file_id=@fileId AND status=0 LIMIT 1",
|
||||||
|
new { sourceId, fileId }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> GetChildrenAsync(long sourceId, long? parentId, EntryStatus? status = EntryStatus.Present, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
IEnumerable<EntryRow> rows;
|
||||||
|
if (status is null)
|
||||||
|
{
|
||||||
|
rows = await conn.QueryAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) ORDER BY is_dir DESC, name_norm",
|
||||||
|
new { sourceId, parentId }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
rows = await conn.QueryAsync<EntryRow>(
|
||||||
|
"SELECT * FROM entries WHERE source_id=@sourceId AND ifnull(parent_id,-1)=ifnull(@parentId,-1) AND status=@st ORDER BY is_dir DESC, name_norm",
|
||||||
|
new { sourceId, parentId, st = (int)status.Value }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.Select(r => r.ToModel()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpsertBatchAsync(IReadOnlyList<IndexEntry> entries, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
await UpsertCore(conn, entry).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task<long> UpsertAsync(IndexEntry entry, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => UpsertCore(conn, entry), cancellationToken);
|
||||||
|
|
||||||
|
internal static async Task<long> UpsertCore(SqliteConnection conn, IndexEntry entry)
|
||||||
|
{
|
||||||
|
var existing = await SqliteExec.ScalarAsync<long?>(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM entries WHERE source_id=@SourceId AND ifnull(parent_id,-1)=ifnull(@ParentId,-1) AND name_norm=@NameNorm",
|
||||||
|
new { entry.SourceId, entry.ParentId, entry.NameNorm }).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (existing is > 0)
|
||||||
|
{
|
||||||
|
entry.Id = existing.Value;
|
||||||
|
await SqliteExec.ExecuteAsync(conn, """
|
||||||
|
UPDATE entries SET
|
||||||
|
name=@Name, extension=@Extension, is_dir=@IsDir, size_bytes=@SizeBytes,
|
||||||
|
created_utc=@CreatedUtc, modified_utc=@ModifiedUtc, last_seen_utc=@LastSeenUtc,
|
||||||
|
last_indexed_utc=@LastIndexedUtc, attributes=@Attributes, file_id=@FileId,
|
||||||
|
parent_file_id=@ParentFileId, reparse_tag=@ReparseTag, status=@Status,
|
||||||
|
deleted_utc=@DeletedUtc, path_rel=@PathRel, scan_generation=@ScanGeneration,
|
||||||
|
allocated_size=@AllocatedSizeBytes, cloud_availability=@CloudAvailability
|
||||||
|
WHERE id=@Id
|
||||||
|
""", ToArgs(entry)).ConfigureAwait(false);
|
||||||
|
return entry.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
var id = await SqliteExec.InsertAsync(conn, """
|
||||||
|
INSERT INTO entries (source_id, parent_id, name, name_norm, extension, is_dir, size_bytes, aggregate_size,
|
||||||
|
child_file_count, child_dir_count, created_utc, modified_utc, last_seen_utc, last_indexed_utc,
|
||||||
|
attributes, file_id, parent_file_id, reparse_tag, status, deleted_utc, path_rel, content_hash, hash_state, scan_generation,
|
||||||
|
allocated_size, cloud_availability)
|
||||||
|
VALUES (@SourceId, @ParentId, @Name, @NameNorm, @Extension, @IsDir, @SizeBytes, @AggregateSize,
|
||||||
|
@ChildFileCount, @ChildDirCount, @CreatedUtc, @ModifiedUtc, @LastSeenUtc, @LastIndexedUtc,
|
||||||
|
@Attributes, @FileId, @ParentFileId, @ReparseTag, @Status, @DeletedUtc, @PathRel, @ContentHash, @HashState, @ScanGeneration,
|
||||||
|
@AllocatedSizeBytes, @CloudAvailability)
|
||||||
|
""", ToArgs(entry)).ConfigureAwait(false);
|
||||||
|
entry.Id = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpdateAggregatesAsync(long id, long aggregateSize, int files, int dirs, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE entries SET aggregate_size=@aggregateSize, child_file_count=@files, child_dir_count=@dirs WHERE id=@id",
|
||||||
|
new { id, aggregateSize, files, dirs }), cancellationToken);
|
||||||
|
|
||||||
|
public Task ApplySizeDeltaToAncestorsAsync(long? parentId, long sizeDelta, int fileDelta, int dirDelta, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => ApplyDelta(conn, parentId, sizeDelta, fileDelta, dirDelta), cancellationToken);
|
||||||
|
|
||||||
|
internal static async Task ApplyDelta(SqliteConnection conn, long? parentId, long sizeDelta, int fileDelta, int dirDelta)
|
||||||
|
{
|
||||||
|
var current = parentId;
|
||||||
|
while (current is > 0)
|
||||||
|
{
|
||||||
|
await SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE entries SET aggregate_size = aggregate_size + @sizeDelta, child_file_count = child_file_count + @fileDelta, child_dir_count = child_dir_count + @dirDelta WHERE id=@id",
|
||||||
|
new { id = current.Value, sizeDelta, fileDelta, dirDelta }).ConfigureAwait(false);
|
||||||
|
current = await SqliteExec.ScalarAsync<long?>(
|
||||||
|
conn, "SELECT parent_id FROM entries WHERE id=@id", new { id = current.Value }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task MarkMissingAsDeletedAsync(long sourceId, long generation, DateTimeOffset utc, string? pathRelPrefix, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
var sql = """
|
||||||
|
UPDATE entries SET status=2, deleted_utc=@utc
|
||||||
|
WHERE source_id=@sourceId AND status=0 AND scan_generation < @generation
|
||||||
|
""";
|
||||||
|
if (!string.IsNullOrEmpty(pathRelPrefix))
|
||||||
|
{
|
||||||
|
sql += " AND (path_rel = @prefix OR path_rel LIKE @like ESCAPE '\\')";
|
||||||
|
}
|
||||||
|
|
||||||
|
await SqliteExec.ExecuteAsync(conn, sql, new
|
||||||
|
{
|
||||||
|
sourceId,
|
||||||
|
generation,
|
||||||
|
utc = utc.ToString("O"),
|
||||||
|
prefix = pathRelPrefix,
|
||||||
|
like = EscapeLike(pathRelPrefix) + "\\\\%"
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task MarkSourceOfflineAsync(long sourceId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(
|
||||||
|
conn, "UPDATE entries SET status=1 WHERE source_id=@sourceId AND status=0",
|
||||||
|
new { sourceId }), cancellationToken);
|
||||||
|
|
||||||
|
public Task MarkSourceOnlinePresentAsync(long sourceId, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(
|
||||||
|
conn, "UPDATE entries SET status=0 WHERE source_id=@sourceId AND status=1",
|
||||||
|
new { sourceId }), cancellationToken);
|
||||||
|
|
||||||
|
public Task TombstoneAsync(long id, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
var isDir = await SqliteExec.ScalarAsync<long?>(conn, "SELECT is_dir FROM entries WHERE id=@id", new { id })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (isDir is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentId = await SqliteExec.ScalarAsync<long?>(conn, "SELECT parent_id FROM entries WHERE id=@id", new { id })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var size = await SqliteExec.ScalarAsync<long>(conn, "SELECT size_bytes FROM entries WHERE id=@id", new { id })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var aggregate = await SqliteExec.ScalarAsync<long>(conn, "SELECT aggregate_size FROM entries WHERE id=@id", new { id })
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE entries SET status=2, deleted_utc=@utc WHERE id=@id",
|
||||||
|
new { id, utc = utc.ToString("O") }).ConfigureAwait(false);
|
||||||
|
if (isDir == 0)
|
||||||
|
{
|
||||||
|
await ApplyDelta(conn, parentId, -size, -1, 0).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ApplyDelta(conn, parentId, -aggregate, 0, -1).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task DeleteExpiredTombstonesAsync(DateTimeOffset cutoffUtc, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"DELETE FROM entries WHERE status=2 AND deleted_utc IS NOT NULL AND deleted_utc < @cutoff",
|
||||||
|
new { cutoff = cutoffUtc.ToString("O") }), cancellationToken);
|
||||||
|
|
||||||
|
public Task RenameSubtreePathAsync(long sourceId, string oldPathRel, string newPathRel, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(oldPathRel))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE entries SET path_rel=@newPathRel WHERE source_id=@sourceId AND path_rel=@oldPathRel",
|
||||||
|
new { sourceId, oldPathRel, newPathRel }).ConfigureAwait(false);
|
||||||
|
var like = EscapeLike(oldPathRel) + "\\\\%";
|
||||||
|
await SqliteExec.ExecuteAsync(conn, """
|
||||||
|
UPDATE entries
|
||||||
|
SET path_rel = @newPathRel || substr(path_rel, @oldLen)
|
||||||
|
WHERE source_id=@sourceId AND path_rel LIKE @like ESCAPE '\'
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
sourceId,
|
||||||
|
newPathRel,
|
||||||
|
oldLen = oldPathRel.Length + 1,
|
||||||
|
like
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<long> CountPresentAsync(long sourceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await conn.ExecuteScalarAsync<long>(
|
||||||
|
"SELECT COUNT(*) FROM entries WHERE source_id=@sourceId AND status=0",
|
||||||
|
new { sourceId }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeLike(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("%", "\\%", StringComparison.Ordinal)
|
||||||
|
.Replace("_", "\\_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToArgs(IndexEntry e) => new
|
||||||
|
{
|
||||||
|
e.Id,
|
||||||
|
e.SourceId,
|
||||||
|
e.ParentId,
|
||||||
|
e.Name,
|
||||||
|
e.NameNorm,
|
||||||
|
e.Extension,
|
||||||
|
IsDir = e.IsDirectory ? 1 : 0,
|
||||||
|
e.SizeBytes,
|
||||||
|
e.AggregateSize,
|
||||||
|
e.ChildFileCount,
|
||||||
|
e.ChildDirCount,
|
||||||
|
CreatedUtc = e.CreatedUtc?.ToString("O"),
|
||||||
|
ModifiedUtc = e.ModifiedUtc?.ToString("O"),
|
||||||
|
LastSeenUtc = e.LastSeenUtc.ToString("O"),
|
||||||
|
LastIndexedUtc = e.LastIndexedUtc?.ToString("O"),
|
||||||
|
e.Attributes,
|
||||||
|
e.FileId,
|
||||||
|
e.ParentFileId,
|
||||||
|
e.ReparseTag,
|
||||||
|
Status = (int)e.Status,
|
||||||
|
DeletedUtc = e.DeletedUtc?.ToString("O"),
|
||||||
|
e.PathRel,
|
||||||
|
e.ContentHash,
|
||||||
|
HashState = (int)e.HashState,
|
||||||
|
e.ScanGeneration,
|
||||||
|
e.AllocatedSizeBytes,
|
||||||
|
CloudAvailability = e.CloudAvailability is { } a ? (int?)a : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class EntryRow
|
||||||
|
{
|
||||||
|
public long id { get; set; }
|
||||||
|
public long source_id { get; set; }
|
||||||
|
public long? parent_id { get; set; }
|
||||||
|
public string name { get; set; } = "";
|
||||||
|
public string name_norm { get; set; } = "";
|
||||||
|
public string? extension { get; set; }
|
||||||
|
public int is_dir { get; set; }
|
||||||
|
public long size_bytes { get; set; }
|
||||||
|
public long aggregate_size { get; set; }
|
||||||
|
public int child_file_count { get; set; }
|
||||||
|
public int child_dir_count { get; set; }
|
||||||
|
public string? created_utc { get; set; }
|
||||||
|
public string? modified_utc { get; set; }
|
||||||
|
public string last_seen_utc { get; set; } = "";
|
||||||
|
public string? last_indexed_utc { get; set; }
|
||||||
|
public int attributes { get; set; }
|
||||||
|
public long? file_id { get; set; }
|
||||||
|
public long? parent_file_id { get; set; }
|
||||||
|
public int reparse_tag { get; set; }
|
||||||
|
public int status { get; set; }
|
||||||
|
public string? deleted_utc { get; set; }
|
||||||
|
public string path_rel { get; set; } = "";
|
||||||
|
public byte[]? content_hash { get; set; }
|
||||||
|
public int hash_state { get; set; }
|
||||||
|
public long scan_generation { get; set; }
|
||||||
|
public long? allocated_size { get; set; }
|
||||||
|
public int? cloud_availability { get; set; }
|
||||||
|
|
||||||
|
public IndexEntry ToModel() => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
SourceId = source_id,
|
||||||
|
ParentId = parent_id,
|
||||||
|
Name = name,
|
||||||
|
NameNorm = name_norm,
|
||||||
|
Extension = extension,
|
||||||
|
IsDirectory = is_dir != 0,
|
||||||
|
SizeBytes = size_bytes,
|
||||||
|
AggregateSize = aggregate_size,
|
||||||
|
ChildFileCount = child_file_count,
|
||||||
|
ChildDirCount = child_dir_count,
|
||||||
|
CreatedUtc = Parse(created_utc),
|
||||||
|
ModifiedUtc = Parse(modified_utc),
|
||||||
|
LastSeenUtc = DateTimeOffset.Parse(last_seen_utc),
|
||||||
|
LastIndexedUtc = Parse(last_indexed_utc),
|
||||||
|
Attributes = attributes,
|
||||||
|
FileId = file_id,
|
||||||
|
ParentFileId = parent_file_id,
|
||||||
|
ReparseTag = reparse_tag,
|
||||||
|
Status = (EntryStatus)status,
|
||||||
|
DeletedUtc = Parse(deleted_utc),
|
||||||
|
PathRel = path_rel,
|
||||||
|
ContentHash = content_hash,
|
||||||
|
HashState = (HashState)hash_state,
|
||||||
|
ScanGeneration = scan_generation,
|
||||||
|
AllocatedSizeBytes = allocated_size,
|
||||||
|
CloudAvailability = cloud_availability is { } a ? (CloudAvailability)a : null
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateTimeOffset? Parse(string? v)
|
||||||
|
=> string.IsNullOrEmpty(v) ? null : DateTimeOffset.Parse(v);
|
||||||
|
}
|
||||||
17
src/Explorer.Storage.Sqlite/Explorer.Storage.Sqlite.csproj
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<RootNamespace>Explorer.Storage.Sqlite</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Explorer.Application\Explorer.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\Explorer.Domain\Explorer.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Schema\V1.sql" LogicalName="Explorer.Storage.Sqlite.Schema.V1.sql" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
182
src/Explorer.Storage.Sqlite/MiscStores.cs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class ExcludeStore : IExcludeStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public ExcludeStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExcludeRule>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<(long id, string? scope, long? source_id, string kind, string pattern, int enabled)>(
|
||||||
|
"SELECT id, scope, source_id, kind, pattern, enabled FROM excludes").ConfigureAwait(false);
|
||||||
|
return rows.Select(r => new ExcludeRule
|
||||||
|
{
|
||||||
|
Id = r.id,
|
||||||
|
Scope = r.scope ?? "global",
|
||||||
|
SourceId = r.source_id,
|
||||||
|
Kind = Enum.Parse<ExcludeKind>(r.kind),
|
||||||
|
Pattern = r.pattern,
|
||||||
|
Enabled = r.enabled != 0
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<long> AddAsync(ExcludeRule rule, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
await conn.ExecuteAsync("""
|
||||||
|
INSERT OR IGNORE INTO excludes (scope, source_id, kind, pattern, enabled)
|
||||||
|
VALUES (@Scope, @SourceId, @Kind, @Pattern, @Enabled)
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
rule.Scope,
|
||||||
|
rule.SourceId,
|
||||||
|
Kind = rule.Kind.ToString(),
|
||||||
|
rule.Pattern,
|
||||||
|
Enabled = rule.Enabled ? 1 : 0
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
return await conn.ExecuteScalarAsync<long>("""
|
||||||
|
SELECT id FROM excludes
|
||||||
|
WHERE ifnull(source_id,-1)=ifnull(@SourceId,-1) AND kind=@Kind AND pattern=@Pattern
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
rule.SourceId,
|
||||||
|
Kind = rule.Kind.ToString(),
|
||||||
|
rule.Pattern
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task RemoveAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("DELETE FROM excludes WHERE id=@id", new { id }), cancellationToken);
|
||||||
|
|
||||||
|
public Task EnsureDefaultsAsync(IReadOnlyList<ExcludeRule> defaults, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.RunWriteAsync(async _ =>
|
||||||
|
{
|
||||||
|
foreach (var rule in defaults)
|
||||||
|
{
|
||||||
|
await AddAsync(rule, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class ScanJobStore : IScanJobStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public ScanJobStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task<long> InsertAsync(ScanJob job, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
var id = await SqliteInsert.ExecuteAsync(conn, """
|
||||||
|
INSERT INTO scan_jobs (source_id, kind, status, started_utc, finished_utc, files_seen, dirs_seen, bytes_seen, resume_path, error_count, last_error, folder_path_rel)
|
||||||
|
VALUES (@SourceId, @Kind, @Status, @StartedUtc, @FinishedUtc, @FilesSeen, @DirsSeen, @BytesSeen, @ResumePath, @ErrorCount, @LastError, @FolderPathRel);
|
||||||
|
""", Args(job)).ConfigureAwait(false);
|
||||||
|
job.Id = id;
|
||||||
|
return id;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateAsync(ScanJob job, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
UPDATE scan_jobs SET status=@Status, started_utc=@StartedUtc, finished_utc=@FinishedUtc,
|
||||||
|
files_seen=@FilesSeen, dirs_seen=@DirsSeen, bytes_seen=@BytesSeen, resume_path=@ResumePath,
|
||||||
|
error_count=@ErrorCount, last_error=@LastError, folder_path_rel=@FolderPathRel
|
||||||
|
WHERE id=@Id
|
||||||
|
""", Args(job)), cancellationToken);
|
||||||
|
|
||||||
|
public Task InterruptRunningAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
UPDATE scan_jobs SET status='Interrupted', finished_utc=@utc
|
||||||
|
WHERE status IN ('Running','Queued')
|
||||||
|
""", new { utc = DateTimeOffset.UtcNow.ToString("O") }), cancellationToken);
|
||||||
|
|
||||||
|
public Task AddErrorAsync(ScanError error, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => conn.ExecuteAsync("""
|
||||||
|
INSERT INTO scan_errors (job_id, path, kind, message, utc)
|
||||||
|
VALUES (@JobId, @Path, @Kind, @Message, @Utc)
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
error.JobId,
|
||||||
|
error.Path,
|
||||||
|
error.Kind,
|
||||||
|
error.Message,
|
||||||
|
Utc = error.Utc.ToString("O")
|
||||||
|
}), cancellationToken);
|
||||||
|
|
||||||
|
public async Task<ScanJob?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await conn.QuerySingleOrDefaultAsync<ScanJob>(
|
||||||
|
"SELECT id AS Id, source_id AS SourceId, kind AS Kind, status AS Status, started_utc AS StartedUtc, finished_utc AS FinishedUtc, files_seen AS FilesSeen, dirs_seen AS DirsSeen, bytes_seen AS BytesSeen, resume_path AS ResumePath, error_count AS ErrorCount, last_error AS LastError, folder_path_rel AS FolderPathRel FROM scan_jobs WHERE id=@id",
|
||||||
|
new { id }).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ScanJob>> GetRecentAsync(int take, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<ScanJob>(
|
||||||
|
"SELECT id AS Id, source_id AS SourceId, kind AS Kind, status AS Status, started_utc AS StartedUtc, finished_utc AS FinishedUtc, files_seen AS FilesSeen, dirs_seen AS DirsSeen, bytes_seen AS BytesSeen, resume_path AS ResumePath, error_count AS ErrorCount, last_error AS LastError, folder_path_rel AS FolderPathRel FROM scan_jobs ORDER BY id DESC LIMIT @take",
|
||||||
|
new { take }).ConfigureAwait(false);
|
||||||
|
return rows.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object Args(ScanJob j) => new
|
||||||
|
{
|
||||||
|
j.Id,
|
||||||
|
j.SourceId,
|
||||||
|
Kind = j.Kind.ToString(),
|
||||||
|
Status = j.Status.ToString(),
|
||||||
|
StartedUtc = j.StartedUtc?.ToString("O"),
|
||||||
|
FinishedUtc = j.FinishedUtc?.ToString("O"),
|
||||||
|
j.FilesSeen,
|
||||||
|
j.DirsSeen,
|
||||||
|
j.BytesSeen,
|
||||||
|
j.ResumePath,
|
||||||
|
j.ErrorCount,
|
||||||
|
j.LastError,
|
||||||
|
j.FolderPathRel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class TransferStore : ITransferStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public TransferStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public Task<long> InsertAsync(TransferJob job, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
WHERE id=@Id
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.BytesTotal,
|
||||||
|
job.BytesDone,
|
||||||
|
job.Error
|
||||||
|
}), cancellationToken);
|
||||||
|
}
|
||||||
170
src/Explorer.Storage.Sqlite/Schema/V1.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
CREATE TABLE sources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
stable_key TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
volume_guid TEXT,
|
||||||
|
volume_serial INTEGER,
|
||||||
|
filesystem TEXT,
|
||||||
|
label TEXT,
|
||||||
|
capacity_bytes INTEGER,
|
||||||
|
device_instance_id TEXT,
|
||||||
|
last_root_path TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_seen_utc TEXT,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
usn_journal_id INTEGER,
|
||||||
|
usn_next INTEGER,
|
||||||
|
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE entries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||||
|
parent_id INTEGER REFERENCES entries(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_norm TEXT NOT NULL,
|
||||||
|
extension TEXT,
|
||||||
|
is_dir INTEGER NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
aggregate_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
child_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
child_dir_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_utc TEXT,
|
||||||
|
modified_utc TEXT,
|
||||||
|
last_seen_utc TEXT NOT NULL,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
attributes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
file_id INTEGER,
|
||||||
|
parent_file_id INTEGER,
|
||||||
|
reparse_tag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_utc TEXT,
|
||||||
|
path_rel TEXT NOT NULL,
|
||||||
|
content_hash BLOB,
|
||||||
|
hash_state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scan_generation INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ix_entries_identity ON entries(source_id, ifnull(parent_id, -1), name_norm);
|
||||||
|
CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status);
|
||||||
|
CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0;
|
||||||
|
CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0;
|
||||||
|
CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc);
|
||||||
|
CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL;
|
||||||
|
CREATE INDEX ix_entries_path ON entries(source_id, path_rel);
|
||||||
|
CREATE INDEX ix_entries_status ON entries(source_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_entries_dir_agg ON entries(source_id, aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_entries_dir_agg_all ON entries(aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_entries_file_size_all ON entries(size_bytes DESC) WHERE is_dir = 0 AND status = 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_entries_ext_usage ON entries(source_id, extension, size_bytes) WHERE is_dir = 0 AND status = 0;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_entries_ext_usage_all ON entries(extension, size_bytes) WHERE is_dir = 0 AND status = 0;
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
||||||
|
name,
|
||||||
|
name_norm,
|
||||||
|
extension,
|
||||||
|
content = 'entries',
|
||||||
|
content_rowid = 'id',
|
||||||
|
tokenize = 'unicode61'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(rowid, name, name_norm, extension)
|
||||||
|
VALUES (new.id, new.name, new.name_norm, new.extension);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
|
||||||
|
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
|
||||||
|
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
|
||||||
|
INSERT INTO entries_fts(rowid, name, name_norm, extension)
|
||||||
|
VALUES (new.id, new.name, new.name_norm, new.extension);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TABLE excludes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scope TEXT,
|
||||||
|
source_id INTEGER REFERENCES sources(id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX ix_excludes_pattern ON excludes(ifnull(source_id, -1), kind, pattern);
|
||||||
|
|
||||||
|
CREATE TABLE scan_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_utc TEXT,
|
||||||
|
finished_utc TEXT,
|
||||||
|
files_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dirs_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resume_path TEXT,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
folder_path_rel TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE scan_errors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
path TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
message TEXT,
|
||||||
|
utc TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE transfer_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
op TEXT NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bytes_total INTEGER,
|
||||||
|
bytes_done INTEGER,
|
||||||
|
created_utc TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE hash_queue (
|
||||||
|
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE source_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
total_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL,
|
||||||
|
dir_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE directory_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
path_rel TEXT NOT NULL,
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
aggregate_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30');
|
||||||
196
src/Explorer.Storage.Sqlite/SchemaScript.cs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal static class SchemaScript
|
||||||
|
{
|
||||||
|
public static readonly string[] AnalysisIndexes =
|
||||||
|
[
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_entries_dir_agg ON entries(source_id, aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_entries_dir_agg_all ON entries(aggregate_size DESC) WHERE is_dir = 1 AND status = 0 AND parent_id IS NOT NULL",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_entries_file_size_all ON entries(size_bytes DESC) WHERE is_dir = 0 AND status = 0",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_entries_ext_usage ON entries(source_id, extension, size_bytes) WHERE is_dir = 0 AND status = 0",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_entries_ext_usage_all ON entries(extension, size_bytes) WHERE is_dir = 0 AND status = 0"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly string[] Statements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE sources (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
stable_key TEXT NOT NULL UNIQUE,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
volume_guid TEXT,
|
||||||
|
volume_serial INTEGER,
|
||||||
|
filesystem TEXT,
|
||||||
|
label TEXT,
|
||||||
|
capacity_bytes INTEGER,
|
||||||
|
device_instance_id TEXT,
|
||||||
|
last_root_path TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_seen_utc TEXT,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
usn_journal_id INTEGER,
|
||||||
|
usn_next INTEGER,
|
||||||
|
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE entries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||||
|
parent_id INTEGER REFERENCES entries(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
name_norm TEXT NOT NULL,
|
||||||
|
extension TEXT,
|
||||||
|
is_dir INTEGER NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
aggregate_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
child_file_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
child_dir_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_utc TEXT,
|
||||||
|
modified_utc TEXT,
|
||||||
|
last_seen_utc TEXT NOT NULL,
|
||||||
|
last_indexed_utc TEXT,
|
||||||
|
attributes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
file_id INTEGER,
|
||||||
|
parent_file_id INTEGER,
|
||||||
|
reparse_tag INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deleted_utc TEXT,
|
||||||
|
path_rel TEXT NOT NULL,
|
||||||
|
content_hash BLOB,
|
||||||
|
hash_state INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scan_generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
allocated_size INTEGER,
|
||||||
|
cloud_availability INTEGER
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX ix_entries_identity ON entries(source_id, ifnull(parent_id, -1), name_norm)",
|
||||||
|
"CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status)",
|
||||||
|
"CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0",
|
||||||
|
"CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0",
|
||||||
|
"CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc)",
|
||||||
|
"CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL",
|
||||||
|
"CREATE INDEX ix_entries_path ON entries(source_id, path_rel)",
|
||||||
|
"CREATE INDEX ix_entries_status ON entries(source_id, status)",
|
||||||
|
..AnalysisIndexes,
|
||||||
|
"""
|
||||||
|
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
||||||
|
name,
|
||||||
|
name_norm,
|
||||||
|
extension,
|
||||||
|
content = 'entries',
|
||||||
|
content_rowid = 'id',
|
||||||
|
tokenize = 'unicode61'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(rowid, name, name_norm, extension)
|
||||||
|
VALUES (new.id, new.name, new.name_norm, new.extension);
|
||||||
|
END
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
|
||||||
|
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
|
||||||
|
END
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
|
||||||
|
INSERT INTO entries_fts(entries_fts, rowid, name, name_norm, extension)
|
||||||
|
VALUES ('delete', old.id, old.name, old.name_norm, old.extension);
|
||||||
|
INSERT INTO entries_fts(rowid, name, name_norm, extension)
|
||||||
|
VALUES (new.id, new.name, new.name_norm, new.extension);
|
||||||
|
END
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE excludes (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scope TEXT,
|
||||||
|
source_id INTEGER REFERENCES sources(id),
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE UNIQUE INDEX ix_excludes_pattern ON excludes(ifnull(source_id, -1), kind, pattern)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE scan_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_utc TEXT,
|
||||||
|
finished_utc TEXT,
|
||||||
|
files_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
dirs_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_seen INTEGER NOT NULL DEFAULT 0,
|
||||||
|
resume_path TEXT,
|
||||||
|
error_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error TEXT,
|
||||||
|
folder_path_rel TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE scan_errors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
job_id INTEGER NOT NULL,
|
||||||
|
path TEXT,
|
||||||
|
kind TEXT,
|
||||||
|
message TEXT,
|
||||||
|
utc TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE transfer_jobs (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
op TEXT NOT NULL,
|
||||||
|
src TEXT NOT NULL,
|
||||||
|
dst TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
bytes_total INTEGER,
|
||||||
|
bytes_done INTEGER,
|
||||||
|
created_utc TEXT NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE hash_queue (
|
||||||
|
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE source_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
total_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL,
|
||||||
|
dir_count INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE directory_stats_history (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_id INTEGER NOT NULL,
|
||||||
|
path_rel TEXT NOT NULL,
|
||||||
|
captured_utc TEXT NOT NULL,
|
||||||
|
aggregate_size INTEGER NOT NULL,
|
||||||
|
file_count INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"INSERT INTO settings(key, value) VALUES ('tombstone_retention_days', '30')"
|
||||||
|
];
|
||||||
|
}
|
||||||
153
src/Explorer.Storage.Sqlite/SearchStore.cs
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class SearchStore : ISearchStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
public SearchStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IndexEntry>> SearchAsync(SearchRequest request, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var sql = new System.Text.StringBuilder("""
|
||||||
|
SELECT e.* FROM entries e
|
||||||
|
WHERE 1=1
|
||||||
|
""");
|
||||||
|
var args = new DynamicParameters();
|
||||||
|
|
||||||
|
if (!request.IncludeDeleted)
|
||||||
|
{
|
||||||
|
sql.Append(request.IncludeOffline
|
||||||
|
? " AND e.status IN (0, 1)"
|
||||||
|
: " AND e.status = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SourceIds is { Count: > 0 })
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.source_id IN @SourceIds");
|
||||||
|
args.Add("SourceIds", request.SourceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.PathRelPrefix) || request.DirectChildrenOnly)
|
||||||
|
{
|
||||||
|
if (request.DirectChildrenOnly)
|
||||||
|
{
|
||||||
|
sql.Append("""
|
||||||
|
AND e.parent_id = (
|
||||||
|
SELECT p.id FROM entries p
|
||||||
|
WHERE p.source_id = e.source_id AND p.path_rel = @Prefix
|
||||||
|
LIMIT 1)
|
||||||
|
""");
|
||||||
|
args.Add("Prefix", request.PathRelPrefix ?? "");
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(request.PathRelPrefix))
|
||||||
|
{
|
||||||
|
sql.Append(" AND (e.path_rel = @Prefix OR e.path_rel LIKE @PrefixLike ESCAPE '\\')");
|
||||||
|
args.Add("Prefix", request.PathRelPrefix);
|
||||||
|
args.Add("PrefixLike", EscapeLike(request.PathRelPrefix) + "\\\\%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsDirectory is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.is_dir = @IsDir");
|
||||||
|
args.Add("IsDir", request.IsDirectory.Value ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.Extension))
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.extension = @Ext");
|
||||||
|
args.Add("Ext", NameNormalizer.Normalize(request.Extension.TrimStart('.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MinSize is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.size_bytes >= @MinSize");
|
||||||
|
args.Add("MinSize", request.MinSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.MaxSize is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.size_bytes <= @MaxSize");
|
||||||
|
args.Add("MaxSize", request.MaxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CreatedAfter is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.created_utc >= @CreatedAfter");
|
||||||
|
args.Add("CreatedAfter", request.CreatedAfter.Value.ToString("O"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.CreatedBefore is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.created_utc <= @CreatedBefore");
|
||||||
|
args.Add("CreatedBefore", request.CreatedBefore.Value.ToString("O"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ModifiedAfter is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.modified_utc >= @ModifiedAfter");
|
||||||
|
args.Add("ModifiedAfter", request.ModifiedAfter.Value.ToString("O"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ModifiedBefore is not null)
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.modified_utc <= @ModifiedBefore");
|
||||||
|
args.Add("ModifiedBefore", request.ModifiedBefore.Value.ToString("O"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
{
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
if (name.Contains('*', StringComparison.Ordinal) || name.Contains('?', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
sql.Append(" AND (e.name_norm LIKE @Glob ESCAPE '\\' OR e.name LIKE @Glob ESCAPE '\\')");
|
||||||
|
args.Add("Glob", GlobToLike(name));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sql.Append(" AND e.id IN (SELECT rowid FROM entries_fts WHERE entries_fts MATCH @Fts)");
|
||||||
|
args.Add("Fts", ToFtsQuery(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.Append(" ORDER BY e.is_dir DESC, e.name_norm LIMIT @Take OFFSET @Skip");
|
||||||
|
args.Add("Take", Math.Clamp(request.Take, 1, AppConstants.SearchHardLimit));
|
||||||
|
args.Add("Skip", Math.Max(0, request.Skip));
|
||||||
|
|
||||||
|
var rows = await conn.QueryAsync<EntryRow>(sql.ToString(), args).ConfigureAwait(false);
|
||||||
|
return rows.Select(r => r.ToModel()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string ToFtsQuery(string name)
|
||||||
|
{
|
||||||
|
var token = name.Replace("\"", "\"\"", StringComparison.Ordinal).Trim();
|
||||||
|
if (token.Length == 0)
|
||||||
|
{
|
||||||
|
return "\"\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.Any(ch => !char.IsLetterOrDigit(ch)))
|
||||||
|
{
|
||||||
|
return $"\"{token}\"*";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{token}*";
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GlobToLike(string glob)
|
||||||
|
{
|
||||||
|
var escaped = glob.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("%", "\\%", StringComparison.Ordinal)
|
||||||
|
.Replace("_", "\\_", StringComparison.Ordinal);
|
||||||
|
return escaped.Replace("*", "%", StringComparison.Ordinal).Replace("?", "_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeLike(string value)
|
||||||
|
=> value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||||
|
.Replace("%", "\\%", StringComparison.Ordinal)
|
||||||
|
.Replace("_", "\\_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
182
src/Explorer.Storage.Sqlite/SourceStore.cs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Explorer.Domain;
|
||||||
|
using Explorer.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
internal sealed class SourceStore : ISourceStore
|
||||||
|
{
|
||||||
|
private readonly SqliteIndexStore _store;
|
||||||
|
|
||||||
|
public SourceStore(SqliteIndexStore store) => _store = store;
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Source>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var rows = await conn.QueryAsync<SourceRow>("SELECT * FROM sources ORDER BY display_name").ConfigureAwait(false);
|
||||||
|
return rows.Select(r => r.ToModel()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Source?> GetAsync(long id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT * FROM sources WHERE id = @id";
|
||||||
|
cmd.Parameters.AddWithValue("@id", id);
|
||||||
|
using var reader = cmd.ExecuteReader();
|
||||||
|
if (!reader.Read())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SourceRow
|
||||||
|
{
|
||||||
|
id = reader.GetInt64(reader.GetOrdinal("id")),
|
||||||
|
stable_key = reader.GetString(reader.GetOrdinal("stable_key")),
|
||||||
|
kind = reader.GetString(reader.GetOrdinal("kind")),
|
||||||
|
display_name = reader.GetString(reader.GetOrdinal("display_name")),
|
||||||
|
volume_guid = reader.IsDBNull(reader.GetOrdinal("volume_guid")) ? null : reader.GetString(reader.GetOrdinal("volume_guid")),
|
||||||
|
volume_serial = reader.IsDBNull(reader.GetOrdinal("volume_serial")) ? null : reader.GetInt64(reader.GetOrdinal("volume_serial")),
|
||||||
|
filesystem = reader.IsDBNull(reader.GetOrdinal("filesystem")) ? null : reader.GetString(reader.GetOrdinal("filesystem")),
|
||||||
|
label = reader.IsDBNull(reader.GetOrdinal("label")) ? null : reader.GetString(reader.GetOrdinal("label")),
|
||||||
|
capacity_bytes = reader.IsDBNull(reader.GetOrdinal("capacity_bytes")) ? null : reader.GetInt64(reader.GetOrdinal("capacity_bytes")),
|
||||||
|
device_instance_id = reader.IsDBNull(reader.GetOrdinal("device_instance_id")) ? null : reader.GetString(reader.GetOrdinal("device_instance_id")),
|
||||||
|
last_root_path = reader.IsDBNull(reader.GetOrdinal("last_root_path")) ? null : reader.GetString(reader.GetOrdinal("last_root_path")),
|
||||||
|
status = reader.GetString(reader.GetOrdinal("status")),
|
||||||
|
last_seen_utc = reader.IsDBNull(reader.GetOrdinal("last_seen_utc")) ? null : reader.GetString(reader.GetOrdinal("last_seen_utc")),
|
||||||
|
last_indexed_utc = reader.IsDBNull(reader.GetOrdinal("last_indexed_utc")) ? null : reader.GetString(reader.GetOrdinal("last_indexed_utc")),
|
||||||
|
usn_journal_id = reader.IsDBNull(reader.GetOrdinal("usn_journal_id")) ? null : reader.GetInt64(reader.GetOrdinal("usn_journal_id")),
|
||||||
|
usn_next = reader.IsDBNull(reader.GetOrdinal("usn_next")) ? null : reader.GetInt64(reader.GetOrdinal("usn_next")),
|
||||||
|
scan_generation = reader.GetInt64(reader.GetOrdinal("scan_generation")),
|
||||||
|
last_error = reader.IsDBNull(reader.GetOrdinal("last_error")) ? null : reader.GetString(reader.GetOrdinal("last_error"))
|
||||||
|
}.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Source?> GetByStableKeyAsync(string key, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var conn = await _store.OpenReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
var row = await conn.QuerySingleOrDefaultAsync<SourceRow>("SELECT * FROM sources WHERE stable_key = @key", new { key }).ConfigureAwait(false);
|
||||||
|
return row?.ToModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<long> UpsertAsync(Source source, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
if (source.Id == 0)
|
||||||
|
{
|
||||||
|
const string insert = """
|
||||||
|
INSERT INTO sources (stable_key, kind, display_name, volume_guid, volume_serial, filesystem, label,
|
||||||
|
capacity_bytes, device_instance_id, last_root_path, status, last_seen_utc, last_indexed_utc,
|
||||||
|
usn_journal_id, usn_next, scan_generation, last_error)
|
||||||
|
VALUES (@StableKey, @Kind, @DisplayName, @VolumeGuid, @VolumeSerial, @Filesystem, @Label,
|
||||||
|
@CapacityBytes, @DeviceInstanceId, @LastRootPath, @Status, @LastSeenUtc, @LastIndexedUtc,
|
||||||
|
@UsnJournalId, @UsnNext, @ScanGeneration, @LastError);
|
||||||
|
""";
|
||||||
|
var id = await SqliteInsert.ExecuteAsync(conn, insert, ToArgs(source)).ConfigureAwait(false);
|
||||||
|
source.Id = id;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const string update = """
|
||||||
|
UPDATE sources SET
|
||||||
|
kind=@Kind, display_name=@DisplayName, volume_guid=@VolumeGuid, volume_serial=@VolumeSerial,
|
||||||
|
filesystem=@Filesystem, label=@Label, capacity_bytes=@CapacityBytes,
|
||||||
|
device_instance_id=@DeviceInstanceId, last_root_path=@LastRootPath, status=@Status,
|
||||||
|
last_seen_utc=@LastSeenUtc, last_indexed_utc=@LastIndexedUtc, usn_journal_id=@UsnJournalId,
|
||||||
|
usn_next=@UsnNext, scan_generation=@ScanGeneration, last_error=@LastError
|
||||||
|
WHERE id=@Id;
|
||||||
|
""";
|
||||||
|
await SqliteExec.ExecuteAsync(conn, update, ToArgs(source)).ConfigureAwait(false);
|
||||||
|
return source.Id;
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateStatusAsync(long id, SourceStatus status, string? error, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE sources SET status=@status, last_error=@error WHERE id=@id",
|
||||||
|
new { id, status = status.ToString(), error }), cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateUsnAsync(long id, long journalId, long nextUsn, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE sources SET usn_journal_id=@journalId, usn_next=@nextUsn WHERE id=@id",
|
||||||
|
new { id, journalId, nextUsn }), cancellationToken);
|
||||||
|
|
||||||
|
public Task UpdateIndexedAsync(long id, DateTimeOffset utc, long generation, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE sources SET last_indexed_utc=@utc, scan_generation=@generation, status='Online', last_error=NULL WHERE id=@id",
|
||||||
|
new { id, utc = utc.ToString("O"), generation }), cancellationToken);
|
||||||
|
|
||||||
|
public Task SetLastSeenAsync(long id, string rootPath, DateTimeOffset utc, CancellationToken cancellationToken = default)
|
||||||
|
=> _store.WriteAsync(conn => SqliteExec.ExecuteAsync(conn,
|
||||||
|
"UPDATE sources SET last_root_path=@rootPath, last_seen_utc=@utc WHERE id=@id",
|
||||||
|
new { id, rootPath, utc = utc.ToString("O") }), cancellationToken);
|
||||||
|
|
||||||
|
private static object ToArgs(Source s) => new
|
||||||
|
{
|
||||||
|
s.Id,
|
||||||
|
s.StableKey,
|
||||||
|
Kind = s.Kind.ToString(),
|
||||||
|
s.DisplayName,
|
||||||
|
s.VolumeGuid,
|
||||||
|
s.VolumeSerial,
|
||||||
|
s.Filesystem,
|
||||||
|
s.Label,
|
||||||
|
s.CapacityBytes,
|
||||||
|
s.DeviceInstanceId,
|
||||||
|
s.LastRootPath,
|
||||||
|
Status = s.Status.ToString(),
|
||||||
|
LastSeenUtc = s.LastSeenUtc?.ToString("O"),
|
||||||
|
LastIndexedUtc = s.LastIndexedUtc?.ToString("O"),
|
||||||
|
s.UsnJournalId,
|
||||||
|
s.UsnNext,
|
||||||
|
s.ScanGeneration,
|
||||||
|
s.LastError
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class SourceRow
|
||||||
|
{
|
||||||
|
public long id { get; set; }
|
||||||
|
public string stable_key { get; set; } = "";
|
||||||
|
public string kind { get; set; } = "";
|
||||||
|
public string display_name { get; set; } = "";
|
||||||
|
public string? volume_guid { get; set; }
|
||||||
|
public long? volume_serial { get; set; }
|
||||||
|
public string? filesystem { get; set; }
|
||||||
|
public string? label { get; set; }
|
||||||
|
public long? capacity_bytes { get; set; }
|
||||||
|
public string? device_instance_id { get; set; }
|
||||||
|
public string? last_root_path { get; set; }
|
||||||
|
public string status { get; set; } = "";
|
||||||
|
public string? last_seen_utc { get; set; }
|
||||||
|
public string? last_indexed_utc { get; set; }
|
||||||
|
public long? usn_journal_id { get; set; }
|
||||||
|
public long? usn_next { get; set; }
|
||||||
|
public long scan_generation { get; set; }
|
||||||
|
public string? last_error { get; set; }
|
||||||
|
|
||||||
|
public Source ToModel() => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
StableKey = stable_key,
|
||||||
|
Kind = Enum.Parse<SourceKind>(kind),
|
||||||
|
DisplayName = display_name,
|
||||||
|
VolumeGuid = volume_guid,
|
||||||
|
VolumeSerial = volume_serial,
|
||||||
|
Filesystem = filesystem,
|
||||||
|
Label = label,
|
||||||
|
CapacityBytes = capacity_bytes,
|
||||||
|
DeviceInstanceId = device_instance_id,
|
||||||
|
LastRootPath = last_root_path,
|
||||||
|
Status = Enum.Parse<SourceStatus>(status),
|
||||||
|
LastSeenUtc = Parse(last_seen_utc),
|
||||||
|
LastIndexedUtc = Parse(last_indexed_utc),
|
||||||
|
UsnJournalId = usn_journal_id,
|
||||||
|
UsnNext = usn_next,
|
||||||
|
ScanGeneration = scan_generation,
|
||||||
|
LastError = last_error
|
||||||
|
};
|
||||||
|
|
||||||
|
private static DateTimeOffset? Parse(string? v)
|
||||||
|
=> string.IsNullOrEmpty(v) ? null : DateTimeOffset.Parse(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Explorer.Storage.Sqlite/SqliteExec.cs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Explorer.Storage.Sqlite;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synchronous commands on the writer connection.
|
||||||
|
/// Microsoft.Data.Sqlite async APIs plus Dapper readers can hang (busy connection / sync-context).
|
||||||
|
/// The store already serializes writers, so blocking here is acceptable.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SqliteExec
|
||||||
|
{
|
||||||
|
public static Task ExecuteAsync(SqliteConnection conn, string sql, object? args = null)
|
||||||
|
{
|
||||||
|
Execute(conn, sql, args);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<T?> ScalarAsync<T>(SqliteConnection conn, string sql, object? args = null)
|
||||||
|
=> Task.FromResult(Scalar<T>(conn, sql, args));
|
||||||
|
|
||||||
|
public static Task<long> InsertAsync(SqliteConnection conn, string sql, object args)
|
||||||
|
=> Task.FromResult(Insert(conn, sql, args));
|
||||||
|
|
||||||
|
public static void Execute(SqliteConnection conn, string sql, object? args = null)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandTimeout = 20;
|
||||||
|
cmd.CommandText = Strip(sql);
|
||||||
|
Bind(cmd, args);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static T? Scalar<T>(SqliteConnection conn, string sql, object? args = null)
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandTimeout = 20;
|
||||||
|
cmd.CommandText = Strip(sql);
|
||||||
|
Bind(cmd, args);
|
||||||
|
var value = cmd.ExecuteScalar();
|
||||||
|
if (value is null or DBNull)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is T typed)
|
||||||
|
{
|
||||||
|
return typed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
|
||||||
|
return (T)Convert.ChangeType(value, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long Insert(SqliteConnection conn, string sql, object args)
|
||||||
|
{
|
||||||
|
Execute(conn, sql, args);
|
||||||
|
return Scalar<long>(conn, "SELECT last_insert_rowid()")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Strip(string sql) => sql.Trim().TrimEnd(';');
|
||||||
|
|
||||||
|
private static void Bind(SqliteCommand cmd, object? args)
|
||||||
|
{
|
||||||
|
if (args is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var prop in args.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
|
||||||
|
{
|
||||||
|
var value = prop.GetValue(args) ?? DBNull.Value;
|
||||||
|
cmd.Parameters.AddWithValue("@" + prop.Name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||