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>
This commit is contained in:
2026-08-22 12:43:05 +02:00
commit e9aba73552
130 changed files with 15110 additions and 0 deletions

37
.gitignore vendored Normal file
View 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
View 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
View 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
View 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 10000).
- `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
View 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 **12**.
5. Pro Batch (20005000 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 → **24 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 vonbis, 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 10000, 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>
→ 12 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 300500 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 (23 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 (12 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 13 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 folders children in SQL (not a client-side filter after paging).
13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

6
global.json Normal file
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.301",
"rollForward": "latestFeature"
}
}

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

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

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

View 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
View 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="&#xE977;" FontSize="14"
Margin="0,0,6,0" VerticalAlignment="Center" Foreground="{DynamicResource Accent}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Label}" Value="This PC">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" Foreground="{DynamicResource Fg}"/>
</StackPanel>
</Button>
<TextBlock Text="" Margin="2,0,2,0" VerticalAlignment="Center" Foreground="{DynamicResource FgMuted}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsLast}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</DataTemplate>
<Style x:Key="PaneChrome" TargetType="Border">
<Setter Property="SnapsToDevicePixels" Value="True"/>
<Setter Property="BorderBrush" Value="{DynamicResource Stroke}"/>
<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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Domain</RootNamespace>
</PropertyGroup>
</Project>

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

View 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 + @"\…";
}
}

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>Explorer.Plugin.Abstractions</RootNamespace>
</PropertyGroup>
</Project>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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