MainWindow.xaml.cs: Update to use IndexStore.Finalize() API matching Rust. Remove CompleteIndex() call that no longer exists.
6b2f56b verified | 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.Finalize(); | |
| 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; } | |
| } | |