commit e9aba73552051bc8de1f9d12295a2ab137030b25 Author: netquick Date: Sat Aug 22 12:43:05 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46e65c4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..a782e0f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + latest + false + + en + Explorer Workbench + Explorer Workbench + Explorer Workbench + 0.1.0.0 + 0.1.0.0 + 0.1.0 + true + + diff --git a/Explorer.slnx b/Explorer.slnx new file mode 100644 index 0000000..4563adf --- /dev/null +++ b/Explorer.slnx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cdf7e1 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Explorer Workbench + +Working name for a Windows file manager with **persistent memory**: live Explorer browsing plus a SQLite index for search, folder sizes, offline media and storage analysis. + +Mental model: + +> Open Explorer → see files → manage files → search immediately → analyze storage. + +The index is infrastructure. You never need to know about SQLite, FTS or change journals to use the app. + +## Requirements + +- Windows 10 version 1809 or later, or Windows 11 +- [.NET 10 SDK](https://dotnet.microsoft.com/download) (this repo pins `10.0.301` in `global.json`) +- No administrator privileges required + +## Build + +```powershell +dotnet build Explorer.slnx -c Release +``` + +## Run + +```powershell +dotnet run --project src/Explorer.App -c Release +``` + +The executable is `src/Explorer.App/bin/Release/net10.0-windows/Explorer.App.exe`. + +## Tests + +```powershell +dotnet test Explorer.slnx -c Release +``` + +## Architecture overview + +Modular monolith (see `docs/ARCHITECTURE.md`): + +| Project | Role | +|---|---| +| `Explorer.App` | WPF shell, themes, composition | +| `Explorer.Presentation` | ViewModels (no WPF types) | +| `Explorer.Application` | Use-cases (browse, sources) | +| `Explorer.Domain` | Entities, identity, excludes | +| `Explorer.Indexing` | Full scan, folder reconcile, USN apply, watchers | +| `Explorer.Search` | Structured `SearchQuery` → index | +| `Explorer.Analysis` | Top-N, types, duplicates, history rollups | +| `Explorer.FileOperations` | Transfer queue, copy/move/delete | +| `Explorer.Storage.Sqlite` | Schema, WAL, FTS5 | +| `Explorer.Windows` | Volumes, USN, Recycle Bin, `CopyFileEx` | + +Listing is **hybrid**: the open folder always comes from the live filesystem when the location is online. Folder sizes, search, analysis and offline media come from the index. + +## Database location + +`%LocalAppData%\ExplorerWorkbench\index.db` + +Never stored beside the executable. + +## Logging location + +`%LocalAppData%\ExplorerWorkbench\logs\explorer-YYYYMMDD.log` + +## Indexing behavior + +- Browsing local drives works immediately with an empty index. +- Initial indexing is **user-triggered** (banner **Build index** or the toolbar **Index** button). +- Scans run in the background, write in batches, skip inaccessible paths, and can be cancelled. +- Local NTFS volumes use the USN change journal when the process can open it; otherwise folder reconcile + `FileSystemWatcher` (best effort; overflow marks the location as possibly out of date). +- SMB/UNC paths are added manually (address bar or **Add network**). The Windows Network neighborhood is not enumerated. +- Default excludes include `C:\Windows`, recycle bins, `node_modules`, `.git`, and similar. + +## Keyboard shortcuts + +| Shortcut | Action | +|---|---| +| `Alt+Left` / `Back` | Back | +| `Alt+Right` | Forward | +| `Alt+Up` | Up | +| `F5` | Refresh | +| `Enter` | Open | +| `F2` | Rename | +| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | Copy / Cut / Paste | +| `Delete` | Delete to Recycle Bin | +| `Ctrl+T` | New tab | +| `Ctrl+W` | Close tab | + +Drag and drop copies by default; hold `Shift` to move. + +## Known limitations + +- Product name `Explorer` is temporary; app data uses `ExplorerWorkbench`. +- UI strings are English in this build; resource structure can take German later. +- Everything-like search syntax is not implemented; name/glob plus size/type/date filters are. +- USN requires privileges Windows does not grant to a normal desktop process on many volumes — fallback is used then. +- Duplicate hashing is staged (size → partial hash → full hash only when partial hashes still collide) and runs in the background; confirmed groups appear after hashing finishes. +- No treemap, no document full-text, no cloud providers, no installer/MSIX in this run. +- Very large folders rely on WPF virtualization; search results are paged (500, hard cap 10 000). +- `mmap_size` is not applied; it caused unreliable native SQLite startup under concurrent test hosts. +- UI chrome is English; German `.resx` files are not wired through every control in this run. + +## License + +Use as a private/local project unless otherwise specified. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..e5f9545 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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`, `Channel`, Messenger für „Source status changed“. + +Indexer-Events aktualisieren den Index; der ExplorerPane subscribed auf „current folder invalidated“ und refresht nur den offenen Pfad. + +--- + +## 4. Datenmodell / SQLite-Schema + +### 4.1 Betriebsmodus + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA foreign_keys = ON; +PRAGMA temp_store = MEMORY; +PRAGMA mmap_size = 268435456; -- 256 MB, tunen +PRAGMA cache_size = -131072; -- 128 MB +PRAGMA busy_timeout = 5000; +``` + +Schema-Version über `PRAGMA user_version`. Migrationen als eingebettete SQL-Skripte. + +### 4.2 Tabellen + +```sql +CREATE TABLE sources ( + id INTEGER PRIMARY KEY, + stable_key TEXT NOT NULL UNIQUE, -- App-UUID, nie Buchstabe + kind TEXT NOT NULL, -- NtfsLocal, Removable, Smb, Nfs, Cloud + display_name TEXT NOT NULL, -- "SanDisk Ultra 64 GB" + volume_guid TEXT, -- \\?\Volume{...}\ + volume_serial INTEGER, -- NTFS/FAT serial + filesystem TEXT, -- NTFS, exFAT, SMBFS, NFS + label TEXT, + capacity_bytes INTEGER, + device_instance_id TEXT, -- USB PnP, optional + last_root_path TEXT, -- E:\ oder \\media\movies + status TEXT NOT NULL, -- Online, Offline, Scanning, Stale, Error + last_seen_utc TEXT, + last_indexed_utc TEXT, + usn_journal_id INTEGER, -- 64-bit als INTEGER + usn_next INTEGER, + scan_generation INTEGER NOT NULL DEFAULT 0, + last_error TEXT +); + +CREATE TABLE entries ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE, + parent_id INTEGER REFERENCES entries(id), + name TEXT NOT NULL, -- Original-Schreibweise + name_norm TEXT NOT NULL, -- Unicode-Fold für Suche/Unique + extension TEXT, -- ohne Punkt, lower + is_dir INTEGER NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, -- Datei: logische Größe + aggregate_size INTEGER NOT NULL DEFAULT 0, -- Dir: Summe Kinder+selbst + child_file_count INTEGER NOT NULL DEFAULT 0, + child_dir_count INTEGER NOT NULL DEFAULT 0, + created_utc TEXT, + modified_utc TEXT, + last_seen_utc TEXT NOT NULL, + last_indexed_utc TEXT, + attributes INTEGER NOT NULL DEFAULT 0, + file_id INTEGER, -- NTFS FRN + parent_file_id INTEGER, + reparse_tag INTEGER, -- 0 = keines + status INTEGER NOT NULL DEFAULT 0, -- 0 Present, 1 Offline, 2 Deleted, 3 Unknown + deleted_utc TEXT, + path_rel TEXT NOT NULL, -- relativ zum Source-Root, \ getrennt + content_hash BLOB, -- SHA-256, nullable + hash_state INTEGER NOT NULL DEFAULT 0, -- 0 none, 1 partial, 2 full + UNIQUE (source_id, parent_id, name_norm) +); + +CREATE INDEX ix_entries_parent ON entries(source_id, parent_id, status); +CREATE INDEX ix_entries_ext_size ON entries(source_id, extension, size_bytes) WHERE is_dir = 0; +CREATE INDEX ix_entries_size ON entries(source_id, size_bytes) WHERE is_dir = 0 AND status = 0; +CREATE INDEX ix_entries_modified ON entries(source_id, modified_utc); +CREATE INDEX ix_entries_file_id ON entries(source_id, file_id) WHERE file_id IS NOT NULL; +CREATE INDEX ix_entries_path ON entries(source_id, path_rel); +CREATE INDEX ix_entries_status ON entries(source_id, status); + +CREATE VIRTUAL TABLE entries_fts USING fts5( + name, + name_norm, + extension, + content = 'entries', + content_rowid = 'id', + tokenize = 'unicode61' +); + +CREATE TABLE excludes ( + id INTEGER PRIMARY KEY, + scope TEXT, -- global oder source_id + source_id INTEGER REFERENCES sources(id), + kind TEXT NOT NULL, -- PathPrefix, Glob, Extension, Attribute + pattern TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE scan_jobs ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL, + kind TEXT NOT NULL, -- Full, Incremental, Folder, Rebuild + status TEXT NOT NULL, -- Queued, Running, Cancelled, Failed, Done + started_utc TEXT, + finished_utc TEXT, + files_seen INTEGER NOT NULL DEFAULT 0, + dirs_seen INTEGER NOT NULL DEFAULT 0, + bytes_seen INTEGER NOT NULL DEFAULT 0, + resume_path TEXT, -- letzter abgeschlossener Ordner + error_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT +); + +CREATE TABLE scan_errors ( + id INTEGER PRIMARY KEY, + job_id INTEGER NOT NULL, + path TEXT, + kind TEXT, -- AccessDenied, Invalid, Io, Network + message TEXT, + utc TEXT NOT NULL +); + +CREATE TABLE transfer_jobs ( + id INTEGER PRIMARY KEY, + op TEXT NOT NULL, -- Copy, Move, Delete, Rename + src TEXT NOT NULL, + dst TEXT, + status TEXT NOT NULL, + bytes_total INTEGER, + bytes_done INTEGER, + created_utc TEXT NOT NULL, + error TEXT +); + +CREATE TABLE hash_queue ( + entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE, + size_bytes INTEGER NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL -- Pending, PartialDone, Done, Error +); + +CREATE TABLE source_stats_history ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL, + captured_utc TEXT NOT NULL, + total_size INTEGER NOT NULL, + file_count INTEGER NOT NULL, + dir_count INTEGER NOT NULL +); + +CREATE TABLE directory_stats_history ( + id INTEGER PRIMARY KEY, + source_id INTEGER NOT NULL, + path_rel TEXT NOT NULL, -- nicht jede Datei, nur Dirs über Schwellwert + captured_utc TEXT NOT NULL, + aggregate_size INTEGER NOT NULL, + file_count INTEGER NOT NULL +); + +CREATE INDEX ix_dir_hist ON directory_stats_history(source_id, path_rel, captured_utc); +``` + +### 4.3 Pfade speichern oder rekonstruieren? + +**Empfehlung: `path_rel` denormalisiert speichern.** + +- Rekonstruktion über Parent-Kette ist bei 10M Zeilen und Suche zu teuer. +- Speicher: 10M × ~80 Byte ≈ 0,8 GB — akzeptabel neben dem Nutzen. +- Rename eines Vorfahren: `UPDATE entries SET path_rel = $new || substr(path_rel, length($old)+1) WHERE source_id=? AND path_rel LIKE $old || '\%' ESCAPE ...` + +SQLite `NOCASE` ist **nur ASCII**. Deshalb `name_norm` in der App (Unicode-Fold) und FTS5 `unicode61`. Niemals auf `COLLATE NOCASE` für deutsche/asiatische Namen verlassen. + +Root-Eintrag jeder Source: `parent_id IS NULL`, `path_rel = ''`. + +Anzeige-Pfad = `sources.last_root_path` + `path_rel`. Offline: Anzeige über `display_name` + `path_rel`. + +--- + +## 5. Strategie für Volume-Identifikation + +Eine Source hat eine **stabile App-UUID** (`stable_key`). Laufwerksbuchstaben sind nur `last_root_path`. + +### 5.1 Lokale / Wechselmedien + +Fingerprint, Match-Reihenfolge: + +1. **Volume GUID** (`GetVolumeNameForVolumeMountPoint`) — primär, überlebt Buchstabenwechsel. +2. **Volume Serial + Filesystem + Capacity** — GUID fehlt (manche exFAT/USB). +3. **Serial + Label + Capacity** — schwächer. +4. **Device Instance ID** (USB PnP) als Zusatzsignal. +5. Unklar → Benutzer: „Ist das derselbe Datenträger wie SanDisk Ultra 64 GB?“ + +Nicht als Identität verwenden: nur Buchstabe, nur Label, nur Größe. + +### 5.2 SMB + +- Kanonische Form: `\\server\share` (Hostname lower-case, Share original). +- Zusätzlich speichern: eingegebener Pfad, ggf. IP. +- `stable_key` bleibt UUID; UNC ist `last_root_path`. +- Hostname vs. IP vs. FQDN können dieselbe Share sein — V1 nicht automatisch mergen; manuell „als dasselbe Medium verbinden“ reicht. + +### 5.3 NFS + +Windows-NFS-Client (optional Feature) erscheint als Laufwerk oder Mount. Behandeln wie Netzwerk-FS ohne USN. Identität: Server+Export-Pfad. + +### 5.4 Cloud (Plugin-Overlay) + +Explorer **implementiert keine Synchronisation**. Offizielle Clients (OneDrive, später Google Drive / Dropbox) bleiben zuständig für Sync, Hydration, Konflikte und Anmeldung. + +Cloud-Ordner werden wie jedes andere gemountete Dateisystem über Win32 gelistet. `Explorer.Plugin.Abstractions` / `IStorageProvider` **reichern** Einträge an (Status, logische vs. allokierte Größe, Pin/Free-up), sie **ersetzen nicht** Enumeration, Watcher oder Source-Identität. + +- Fehlendes, deaktiviertes oder fehlerhaftes Plugin: normales Browse. +- Index, Thumbnails, Duplikat-Hash und Analyse **dürfen online-only Dateien nicht hydrieren**. Lesen von Dateiinhalten nur bei expliziter Nutzeraktion (Öffnen, Kopieren, Pin). +- Fähigkeiten sind entdeckbar (`CloudState`, `Pin`, `Dehydrate`, `Quota`, …), nicht hart verdrahtet. +- Verträge sind IPC-tauglich (`InProcess` / `OutOfProcess`). PluginHost/Marketplace sind nicht Teil dieses Schritts. + +### 5.5 Offline-Anzeige + +Tree-Knoten: + +``` +SanDisk Ultra 64 GB +Offline · Zuletzt gesehen 18.08.2026 +``` + +Statusfarbe/Badge. Doppelklick öffnet den Index-Browser (read-only Listing + Suche), keine Live-Operationen außer „Pfad kopieren“ / „merken, wohin kopiert werden sollte“. + +--- + +## 6. Initialscan-Strategie + +Manuell pro Source. Kein stiller Vollscan aller Festplatten beim ersten Start — das wäre unexplorer-typisch und I/O-feindlich. Beim ersten Öffnen eines Laufwerks: dezentes Banner „Index anlegen, damit Suche und Ordnergrößen funktionieren“ mit Aktion **Index aufbauen**. + +### 6.1 Algorithmus + +1. Job anlegen, Status Scanning, `scan_generation++`. +2. Exclude-Regeln laden. +3. Iterative DFS/Post-Order mit `System.IO.Enumeration.FileSystemEnumerable` und `\\?\`-Präfix. +4. Parallelität: lokal 4 Enum-Worker, SMB/NFS **1–2**. +5. Pro Batch (2 000–5 000 Einträge) eine Transaktion an den SQLite-Writer. +6. Ordnergrößen **post-order im Scan** akkumulieren (Stack), nicht per Parent-Walk pro Datei. +7. Einzeldateifehler → `scan_errors`, weiter. +8. Access Denied → loggen, Ordner überspringen. +9. `CancellationToken` auf jedem Worker; nach Cancel konsistenter Stand (abgeschlossene Batches committed). +10. Fortschritt: Dateien, Ordner, Bytes, aktueller Pfad, Fehlerzahl — UI via `IProgress`, gedrosselt (~10 Hz). + +Thread-/Prozess-I/O-Priorität: `PROCESS_MODE_BACKGROUND_BEGIN` während Scan, damit Explorer und andere Apps fluide bleiben. + +### 6.2 Resume (V1 pragmatisch, V1.1 vollständig) + +- V1: unterbrochener Scan gilt als unvollständig; **erneuter Full Scan mit UPSERT** (idempotent). `status=Present` nur für gesehene Einträge der neuen Generation; Rest → Deleted gemäß Retention. +- V1.1: `resume_path` = letzter **abgeschlossener** Ordner; Skip bereits geschriebener Subtrees. + +UPSERT-Schlüssel: `(source_id, parent_id, name_norm)`. FRN zusätzlich für Rename-Erkennung. + +### 6.3 Millionen Dateien + +Grobe Rechnung: 5M Dateien × ~250 Byte Indexzeile ≈ 1,25 GB plus FTS und Indizes → **2–4 GB DB** realistisch. SSD lokal: Initialscan CPU/I/O-gebunden, nicht SQLite-gebunden, sofern Batches und ein Writer stimmen. + +--- + +## 7. NTFS-USN-Journal-Strategie + +### 7.1 Ziel + +Nach dem Initialscan **kein** periodischer Full Scan auf lokalen NTFS-Volumes. Änderungen seit `usn_next` einlesen und anwenden. + +### 7.2 Ablauf + +1. `FSCTL_QUERY_USN_JOURNAL` → `UsnJournalID`, `NextUsn`. +2. Persistiert in `sources.usn_journal_id` / `usn_next`. +3. Beim Start / periodisch / nach Watcher-Burst: `FSCTL_READ_USN_JOURNAL` ab gespeichertem USN. +4. Records (`USN_RECORD_V2/V3`) auf `IndexCommand` mappen: + - Create → Insert + - Delete → Tombstone/Delete + - Rename old/new → path_rel umschreiben + - Data/Basic info → Size, Times, Attributes +5. FRN (`FileReferenceNumber`) → `entries.file_id`. +6. Nach erfolgreichem Batch `usn_next` fortschreiben **in derselben Transaktion**. + +Journal-ID geändert oder USN zu alt (Journal wrapped): Source → **Stale**, Banner „Index veraltet — aktualisieren“, kein stilles Vollscan ohne Auftrag (außer User-Setting „automatisch neu aufbauen“). + +### 7.3 Rechte — wichtiger Trade-off + +Das USN-Journal ist oft nur mit **Administrator** oder Backup-Privilegien vollständig lesbar. + +| Option | Vorteil | Nachteil | +|---|---|---| +| A Immer als Admin | Zuverlässiges USN | UAC, schlechte Desktop-UX | +| B Optionaler Index-Dienst als SYSTEM | USN ohne UI-Elevation | Komplex, V2 | +| C **V1: USN wenn möglich, sonst Fallback** | Kein Pflicht-Admin | Manche Volumes nur Watcher+Walk | + +**Empfehlung C für V1**, Architektur für B offen halten (`IChangeFeed`). + +Fallback lokal ohne USN: + +- `FileSystemWatcher` (best effort, Buffer können überlaufen). +- Directory-mtime / `FindFirstFile` inkrementeller Ordnerwalk beim Öffnen und per manuellem Refresh. +- Watcher-Overflow → Source **Stale**. + +FileSystemWatcher **nie** als alleinige Wahrheitsquelle. + +### 7.4 Coalescing + +USN liefert viele Reasons plus oft `USN_REASON_CLOSE`. Apply-Logik coalesct pro FRN im Batch (letzte Create/Delete/Rename gewinnt), um Schreiblast zu senken. + +--- + +## 8. Strategie für SMB/NFS + +Kein USN, unzuverlässige Watcher, hohe Latenz. + +**Empfehlung: Hybrid aus opportunistischem Walk und manueller Aktualisierung.** + +| Trigger | Aktion | +|---|---| +| Ordner öffnen (online) | Live-Listing; Diff gegen Index für diesen Ordner (Namen, Size, Mtime) | +| Manuell Refresh | Ordner oder Source | +| Optional Timer (User) | z. B. alle 30 min, nur gemountete Shares, idle | +| Watcher, falls der Redirector Events liefert | best effort, Overflow → Stale | + +Inkrementeller Ordner-Rescan: + +1. Kinder enumerieren. +2. Vergleich mit `entries` dieses `parent_id`. +3. Neu → insert, fehlend → tombstone, Mtime/Size geändert → update. +4. Rekursiv nur wenn User „Ordner neu einlesen“ oder Full Rescan. + +Netzwerk-I/O: **ein Enum-Worker pro Share**, Timeouts, Reconnect-Backoff. Verschwundene Share → Offline, Index bleibt. + +NFS unter Windows nur, wenn der optional Client installiert ist. Sonst UNC/NFS nicht erzwingen; Source-Typ `Nfs` vorsehen, Provider kann „nicht verfügbar“ liefern. + +--- + +## 9. Suche und benötigte DB-Indizes + +### 9.1 Syntax vs. UI-Filter + +**V1: UI-Filter + schnelles Suchfeld für Namen/Glob.** +**V1.1: kompakte Query-Syntax.** + +Begründung: Syntax ohne Parser-Disziplin wird zur Falle (`size > 10 GB` vs. `size>10GB`). Everything-Nutzer erwarten sie später. Die Query-Engine sollte intern schon ein strukturiertes `SearchQuery`-Objekt haben, das die Syntax später nur parst. + +V1 Suchfeld: + +- Freitext → FTS5 auf `name` / `name_norm` (Prefix `abc*`) +- `*.mkv` → Extension-Filter +- optional ein Filter-Panel: Typ, Größe von–bis, Datum, nur Ordner, Source-Scope + +V1.1 Syntax (Everything-ähnlich, klein halten): + +``` +*.mkv size:>10gb +type:video size:>5gb +modified:<2025-01-01 +*.zip size:>1gb +path:Downloads +``` + +Keine vollständige Programmiersprache. Ein PEG/Regex-Lexer reicht. + +### 9.2 Scopes + +`SearchScope`: CurrentFolder | CurrentTree | Selected | Sources[] | AllKnown (inkl. Offline). + +CurrentFolder kann Live-FS filtern (sofort). CurrentTree und AllKnown **nur Index** — das ist der Produktvorteil. + +### 9.3 Indizes (siehe Schema) + +Hot Paths: + +- FTS5 für Teilnamen +- `(source_id, parent_id, status)` Listing +- `(source_id, extension, size_bytes)` Typ+Größe +- `(source_id, size_bytes)` Duplikat-Kandidaten +- `(source_id, path_rel)` Tree-Suche `path_rel LIKE 'Movies\%'` +- `(source_id, file_id)` USN + +Query-Plan in Tests mit `EXPLAIN QUERY PLAN` gegen eine 1M-Zeilen-Fixture absichern. + +Ergebnis-Limit default 10 000, virtuelle Liste, „mehr laden“. Niemals 2M Rows in die UI binden. + +--- + +## 10. Architektur der Split View + +Tabs und Split sind **orthogonal**. + +``` +Window + └── TabStrip mehrere Arbeitsbereiche + └── ExplorerTab + └── SplitHost Single | Dual (vertikal, später horizontal) + ├── ExplorerPane A eigene History, Pfad, View, Selection + └── ExplorerPane B +``` + +Nicht: Tabs ersetzen Split. Nicht: ein globaler Dual-Pane ohne Tabs (Total Commander). Beides: + +- Tab = Arbeitskontext (z. B. „Videos sortieren“). +- Split in einem Tab = zwei unabhängige `ExplorerPane`. + +Default neues Tab: **Single**. Aktion „Split“ öffnet rechtes Panel mit demselben Pfad oder dem zweiten selektierten Ordner. Layout persistieren pro Tab. + +Jedes Pane: + +- `NavigationService` (Back/Forward/Up, Breadcrumb) +- `FolderViewModel` (Items, Sort, Filter, ViewMode Details/List/Icons) +- `IDropTarget` / `IDragSource` + +DnD und Copy/Paste zwischen Panes = dieselben `FileOperations` wie innerhalb eines Panes. Quelle und Ziel sind Pfade, nicht „Panel-IDs“. + +Tastatur (V1 anlehnen, später konfigurierbar): + +- `Tab` Fokus anderes Panel +- `F5` / `F6` Refresh (nicht TC-Copy, um Explorer-Nutzer nicht zu verwirren) +- Copy/Paste Standard, plus später TC-Shortcuts als Option + +Tree links: **fensterweit**, nicht pro Pane — sonst wird die UI eng. Klick setzt das **aktive** Pane. Optional später Tree pro Pane. + +--- + +## 11. Dateioperationen und Transfer Queue + +### 11.1 V1-Operationen + +Öffnen, Kopieren, Verschieben, Umbenennen, Löschen (Recycle), Neuer Ordner, DnD, Clipboard (`CF_HDROP` / Shell IDLists), Pfad kopieren. + +Asynchron, UI nie blockieren. Kurze Ops (Rename, New Folder) ohne Queue-Fenster; lange Ops in die Queue. + +### 11.2 Queue + +``` +UI → FileOperationService.Enqueue(op) + → Channel + → 1–2 Worker (lokal parallel begrenzt, SMB serieller) + → Progress (bytes_done) → TransferPanel +``` + +V1: Queue, Fortschritt, Abbrechen, Fehler anzeigen. +Später: Pause/Resume (`CopyFileEx` COPY_FILE_RESTARTABLE), Konfliktdialog, Retry, Undo (Rename/Move inner volume). + +### 11.3 Windows-Integration + +| Op | API | +|---|---| +| Recycle Delete | `IFileOperation` (empfohlen) oder `SHFileOperation` mit `FOF_ALLOWUNDO` | +| Copy/Move mit Progress | `CopyFileEx` / `MoveFileWithProgress` **oder** `IFileOperation` mit Progress-Sink | +| Open | `ShellExecute` / `Process.Start(UseShellExecute=true)` | +| Icons | `SHGetFileInfo` / ImageList, gecacht | + +**Empfehlung V1:** `IFileOperation` für Delete-to-Recycle (korrekte Undo, Elevation-Prompt). Eigene Copy-Engine (`CopyFileEx`) für Queue-Kontrolle. Trade-off: zwei Pfade vs. volle Kontrolle über Pause und Throughput. + +Locked Files: Fehler in der Queue, Rest weiter. USB während Copy entfernt: Job Failed, Source Offline. + +Nach erfolgreicher Op: Indexer per Command (oder USN) aktualisieren — nicht auf Watcher hoffen. + +Long Paths: Manifest `longPathAware`, intern `\\?\` / `\\?\UNC\`. + +--- + +## 12. Analyse- / Folder-Size-Konzept + +Der Index hält `aggregate_size` und Child-Counts **immer aktuell**, nicht beim Öffnen der Analyse. + +### 12.1 Pflege der Aggregates + +- **Initialscan:** Post-Order, einmal schreiben. +- **USN/incremental:** Delta an der Datei entlang der Parent-Kette (`parent_id`) addieren/subtrahieren. Tiefe selten > 20; bei Rename: subtract am alten Ast, add am neuen. +- Niemals `SUM(*)` über den ganzen Tree für die Explorer-Spalte. + +### 12.2 Explorer-Spalte + +Optionale Spalte „Größe“ für Ordner aus `aggregate_size`. Wenn Index Stale/Scanning: graue Zahl + Hinweis. Online ohne Index: Spalte leer oder „—“, kein heimlicher Recursive Walk (sonst sind wir wieder WinDirStat). + +### 12.3 Analyse-View + +Eigenes Overlay, nicht den Explorer ersetzen. Default ist ein **hierarchischer Tree** der indexierten Sources und Ordner (WinDirStat-ähnlich, Explorer-Workbench-Look). Ranking-Views daneben: Biggest folders, Biggest files, By file type, By source. + +- Tree: Roots = Sources (`GetRootAsync`); Expand lädt Kinder lazy via `LargestDirectoriesAsync(sourceId, parentId)` aus dem Index. Nie den kompletten Tree in WPF materialisieren, nie das Dateisystem walken. +- Balken im Tree relativ zu den **Geschwistern des aktuellen Parents**; in Ranking-Views relativ zur Liste. +- Biggest folders/files zeigen vollständigen oder gekürzten Pfad (`PathRules.ShortenDisplay`), Sortierung `aggregate_size` / `size_bytes` DESC. +- Actions: Open (aktuelles Pane / anderes Split-Pane / neuer Tab), Search within, Rescan (Indexer-Queue), Copy path. +- Virtualisierte `ListView`, Children-Cap `AnalysisTreeChildTake`. + +V1 kein Treemap (Canvas/WPF-Heavy). Datenmodell erlaubt Treemap später (`aggregate_size` pro Kind). + +WinDirStat-Vorteil: **O(log n) Queries statt Disk-Walk**. Nachteil: Zahl so gut wie der Index — deshalb Status „aktuell / veraltet“ sichtbar machen. + +--- + +## 13. Duplikaterkennung + +Nicht jede Datei hashen. + +1. SQL: `GROUP BY size_bytes HAVING COUNT(*) > 1` (nur Present, nicht excluded). +2. Gruppen in `hash_queue`, große Dateien niedrige Priorität oder umgekehrt je nach User-Ziel. +3. Partial Hash: erste 64 KiB SHA-256 (`hash_state=1`). +4. Nur kollidierende Partial-Gruppen: Full-File SHA-256, `content_hash` persistieren. +5. Worker: `SetThreadPriority` Background, max. 1 sequentieller Reader pro Volume, pausieren wenn Transfer-Queue aktiv. + +Filter: Source, Pfad-Prefix, Exclude-Liste. Identisch = gleicher Full Hash, nicht gleicher Name. + +Hardlinks (gleiche FRN): als „gleiche Datei, mehrere Pfade“ markieren, nicht als Duplikat-Müll. + +V1 der Detector kann nach dem Explorer-MVP kommen; Schema (`content_hash`, `hash_queue`) trotzdem in V1 anlegen, damit kein zweites Migration-Chaos entsteht. + +--- + +## 14. Offline-Media-Konzept + +- Source bleibt im Tree, Badge **Offline**, `last_seen_utc`, Kapazität/Label. +- Entries: `status=Offline` (Volume weg) vs. `Deleted` (USN/Scan hat Löschung gesehen). +- Navigation: Index-Listing, Suche über Offline-Medien **an**. +- Dateioperationen: Copy/Move/Delete disabled; Öffnen disabled; Pfad kopieren erlaubt. +- Wieder da: Fingerprint-Match, `last_root_path` updaten, USN oder Folder-Diff, Status Online. + +USB mitten im Scan: Job abbrechen/fehlschlagen, committed Batches behalten, Source Offline, Banner „Scan unvollständig“. + +--- + +## 15. Historienkonzept (speichereffizient) + +Nicht jede Datei versionieren. + +**Drei Stufen:** + +1. **Tombstones** auf `entries` (`status=Deleted`, `deleted_utc`) — Datei existierte. Retention-Policy (sofort / N Tage / dauerhaft). +2. **Source-Rollups** täglich: eine Zeile in `source_stats_history` (Größe, Counts). Das liefert „+384 GB seit letztem Monat“ ohne Dateihistorie. +3. **Directory-Rollups** nur für Ordner über Schwellwert (z. B. > 1 GB oder Top 200 je Source) in `directory_stats_history`. Retention: 90 Tage täglich, danach wöchentlich verdichten. + +Kein Copy-on-Write des ganzen Trees. Kein Git fürs Dateisystem. + +Spätere UI: Delta-Badge am Ordner, wenn zwei Snapshots existieren. V1 nur Schema + nächtlicher Rollup-Job. + +--- + +## 16. Fehler- und Recovery-Konzept + +Prinzip: **Fehler sind lokal, Jobs sind unterbrechbar, die DB überlebt Crashes.** + +| Ereignis | Verhalten | +|---|---| +| Access Denied | `scan_errors`, weiter | +| Invalid/corrupt dirent | loggen, weiter | +| Netzwerk weg | Source Offline, Job Failed/Cancelled, Index behalten | +| USB gezogen | wie Netzwerk; offene Transfers Failed | +| Locked file (Hash/Copy) | Eintrag/Job Error, Queue weiter | +| Sehr lange Pfade | `\\?\`, sonst Error-Log | +| Unicode | UTF-8 in SQLite, Originalname in `name` | +| UI-Crash während Scan | WAL; letzte Transaktion committed; Scan-Job beim Start als unterbrochen markieren | +| SQLite I/O error | Writer stoppt, Banner, keine stillen Writes | +| Cancel | keine halben Batches; `usn_next` nur nach Commit | +| Scan während Änderungen | USN nach Scan-Ende nachziehen; oder generation-mark + missing → Deleted | +| Journal lost | Stale + Full Rescan nötig | + +DB-Integrität: nach Crash `PRAGMA quick_check` beim Start (asynchron, UI nicht blockieren). Backup optional: periodisches `VACUUM INTO` später, nicht V1. + +Logging: Serilog rolling file unter `%LocalAppData%\…\logs`. Scan-Fehler zusätzlich in DB für die UI-Liste „übersprungene Ordner“. + +--- + +## 17. Performance-Risiken + +| Risiko | Mitigation | +|---|---| +| UI-Freeze durch Binding von 100k Items | Virtualizing `ListView`/`DataGrid`, Paging, nie ganze Source materialisieren | +| SQLite Writer-Stau | Ein Writer, große Batches, keine UI-Reads im Writer | +| FTS5 bloat | External content table, `optimize` nach Full Scan | +| Watcher-Flood | Coalesce 300–500 ms, Overflow → Stale | +| SMB-Latenz | Live-Listing async, Placeholder-Zeilen, Timeout | +| Recursive LIKE ohne Index | Immer `source_id` zuerst; Prefix-LIKE nur mit Index auf `path_rel` | +| Aggregate-Update-Ketten bei Massen-Delete | Batch-Delta pro Parent statt N einzelne UPDATEs wo möglich | +| RAM | Streaming Enum, kein `List` der ganzen Platte; DB mmap statt alles in CLR | +| Hash I/O | Throttle, ein Disk-Head, pause on user copy | +| Icon extraction | Async, Default-Icon zuerst, Cache | +| TreeView alle Drives expand | Lazy, nur sichtbare Children aus FS oder Index | + +Ziel-SLO (Richtwerte, kein Contract): + +- Ordner mit 10k Dateien öffnen (online, lokal): < 100 ms bis erste Zeilen. +- Namenssuche 5M Rows, indexed: < 200 ms für erste Seite. +- UI während Scan: Eingabe ohne Ruckeln; Scan-Progress 10 Hz. + +--- + +## 18. MVP-Abgrenzung + +### MVP (fühlbarer Explorer + Gedächtnis) + +- WPF Shell, Dark/Light +- Nav-Tree: lokale Volumes + manuell UNC +- Ein ExplorerPane: Breadcrumb, Back/Forward/Up, Details+List +- Tabs +- Split View + DnD zwischen Panes +- Live-FS Listing wenn online +- Basis-Ops: Open, Copy, Move, Rename, Delete→Recycle, New Folder, Clipboard, Pfad kopieren +- Transfer-Queue mit Progress + Cancel +- SQLite-Index, manuelle Source-Auswahl, Full Scan async +- Excludes (Pfad, Glob, Ext, Hidden/System optional) +- Reparse: nicht folgen (siehe unten) +- Ordnergröße aus Index (Spalte) +- Suche: Name/Glob/Größe/Datum, Scope aktueller Tree oder alle Sources inkl. Offline +- Offline-Volume im Tree +- Source-Status: Online / Scanning / Stale / Offline / Error +- Manuell: Refresh, Rescan Folder, Full Rescan + +### Nicht im MVP + +- USN (sofort danach Phase 2) +- Windows-Dienst +- NFS-Feinschliff (Typ vorsehen, nur wenn Client da) +- Query-Syntax +- Duplikat-UI (Schema ja) +- Historien-Charts (Schema ja) +- Pause/Resume Transfers, Konflikt-UI, Undo +- Icon-View, Treemap, Preview-Pane +- Weitere Cloud-Provider (Google Drive, Dropbox) und PluginHost/IPC +- Explorer.exe ersetzen +- Volltext in Dateiinhalten + +### Reparse-Policy (V1 festlegen) + +| Typ | Default | +|---|---| +| Directory Junction / Directory Symlink | **Nicht folgen**; als Link-Eintrag indexieren, Overlay-Icon | +| Volume Mount Point | Als **eigene Source** behandeln, wenn Volume-GUID bekannt; sonst nicht in den Baum des Parents mergen | +| File Symlink | Als Datei indexieren, Größe des Links; Hash nicht dem Target folgen | +| Andere Reparse (OneDrive placeholder, Dedup) | Als Datei/Ordner mit Tag; Enumeration normal, wenn Win32 sie als Datei zeigt | + +Schleifen: besuchte `(volume_guid, FRN)`-Menge während eines Scans. Zweites Betreten → skip. + +--- + +## 19. Entwicklungsphasen + +### Phase 0 — Gerüst (ca. 1 Woche) + +Solution, DI, Logging, leeres Fenster, Themes, SQLite-Ping, Volume-Enumeration ohne Index. + +### Phase 1 — Live-Explorer (2–3 Wochen) + +Pane, Tree, Breadcrumb, History, Details-View, Virtualization, Basis-Ops ohne Queue, Recycle, Long Paths. **Fühlt sich schon wie Explorer an.** + +### Phase 2 — Tabs, Split, Queue (2 Wochen) + +Tabs, Dual Pane, DnD, Transfer-Queue, Progress-Panel. + +### Phase 3 — Index-MVP (3 Wochen) + +Schema, Source-Identität, Full Scan, Excludes, Aggregates, Offline, Ordnergrößen-Spalte, Suche über Index, Refresh/Rescan. + +### Phase 4 — Change Tracking (2 Wochen) + +USN wo möglich, Watcher+Folder-Diff Fallback, Stale-Logik, Resume-Scan. + +### Phase 5 — Analyse (1–2 Wochen) + +Analyse-View, Balken, Drilldown, Typ-Aggregation. + +### Phase 6 — Duplikate + Historie (2 Wochen) + +Hash-Pipeline, Duplikat-UI, Rollups, Tombstone-Retention-Settings. + +### Phase 7 — Härten + +SMB-Stabilität, Elevation-optional, Query-Syntax, Dienst-Schnittstelle (`Explorer.Contracts` IPC), Installer. + +Phasen 1–3 sind das kleinste lieferbare Produkt, das die Vision trägt. USN ist bewusst Phase 4: ohne soliden Scan und Identity ist das Journal wertlos. + +--- + +## 20. Entscheidungen vor Implementierungsbeginn + +Bitte diese Punkte klären. Empfohlene Defaults in Klammern: + +1. **Produktname und AppId** (Arbeitsname Explorer ist ungeeignet für Store/Mutex/`LocalAppData`). +2. **Hybrid Live-FS + Index** vs. Index-first Listing (**Hybrid**). +3. **Elevation:** nie Admin / optional USN-Dienst / App immer elevatet (**USN opportunistisch, kein Pflicht-Admin**). +4. **Split-Default:** neues Tab single oder immer dual (**single, Split per Tab**). +5. **Tombstones default:** sofort löschen / 30 Tage / dauerhaft (**30 Tage**). +6. **Erstes Laufwerk:** Banner „Index aufbauen“ vs. stiller Scan (**Banner, User startet Scan**). +7. **Sprache UI:** de / en / beide (**de + en, System-UI**). +8. **Distribution:** portable ZIP / MSIX / Setup (**MSIX oder Setup, DB unter LocalAppData**). +9. **Mindest-OS:** Windows 10 22H2 vs. 11 only (**Windows 10 1809+ / 11**). +10. **WPF-Fluent-Kit:** WPF-UI (lepoco) vs. eigene ResourceDictionaries (**WPF-UI für Tempo, austauschbar hinter Themes**). +11. **Copy-Engine:** nur IFileOperation vs. CopyFileEx-Queue (**Mischung, siehe §11**). +12. **Netzwerk-Shares im Tree:** nur manuell vs. zuletzt verwendete vs. Windows-Netzwerkumgebung (**manuell + Recents**). + +--- + +## Hintergrunddienst: A vs. B + +| | A In-Process (V1) | B Windows Service (später) | +|---|---|---| +| Komplexität | niedrig | Session 0, ACL, IPC, Updates | +| Index wenn UI zu | stoppt | läuft weiter | +| USN-Rechte | oft unzureichend | SYSTEM kann Journal lesen | +| Crash-Isolation | UI-Crash stoppt Index | getrennt | +| Empfohlen | **V1 = A** | **V2 = B**, wenn Identity+Schema stabil sind | + +V1 so schneiden, dass der Indexer ein `IHostedService` ist. Im Dienst-Host später derselbe Service, UI spricht über Named Pipe / gRPC (`Explorer.Contracts`). Nicht in V1 bauen, Interfaces nicht an WPF kleben. + +--- + +## Sicherheit und Robustheit (kurz) + +- Keine Indexierung von Inhalten geschützter Ordner ohne User-Exclude-Defaults: `C:\Windows`, `C:\System Volume Information`, `$Recycle.Bin`, `C:\ProgramData\Microsoft`, Standard-Temp. +- Scans laufen in User-Rechten; nicht heimlich SeBackupPrivilege fordern. +- DB liegt im User-Profil, nicht world-writable. +- UNC-Pfade nicht als ausführbare Kommandos interpretieren. +- Hash-Worker lesen nur; keine Writes ins FS. + +--- + +## UX-Sprache (verbindlich) + +| Intern | UI | +|---|---| +| Source | Laufwerk, Medium, Speicherort | +| Entry | Datei, Ordner | +| Full Scan | Index aufbauen / Vollständig einlesen | +| Stale | Möglicherweise nicht aktuell | +| Tombstone | Früher vorhanden / Nicht mehr vorhanden | +| FTS / SQLite | — (nie zeigen) | +| USN | — (nie zeigen; höchstens „Änderungsprotokoll des Laufwerks“) | + +Statuszeile darf „1,2 Mio. Dateien indexiert · aktuell“ sagen. Das ist Explorer-Gedächtnis, kein Datenbankprodukt. + +--- + +## Implementation notes (this codebase) + +Deviations from the design above, with reasons: + +1. **`entries.scan_generation`** — added so a full/folder scan can mark unseen rows as deleted without keeping the whole tree in RAM. UPSERT stamps the generation; missing rows with an older generation become tombstones. +2. **CsWin32** — not used. Win32 is called via `LibraryImport`/`DllImport` in `Explorer.Windows`. CsWin32 generated code was heavier than needed for the small API surface (volumes, USN, `CopyFileEx`, Recycle Bin). +3. **WPF-UI (lepoco)** — not used. Light/Dark Fluent-style brushes live in `Themes/Dark.xaml` and `Themes/Light.xaml` so the UI toolkit stays replaceable. +4. **`PRAGMA mmap_size` / large `cache_size`** — not applied at runtime. They made SQLite native startup unreliable under concurrent test hosts; WAL + `synchronous=NORMAL` remain. +5. **App data folder** — `%LocalAppData%\ExplorerWorkbench` (not `Explorer`) so the working name does not collide with Windows Explorer. +6. **Background work** — in-process `IHostedService` instances (indexer, transfer queue, hash worker, history rollup, watchers). No Windows Service in this run. +7. **Search syntax** — structured `SearchQuery` exists; Everything-like lexer is not shipped (Phase 7). +8. **UNIQUE identity** — `UNIQUE (source_id, ifnull(parent_id,-1), name_norm)` because SQLite UNIQUE treats NULLs as distinct. +9. **INSERT ids** — `Microsoft.Data.Sqlite` + Dapper `ExecuteScalarAsync` on `INSERT … RETURNING` leaves the write connection busy and hangs the next command. Writer-connection SQL uses `SqliteCommand` (`SqliteExec`) and `last_insert_rowid()`. +10. **SQLite cache** — `SqliteCacheMode.Shared` deadlocked reader+writer connections in-process. Connections use the default private cache. +11. **Schema apply** — Microsoft.Data.Sqlite/`Execute` splitting on `;` broke `CREATE TRIGGER` bodies. Schema is applied as an explicit statement array (`SchemaScript.Statements`). +12. **Search CurrentFolder** — `SearchRequest.DirectChildrenOnly` restricts to the folder’s children in SQL (not a client-side filter after paging). +13. **Duplicate hashing** — full-file hashes run only when another same-size file already shares the partial hash. Unique partial hashes skip the full read. + diff --git a/explorer-workbench-icons/explorer-workbench-128.png b/explorer-workbench-icons/explorer-workbench-128.png new file mode 100644 index 0000000..e0e647e Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-128.png differ diff --git a/explorer-workbench-icons/explorer-workbench-16.png b/explorer-workbench-icons/explorer-workbench-16.png new file mode 100644 index 0000000..9920d51 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-16.png differ diff --git a/explorer-workbench-icons/explorer-workbench-20.png b/explorer-workbench-icons/explorer-workbench-20.png new file mode 100644 index 0000000..8348dce Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-20.png differ diff --git a/explorer-workbench-icons/explorer-workbench-24.png b/explorer-workbench-icons/explorer-workbench-24.png new file mode 100644 index 0000000..6d42264 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-24.png differ diff --git a/explorer-workbench-icons/explorer-workbench-256.png b/explorer-workbench-icons/explorer-workbench-256.png new file mode 100644 index 0000000..f1ff1bf Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-256.png differ diff --git a/explorer-workbench-icons/explorer-workbench-32.png b/explorer-workbench-icons/explorer-workbench-32.png new file mode 100644 index 0000000..fcb7085 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-32.png differ diff --git a/explorer-workbench-icons/explorer-workbench-48.png b/explorer-workbench-icons/explorer-workbench-48.png new file mode 100644 index 0000000..01b58ac Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-48.png differ diff --git a/explorer-workbench-icons/explorer-workbench-512.png b/explorer-workbench-icons/explorer-workbench-512.png new file mode 100644 index 0000000..e788315 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-512.png differ diff --git a/explorer-workbench-icons/explorer-workbench-64.png b/explorer-workbench-icons/explorer-workbench-64.png new file mode 100644 index 0000000..31b9668 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-64.png differ diff --git a/explorer-workbench-icons/explorer-workbench-96.png b/explorer-workbench-icons/explorer-workbench-96.png new file mode 100644 index 0000000..eaa4374 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench-96.png differ diff --git a/explorer-workbench-icons/explorer-workbench.ico b/explorer-workbench-icons/explorer-workbench.ico new file mode 100644 index 0000000..4e730d7 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench.ico differ diff --git a/explorer-workbench-icons/explorer-workbench.png b/explorer-workbench-icons/explorer-workbench.png new file mode 100644 index 0000000..e788315 Binary files /dev/null and b/explorer-workbench-icons/explorer-workbench.png differ diff --git a/global.json b/global.json new file mode 100644 index 0000000..f365c8c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.301", + "rollForward": "latestFeature" + } +} diff --git a/src/Explorer.Analysis/AnalysisResultCache.cs b/src/Explorer.Analysis/AnalysisResultCache.cs new file mode 100644 index 0000000..3862cd7 --- /dev/null +++ b/src/Explorer.Analysis/AnalysisResultCache.cs @@ -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 _items = new(StringComparer.Ordinal); + + public bool TryGet(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(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); +} diff --git a/src/Explorer.Analysis/AnalysisService.cs b/src/Explorer.Analysis/AnalysisService.cs new file mode 100644 index 0000000..12b633f --- /dev/null +++ b/src/Explorer.Analysis/AnalysisService.cs @@ -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 GetIndexStampAsync(CancellationToken cancellationToken = default) + => RunOffUiAsync(ct => _store.Analysis.GetIndexStampAsync(ct), cancellationToken); + + public Task> GetDirectoryRootsAsync(CancellationToken cancellationToken = default) + => CachedAsync("roots", ct => _store.Analysis.GetDirectoryRootsAsync(ct), cancellationToken); + + public Task> 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> 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> 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> UsageBySourceAsync(CancellationToken cancellationToken = default) + => CachedAsync("sources", ct => _store.Analysis.UsageBySourceAsync(ct), cancellationToken); + + public Task> DrilldownAsync( + long parentId, + int take = AppConstants.AnalysisTopN, + CancellationToken cancellationToken = default) + => CachedAsync( + $"children:{parentId}:{take}", + ct => _store.Analysis.ChildrenBySizeAsync(parentId, take, ct), + cancellationToken); + + public Task> GetKnownSourcesAsync(CancellationToken cancellationToken = default) + => RunOffUiAsync(ct => _store.Sources.GetAllAsync(ct), cancellationToken); + + private async Task CachedAsync(string key, Func> query, CancellationToken cancellationToken) + { + await EnsureReadyAsync(cancellationToken).ConfigureAwait(false); + var stamp = await GetIndexStampAsync(cancellationToken).ConfigureAwait(false); + if (_cache.TryGet(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 RunOffUiAsync(Func> 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); + } +} diff --git a/src/Explorer.Analysis/DuplicateAndHistory.cs b/src/Explorer.Analysis/DuplicateAndHistory.cs new file mode 100644 index 0000000..4f5b351 --- /dev/null +++ b/src/Explorer.Analysis/DuplicateAndHistory.cs @@ -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 _logger; + private volatile bool _paused; + + public DuplicateHashWorker(IIndexStore store, IHydrationGuard hydration, ILogger 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 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; + } +} diff --git a/src/Explorer.Analysis/Explorer.Analysis.csproj b/src/Explorer.Analysis/Explorer.Analysis.csproj new file mode 100644 index 0000000..8f78f49 --- /dev/null +++ b/src/Explorer.Analysis/Explorer.Analysis.csproj @@ -0,0 +1,12 @@ + + + Explorer.Analysis + + + + + + + + + diff --git a/src/Explorer.App/App.xaml b/src/Explorer.App/App.xaml new file mode 100644 index 0000000..8348a16 --- /dev/null +++ b/src/Explorer.App/App.xaml @@ -0,0 +1,850 @@ + + + + + + + Segoe MDL2 Assets + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Explorer.App/App.xaml.cs b/src/Explorer.App/App.xaml.cs new file mode 100644 index 0000000..84bd34c --- /dev/null +++ b/src/Explorer.App/App.xaml.cs @@ -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(); + await vm.InitializeAsync().ConfigureAwait(true); + await _host.StartAsync().ConfigureAwait(true); + var window = _host.Services.GetRequiredService(); + 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); + } +} diff --git a/src/Explorer.App/AppServices.cs b/src/Explorer.App/AppServices.cs new file mode 100644 index 0000000..318f500 --- /dev/null +++ b/src/Explorer.App/AppServices.cs @@ -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(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var env = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + return new SqliteIndexStore(env.DatabasePath, logger); + }); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddHostedService(); + 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); + } + } +} diff --git a/src/Explorer.App/AssemblyInfo.cs b/src/Explorer.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/Explorer.App/AssemblyInfo.cs @@ -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) +)] diff --git a/src/Explorer.App/Converters.cs b/src/Explorer.App/Converters.cs new file mode 100644 index 0000000..28d3533 --- /dev/null +++ b/src/Explorer.App/Converters.cs @@ -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; + } + } +} diff --git a/src/Explorer.App/Explorer.App.csproj b/src/Explorer.App/Explorer.App.csproj new file mode 100644 index 0000000..e8028b7 --- /dev/null +++ b/src/Explorer.App/Explorer.App.csproj @@ -0,0 +1,38 @@ + + + WinExe + net10.0-windows + true + enable + enable + app.manifest + ..\..\explorer-workbench-icons\explorer-workbench.ico + Explorer.App + Explorer.App + Explorer Workbench + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Explorer.App/ListViewLayout.cs b/src/Explorer.App/ListViewLayout.cs new file mode 100644 index 0000000..a8830c2 --- /dev/null +++ b/src/Explorer.App/ListViewLayout.cs @@ -0,0 +1,65 @@ +using System.Windows; +using System.Windows.Controls; + +namespace Explorer.App; + +/// +/// Makes the first GridView column consume leftover width so Details/List fill the pane. +/// +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); + } +} diff --git a/src/Explorer.App/MainWindow.xaml b/src/Explorer.App/MainWindow.xaml new file mode 100644 index 0000000..00dd16c --- /dev/null +++ b/src/Explorer.App/MainWindow.xaml @@ -0,0 +1,630 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +