| using System; |
| using System.Collections.Generic; |
| using System.ComponentModel; |
| using System.Diagnostics; |
| using System.IO; |
| using System.Linq; |
| using System.Runtime.InteropServices; |
| using System.Text; |
| using System.Threading; |
| using System.Threading.Tasks; |
| using System.Windows; |
| using System.Windows.Controls; |
| using System.Windows.Data; |
| using System.Windows.Input; |
| using System.Windows.Interop; |
| using System.Windows.Media; |
| using System.Windows.Media.Animation; |
| using System.Windows.Threading; |
| using FastSeekWpf.Core; |
| using FastSeekWpf.NativeInterop; |
|
|
| namespace FastSeekWpf; |
|
|
| public partial class MainWindow : Window, INotifyPropertyChanged |
| { |
| public const uint WM_TOGGLE_WINDOW = Win32Api.WM_USER + 3; |
|
|
| private IndexStore _index = new(); |
| private List<NtfsDrive> _drives = new(); |
| private readonly List<UsnWatcher> _watchers = new(); |
| private readonly object _indexLock = new(); |
| private readonly System.Collections.Concurrent.ConcurrentQueue<IndexEvent> _eventQueue = new(); |
| private CancellationTokenSource? _cts; |
| private List<string> _excludedDirs = new(); |
| private int _selectedIndex; |
| private bool _initialized; |
| private bool _isVisible; |
|
|
| private CancellationTokenSource? _searchDebounceCts; |
| private readonly object _searchLock = new(); |
|
|
| public event PropertyChangedEventHandler? PropertyChanged; |
|
|
| private List<ResultItem> _results = new(); |
| public List<ResultItem> Results |
| { |
| get => _results; |
| set { _results = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Results))); } |
| } |
|
|
| public MainWindow() |
| { |
| Logger.Log("MainWindow constructor"); |
| InitializeComponent(); |
| DataContext = this; |
| _excludedDirs = CacheManager.LoadExclusions(); |
|
|
| Loaded += (s, e) => |
| { |
| var helper = new WindowInteropHelper(this); |
| var hwnd = helper.Handle; |
| HwndSource.FromHwnd(hwnd)?.AddHook(WndProc); |
| EnableDwmRoundedCorners(hwnd); |
| }; |
| } |
|
|
| private void EnableDwmRoundedCorners(IntPtr hwnd) |
| { |
| try |
| { |
| int cornerPref = Win32Api.DWMWCP_ROUND; |
| Win32Api.DwmSetWindowAttribute(hwnd, Win32Api.DWMWA_WINDOW_CORNER_PREFERENCE, ref cornerPref, Marshal.SizeOf(cornerPref)); |
| int darkMode = 1; |
| Win32Api.DwmSetWindowAttribute(hwnd, Win32Api.DWMWA_USE_IMMERSIVE_DARK_MODE, ref darkMode, Marshal.SizeOf(darkMode)); |
| Logger.Log("DWM rounded corners + dark mode enabled."); |
| } |
| catch (Exception ex) |
| { |
| Logger.Log($"DWM setup failed: {ex.Message}"); |
| } |
| } |
|
|
| private void Window_Loaded(object sender, RoutedEventArgs e) |
| { |
| Logger.Log("Window_Loaded"); |
| if (_initialized) return; |
| _initialized = true; |
| CenterWindow(); |
| ShowEmptyState(); |
| Task.Run(async () => await InitializeAsync()); |
| } |
|
|
| private void ShowEmptyState() |
| { |
| Dispatcher.Invoke(() => |
| { |
| ResultsScroll.Visibility = Visibility.Collapsed; |
| EmptyHint.Visibility = Visibility.Visible; |
| EmptyHint.Text = "Indexing..."; |
| StatusBar.Visibility = Visibility.Visible; |
| StatusText.Text = "Starting scan..."; |
| SepLine.Visibility = Visibility.Collapsed; |
| }); |
| } |
|
|
| private async Task InitializeAsync() |
| { |
| Logger.Log("InitializeAsync start"); |
| try |
| { |
| _drives = DriveDiscovery.GetNtfsDrives(); |
| Logger.Log($"Found {_drives.Count} NTFS drives"); |
|
|
| if (_drives.Count == 0) |
| { |
| Dispatcher.Invoke(() => |
| { |
| EmptyHint.Text = "No NTFS drives found.\nRun as Administrator."; |
| StatusText.Text = "Error: no drives"; |
| }); |
| return; |
| } |
|
|
| bool needFullScan = true; |
| var cache = CacheManager.LoadCache(); |
| if (cache != null) |
| { |
| Logger.Log($"Cache loaded: {cache.Entries.Count} entries"); |
| lock (_indexLock) |
| { |
| _index = IndexStore.FromCache(cache); |
| } |
| Logger.Log($"Loaded cache: {_index.Count} files"); |
|
|
| var checkpoints = new List<JournalCheckpoint>(cache.Checkpoints); |
| bool deltaOk = true; |
| foreach (var drive in _drives) |
| { |
| var cp = checkpoints.FirstOrDefault(c => c.DriveLetter == drive.Letter); |
| if (cp != null) |
| { |
| try |
| { |
| var watcher = UsnWatcher.CreateForCheckpoint(drive, evt => _eventQueue.Enqueue(evt), cp); |
| watcher.Drain(); |
| lock (_indexLock) |
| { |
| _index.Checkpoints.RemoveAll(c => c.DriveLetter == drive.Letter); |
| _index.Checkpoints.Add(watcher.Checkpoint()); |
| } |
| _watchers.Add(watcher); |
| Logger.Log($"Delta catch-up OK for {drive.Letter}"); |
| } |
| catch (Exception ex) |
| { |
| Logger.Log($"Delta catch-up failed: {ex.Message}"); |
| deltaOk = false; |
| break; |
| } |
| } |
| } |
|
|
| if (deltaOk) |
| { |
| needFullScan = false; |
| while (_eventQueue.TryDequeue(out var evt)) |
| { |
| ApplyEvent(evt); |
| } |
| Logger.Log("Delta events applied."); |
| } |
| else |
| { |
| Logger.Log("Delta failed, clearing cache."); |
| CacheManager.ClearCache(); |
| _watchers.Clear(); |
| _eventQueue.Clear(); |
| } |
| } |
|
|
| if (needFullScan) |
| { |
| Logger.Log("Starting full MFT scan..."); |
| lock (_indexLock) |
| { |
| _index = new IndexStore(); |
| long totalRecords = 0; |
| foreach (var drive in _drives) |
| { |
| try |
| { |
| Logger.Log($"Scanning {drive.Letter}..."); |
| Dispatcher.Invoke(() => StatusText.Text = $"Scanning {drive.Letter}: ..."); |
|
|
| using var reader = new MftReader(drive); |
| var (scan, method) = reader.ScanAny(); |
|
|
| if (scan.Records.Count > 0) |
| { |
| _index.PopulateFromScan(scan, drive.Root); |
| totalRecords += scan.Records.Count; |
| Logger.Log($"Scanned {drive.Letter}: {scan.Records.Count} files ({method})"); |
| Dispatcher.Invoke(() => StatusText.Text = $"Scanned {drive.Letter}: {scan.Records.Count:N0} files"); |
| } |
| else |
| { |
| Logger.Log($"Scan returned 0 records for {drive.Letter}"); |
| Dispatcher.Invoke(() => StatusText.Text = $"Scan failed for {drive.Letter}"); |
| } |
| } |
| catch (Exception ex) |
| { |
| Logger.Log($"Scan failed for {drive.Letter}: {ex}"); |
| } |
| } |
| _index.CompleteIndex(); |
| Logger.Log($"Full scan complete. Total indexed: {_index.Count}"); |
| CacheManager.SaveCache(_index); |
| Logger.Log("Cache saved."); |
| } |
|
|
| foreach (var drive in _drives) |
| { |
| try |
| { |
| var watcher = new UsnWatcher(drive, evt => _eventQueue.Enqueue(evt), null); |
| _watchers.Add(watcher); |
| _ = watcher.RunAsync(CancellationToken.None); |
| Logger.Log($"USN watcher started for {drive.Letter}"); |
| } |
| catch (Exception ex) |
| { |
| Logger.Log($"USN watcher failed for {drive.Letter}: {ex.Message}"); |
| } |
| } |
| } |
|
|
| _cts = new CancellationTokenSource(); |
| _ = Task.Run(() => LiveUpdateLoop(_cts.Token)); |
|
|
| Dispatcher.Invoke(() => |
| { |
| if (_index.Count == 0) |
| { |
| EmptyHint.Text = "Index is empty.\nRun CLI diagnostics: FastSeekWpf.exe --cli"; |
| StatusText.Text = "0 files indexed — check diagnostics"; |
| } |
| else |
| { |
| EmptyHint.Text = "Start typing to search your files..."; |
| StatusText.Text = $"{_index.Count:N0} files indexed"; |
| } |
| StatusBar.Visibility = Visibility.Visible; |
| }); |
| Logger.Log("InitializeAsync done."); |
| } |
| catch (Exception ex) |
| { |
| Logger.Log($"InitializeAsync fatal: {ex}"); |
| Dispatcher.Invoke(() => |
| { |
| EmptyHint.Text = $"Initialization failed:\n{ex.Message}\n\nCheck log at %LOCALAPPDATA%\\FastSeek\\log.txt"; |
| StatusText.Text = "Error — check log"; |
| }); |
| } |
| } |
|
|
| private void LiveUpdateLoop(CancellationToken ct) |
| { |
| while (!ct.IsCancellationRequested) |
| { |
| int processed = 0; |
| while (_eventQueue.TryDequeue(out var evt)) |
| { |
| ApplyEvent(evt); |
| processed++; |
| if (processed > 100) break; |
| } |
| if (processed > 50) |
| { |
| CacheManager.SaveCache(_index); |
| } |
| Thread.Sleep(100); |
| } |
| } |
|
|
| private void ApplyEvent(IndexEvent evt) |
| { |
| lock (_indexLock) |
| { |
| switch (evt) |
| { |
| case IndexEvent.Created c: |
| _index.Insert(c.Record); |
| break; |
| case IndexEvent.Deleted d: |
| _index.Remove(d.FileRef); |
| break; |
| case IndexEvent.Renamed r: |
| _index.Rename(r.OldRef, r.NewRecord); |
| break; |
| case IndexEvent.Moved m: |
| _index.ApplyMove(m.FileRef, m.NewParentRef, m.Name, m.Kind); |
| break; |
| } |
| } |
| } |
|
|
| private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) |
| { |
| if (msg == WM_TOGGLE_WINDOW) |
| { |
| Dispatcher.Invoke(ToggleVisibility); |
| handled = true; |
| } |
| return IntPtr.Zero; |
| } |
|
|
| private void Window_Deactivated(object sender, EventArgs e) |
| { |
| FadeOutAndHide(); |
| } |
|
|
| private void Window_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) |
| { |
| if (e.ChangedButton == MouseButton.Left) |
| DragMove(); |
| } |
|
|
| private void FadeOutAndHide() |
| { |
| if (Resources["FadeAnim"] is Storyboard fade) |
| { |
| fade.Completed += (s, e) => |
| { |
| Hide(); |
| _isVisible = false; |
| }; |
| fade.Begin(this); |
| } |
| else |
| { |
| Hide(); |
| _isVisible = false; |
| } |
| } |
|
|
| private void ToggleVisibility() |
| { |
| if (_isVisible) |
| { |
| FadeOutAndHide(); |
| } |
| else |
| { |
| CenterWindow(); |
| SearchBox.Clear(); |
| Results = new List<ResultItem>(); |
| ShowEmptyState(); |
| Show(); |
| Activate(); |
| SearchBox.Focus(); |
|
|
| if (Resources["AppearAnim"] is Storyboard appear) |
| { |
| appear.Begin(this); |
| } |
| else |
| { |
| Opacity = 1; |
| } |
| _isVisible = true; |
| } |
| } |
|
|
| private void CenterWindow() |
| { |
| var screenW = SystemParameters.PrimaryScreenWidth; |
| var screenH = SystemParameters.PrimaryScreenHeight; |
| Left = (screenW - Width) / 2; |
| Top = screenH * 0.18; |
| } |
|
|
| private void Window_KeyDown(object sender, KeyEventArgs e) |
| { |
| switch (e.Key) |
| { |
| case Key.Escape: |
| FadeOutAndHide(); |
| e.Handled = true; |
| break; |
| case Key.Down: |
| if (ResultsList.Items.Count > 0 && ResultsList.SelectedIndex + 1 < ResultsList.Items.Count) |
| { |
| ResultsList.SelectedIndex++; |
| ResultsList.ScrollIntoView(ResultsList.SelectedItem); |
| e.Handled = true; |
| } |
| break; |
| case Key.Up: |
| if (ResultsList.SelectedIndex > 0) |
| { |
| ResultsList.SelectedIndex--; |
| ResultsList.ScrollIntoView(ResultsList.SelectedItem); |
| e.Handled = true; |
| } |
| break; |
| case Key.Enter: |
| OpenResult(e.KeyboardDevice.IsKeyDown(Key.LeftCtrl) || e.KeyboardDevice.IsKeyDown(Key.RightCtrl)); |
| e.Handled = true; |
| break; |
| } |
| } |
|
|
| private void SearchBox_Loaded(object sender, RoutedEventArgs e) |
| { |
| SearchBox.Focus(); |
| } |
|
|
| private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) |
| { |
| var query = SearchBox.Text.Trim(); |
| Logger.Log($"SearchBox query: '{query}' (index={_index.Count:N0})"); |
|
|
| if (string.IsNullOrEmpty(query)) |
| { |
| CancelPendingSearch(); |
| Dispatcher.Invoke(() => |
| { |
| Results = new List<ResultItem>(); |
| ResultsScroll.Visibility = Visibility.Collapsed; |
| EmptyHint.Visibility = Visibility.Visible; |
| EmptyHint.Text = _index.Count == 0 |
| ? "Index is empty.\nRun CLI diagnostics: FastSeekWpf.exe --cli" |
| : "Start typing to search your files..."; |
| SepLine.Visibility = Visibility.Collapsed; |
| StatusText.Text = $"{_index.Count:N0} files indexed"; |
| }); |
| return; |
| } |
|
|
| CancelPendingSearch(); |
|
|
| var cts = new CancellationTokenSource(); |
| lock (_searchLock) |
| { |
| _searchDebounceCts = cts; |
| } |
|
|
| Task.Run(async () => |
| { |
| try |
| { |
| await Task.Delay(25, cts.Token); |
| } |
| catch (TaskCanceledException) |
| { |
| return; |
| } |
|
|
| if (cts.IsCancellationRequested) return; |
|
|
| List<SearchResult> results; |
| lock (_indexLock) |
| { |
| results = SearchEngine.Search(_index, query, 50, false, _excludedDirs); |
| } |
|
|
| Logger.Log($"Search '{query}': {results.Count} results returned to UI"); |
|
|
| var items = results.Select((r, i) => new ResultItem |
| { |
| FullPath = r.FullPath, |
| DisplayName = Path.GetFileNameWithoutExtension(r.FullPath), |
| IsSelected = i == 0, |
| IsHovered = false, |
| Kind = r.Kind, |
| IconGlyph = GetIconGlyph(r.Kind), |
| BadgeLabel = GetBadgeLabel(r.Kind), |
| BadgeColor = GetBadgeColor(r.Kind) |
| }).ToList(); |
|
|
| Dispatcher.Invoke(() => |
| { |
| if (cts.IsCancellationRequested) return; |
| _selectedIndex = 0; |
| Results = items; |
|
|
| if (items.Count > 0) |
| { |
| ResultsScroll.Visibility = Visibility.Visible; |
| EmptyHint.Visibility = Visibility.Collapsed; |
| SepLine.Visibility = Visibility.Visible; |
| ResultsList.SelectedIndex = 0; |
| } |
| else |
| { |
| ResultsScroll.Visibility = Visibility.Collapsed; |
| EmptyHint.Visibility = Visibility.Visible; |
| EmptyHint.Text = $"No results for \"{query}\""; |
| SepLine.Visibility = Visibility.Collapsed; |
| } |
|
|
| StatusText.Text = $"{items.Count} results"; |
| StatusBar.Visibility = Visibility.Visible; |
| }); |
| }); |
| } |
|
|
| private void ResultsList_SelectionChanged(object sender, SelectionChangedEventArgs e) |
| { |
| _selectedIndex = ResultsList.SelectedIndex; |
| } |
|
|
| private void CancelPendingSearch() |
| { |
| lock (_searchLock) |
| { |
| _searchDebounceCts?.Cancel(); |
| _searchDebounceCts?.Dispose(); |
| _searchDebounceCts = null; |
| } |
| } |
|
|
| private void ResultItem_Click(object sender, MouseButtonEventArgs e) |
| { |
| if (sender is Border border && border.DataContext is ResultItem item) |
| { |
| int idx = Results.IndexOf(item); |
| if (idx >= 0) |
| { |
| ResultsList.SelectedIndex = idx; |
| OpenResult(false); |
| } |
| } |
| } |
|
|
| private void OpenResult(bool folderOnly) |
| { |
| if (ResultsList.SelectedItem is not ResultItem item) return; |
| string target = folderOnly |
| ? (Directory.Exists(item.FullPath) ? item.FullPath : Path.GetDirectoryName(item.FullPath) ?? item.FullPath) |
| : item.FullPath; |
|
|
| Win32Api.ShellExecuteW(IntPtr.Zero, "open", target, null, null, 1); |
| FadeOutAndHide(); |
| } |
|
|
| private static string GetIconGlyph(ResultKind kind) => kind switch |
| { |
| ResultKind.App => "\uE7E8", |
| ResultKind.Document => "\uE8A5", |
| ResultKind.Image => "\uE91B", |
| ResultKind.Video => "\uE714", |
| ResultKind.Audio => "\uE8D6", |
| ResultKind.Archive => "\uE7B8", |
| ResultKind.Folder => "\uE8B7", |
| _ => "\uE7C3" |
| }; |
|
|
| private static string GetBadgeLabel(ResultKind kind) => kind switch |
| { |
| ResultKind.App => "APP", |
| ResultKind.Document => "DOC", |
| ResultKind.Image => "IMG", |
| ResultKind.Video => "VID", |
| ResultKind.Audio => "AUD", |
| ResultKind.Archive => "ZIP", |
| ResultKind.Folder => "DIR", |
| _ => "FILE" |
| }; |
|
|
| private static Color GetBadgeColor(ResultKind kind) => kind switch |
| { |
| ResultKind.App => Color.FromRgb(60, 200, 120), |
| ResultKind.Document => Color.FromRgb(240, 140, 60), |
| ResultKind.Image => Color.FromRgb(180, 80, 220), |
| ResultKind.Video => Color.FromRgb(220, 60, 80), |
| ResultKind.Audio => Color.FromRgb(60, 160, 240), |
| ResultKind.Archive => Color.FromRgb(200, 160, 40), |
| ResultKind.Folder => Color.FromRgb(80, 120, 220), |
| _ => Color.FromRgb(100, 100, 130) |
| }; |
|
|
| protected override void OnClosing(CancelEventArgs e) |
| { |
| _cts?.Cancel(); |
| CancelPendingSearch(); |
| foreach (var w in _watchers) w.Dispose(); |
| _watchers.Clear(); |
| CacheManager.SaveCache(_index); |
| base.OnClosing(e); |
| } |
| } |
|
|
| public record ResultItem |
| { |
| public string FullPath { get; init; } = ""; |
| public string DisplayName { get; init; } = ""; |
| public bool IsSelected { get; init; } |
| public bool IsHovered { get; init; } |
| public ResultKind Kind { get; init; } |
| public string IconGlyph { get; init; } = ""; |
| public string BadgeLabel { get; init; } = ""; |
| public Color BadgeColor { get; init; } |
| } |
|
|