using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media.Imaging;
namespace MmdPdf.Controls
{
/// Open or Save. Picked at construction; changes the accept button and the rules.
public enum FileDialogMode { Open, Save }
///
/// Themed stand-in for Microsoft.Win32.OpenFileDialog / SaveFileDialog. Same chrome, places
/// rail, view modes and sortable columns as FolderPickerDialog (row styles shared from
/// Controls.xaml), plus a file name box and a filter combo.
///
/// The property surface mirrors the Win32 dialogs on purpose - Title, Filter, FilterIndex,
/// FileName, InitialDirectory, DefaultExt, AddExtension, OverwritePrompt, CheckFileExists -
/// so adopting it at a call site is a one-word change:
///
/// var dlg = new FileDialog(FileDialogMode.Save) { Title = ..., Filter = ..., FileName = ... };
/// if (dlg.ShowDialog(owner) == true) Use(dlg.FileName);
///
/// Multiselect is deliberately NOT implemented yet - nothing in the app needs it, and
/// a half-working Multiselect is worse than an absent one. Add it when something wants it.
///
public partial class FileDialog : Window
{
// ── Win32-compatible surface ─────────────────────────────────────────────
/// Win32 filter syntax: "Desc|*.a;*.b|Other|*.c". Empty means every file.
public string Filter { get; set; } = "";
/// 1-based, like the Win32 dialogs. Out of range is clamped.
public int FilterIndex { get; set; } = 1;
/// Seeded with a suggested name; on OK, the full chosen path.
public string FileName { get; set; } = "";
public string InitialDirectory { get; set; } = "";
/// Appended on save when the typed name has no extension. No leading dot needed.
public string DefaultExt { get; set; } = "";
public bool AddExtension { get; set; } = true;
/// Save mode: confirm before replacing an existing file.
public bool OverwritePrompt { get; set; } = true;
/// Open mode: refuse to return a path that does not exist.
public bool CheckFileExists { get; set; } = true;
/// Refuse a path whose DIRECTORY does not exist. On by default, matching the
/// Win32 dialogs - the picker never creates folder trees on the user's behalf, so turning
/// this off only means the caller has agreed to handle a missing directory itself.
public bool CheckPathExists { get; set; } = true;
/// Open mode only: let the user pick several files at once (Ctrl/Shift click, or
/// a drag over the list). Ignored in Save mode, where "several names" is meaningless.
/// Set it BEFORE ShowDialog - the list's selection mode is applied there.
public bool Multiselect { get; set; }
/// Show a live preview pane for image-selection workflows. The pane is opt-in so
/// ordinary Open and Save dialogs keep their compact layout.
public bool ShowImagePreview { get; set; }
/// Every path chosen. Always populated on success, so a caller can read this
/// whether or not it asked for Multiselect - single selection yields one entry, matching
/// the Win32 dialogs' FileNames. FileName remains the first of them.
public string[] FileNames { get; private set; } = [];
// ── internals ────────────────────────────────────────────────────────────
private readonly FileDialogMode _mode;
public ObservableCollection Places { get; } = [];
public ObservableCollection Entries { get; } = [];
private readonly List _raw = [];
private string _currentDir = string.Empty;
private bool _navigating;
private bool _built; // suppresses filter events during construction
private int _viewMode; // 0 list, 1 icons, 2 details
private int _sortKey; // 0 name, 1 size, 2 modified
private bool _sortAsc = true;
private int _imagePreviewGeneration;
private static readonly HashSet PreviewImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tif", ".tiff"
};
// Per-filter-entry patterns, parallel to FilterCombo's items. Empty list = show all.
private readonly List _filterPatterns = [];
private static readonly string ArrowUp = ((char)0xE70E).ToString();
private static readonly string ArrowDown = ((char)0xE70D).ToString();
// ── Tree / pinned places / recents / hidden state ────────────────────────
public ObservableCollection TreeRoots { get; } = [];
private bool _treeSyncing; // tree selection navigates, navigation selects: no ping-pong
private bool _showHidden;
private const string ShowHiddenKey = "FileDlgShowHidden";
private const string RecentsKey = "FileDlgRecents";
private const string PinnedKey = "FileDlgPinned";
private const string PlacesHKey = "FileDlgPlacesH";
private const string LastOpenKey = "FileDlgLastOpenDir";
private const string LastSaveKey = "FileDlgLastSaveDir";
// Image pickers (every caller with ShowImagePreview) remember their own folder, separate
// from the document open/save memory. One shared key meant Insert Image always started
// wherever the last PDF was opened from, never where the user last picked an image.
private string LastDirKey =>
(_mode == FileDialogMode.Open ? LastOpenKey : LastSaveKey) + (ShowImagePreview ? "Img" : "");
private const int RecentsMax = 12;
// Guards the fade-then-close re-entry below. Without it OnClosing would cancel forever.
private bool _fadingOut;
/// The result Accept wants, held until the window is actually allowed to close.
/// Null means cancel (the X, Escape, the Cancel button - none of them set it).
private bool? _pendingResult;
/// Fades the dialog out before it actually closes. The first pass cancels the
/// close and runs the fade; the second sees the flag and lets it through.
///
/// The result CANNOT be assigned before the fade. Assigning Window.DialogResult is itself a
/// close request, so it lands in this handler, which cancels that close - and WPF resets
/// DialogResult to null whenever a close is canceled. Accept therefore records what it
/// wants in _pendingResult and the assignment happens in the fade's completion callback,
/// where nothing will cancel it. Assigning DialogResult there also closes the window, which
/// is why that branch does not call Close() as well.
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
if (!_fadingOut)
{
_fadingOut = true;
e.Cancel = true;
Anim.FadeOut(RootFade, () =>
{
if (_pendingResult.HasValue) DialogResult = _pendingResult; // this closes it
else Close(); // cancel path
});
return;
}
base.OnClosing(e);
}
public FileDialog(FileDialogMode mode = FileDialogMode.Open)
{
_mode = mode;
InitializeComponent();
Loaded += (_, _) => Anim.FadeIn(RootFade);
// Size and placement remembered separately from the folder picker: this dialog is a
// different shape and sharing the keys would make each one fight the other.
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
if (double.TryParse(App.GetSetting("FileDlgW"),
System.Globalization.NumberStyles.Float, ci, out double w) &&
double.TryParse(App.GetSetting("FileDlgH"),
System.Globalization.NumberStyles.Float, ci, out double h))
{
Width = Math.Max(MinWidth, Math.Min(w, SystemParameters.WorkArea.Width));
Height = Math.Max(MinHeight, Math.Min(h, SystemParameters.WorkArea.Height));
}
if (double.TryParse(App.GetSetting("FileDlgX"),
System.Globalization.NumberStyles.Float, ci, out double x) &&
double.TryParse(App.GetSetting("FileDlgY"),
System.Globalization.NumberStyles.Float, ci, out double y))
{
var wa = SystemParameters.WorkArea;
if (x > wa.Left - Width + 80 && x < wa.Right - 80 &&
y > wa.Top - 20 && y < wa.Bottom - 80)
{
WindowStartupLocation = WindowStartupLocation.Manual;
Left = x;
Top = y;
}
}
if (double.TryParse(App.GetSetting(PlacesHKey),
System.Globalization.NumberStyles.Float, ci, out double ph) && ph >= 56)
PlacesRow.Height = new GridLength(Math.Min(ph, 600));
}
catch { /* registry unavailable - defaults are fine */ }
_showHidden = App.GetSetting(ShowHiddenKey) == "1";
FolderNode.ShowHidden = _showHidden;
ApplyShowHiddenButton();
Closing += (_, _) =>
{
try
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
App.SetSetting("FileDlgW", ActualWidth.ToString(ci));
App.SetSetting("FileDlgH", ActualHeight.ToString(ci));
App.SetSetting("FileDlgX", Left.ToString(ci));
App.SetSetting("FileDlgY", Top.ToString(ci));
App.SetSetting(PlacesHKey, PlacesRow.ActualHeight.ToString(ci));
}
catch { /* not worth failing the close */ }
};
// NO DwmChrome calls, deliberately. On an AllowsTransparency window the DWM corner
// preference makes DWM composite its own rounded frame around the WINDOW rect - the
// transparent 10px halo included - and SetThemeBorder tints it: that WAS the gray
// band. The other four dialogs are AllowsTransparency with no DWM calls and have
// never shown one; the card draws its own border and shadow, so DWM has nothing to
// add. The WM_ERASEBKGND hook that lived here went too - a layered window is rendered
// via UpdateLayeredWindow and never receives it. (2026-07-30, fifth attempt)
}
///
/// Sets the owner and shows modally. Everything that depends on Filter / FileName /
/// InitialDirectory is wired HERE rather than in the constructor, because callers set
/// those as object-initializer properties after construction.
///
public bool? ShowDialog(Window? owner)
{
if (owner != null && owner.IsVisible) Owner = owner;
// The picker uses the caller's operation title in its caption. Reuse the owner's
// canonical close-button template so 98SE gets the square raised caption button.
if (owner?.TryFindResource("ChromeCloseButton") is Style closeStyle)
CaptionCloseButton.Style = closeStyle;
AcceptButton.Content = Loc(_mode == FileDialogMode.Save ? "Str_Btn_Save" : "Str_Btn_Open");
ConfigureImagePreview();
// Extended, not Multiple: Extended is the Explorer behavior (plain click replaces the
// selection, Ctrl adds, Shift ranges). Multiple toggles on every click, which feels
// broken to anyone who has used a file dialog before.
FileList.SelectionMode = Multiselect && _mode == FileDialogMode.Open
? SelectionMode.Extended
: SelectionMode.Single;
// Open mode has nothing to name, so the box is for typing/filtering a path, not a
// new file. It stays visible: typing an exact name is faster than hunting for it.
BuildFilters();
BuildPlaces();
PlacesList.ItemsSource = Places;
FileList.ItemsSource = Entries;
InitTree();
ApplyView();
// A seeded FileName can be a bare name ("export.ics"), a full path, or empty.
string startDir = InitialDirectory;
string seedName = "";
if (!string.IsNullOrWhiteSpace(FileName))
{
if (FileName.IndexOfAny(['\\', '/']) >= 0)
{
var d = Path.GetDirectoryName(FileName);
if (!string.IsNullOrEmpty(d) && Directory.Exists(d)) startDir = d!;
seedName = Path.GetFileName(FileName);
}
else seedName = FileName;
}
if (string.IsNullOrWhiteSpace(startDir) || !Directory.Exists(startDir))
{
string? remembered = App.GetSetting(LastDirKey);
startDir = !string.IsNullOrWhiteSpace(remembered) && Directory.Exists(remembered)
? remembered!
: Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
}
_built = true;
NavigateTo(startDir);
FileNameBox.Text = seedName;
// Save: preselect the stem so typing replaces the name but keeps the extension
// visible. Open: caret at the end.
FileNameBox.Focus();
if (_mode == FileDialogMode.Save && seedName.Length > 0)
{
int dot = seedName.LastIndexOf('.');
FileNameBox.Select(0, dot > 0 ? dot : seedName.Length);
}
else FileNameBox.CaretIndex = FileNameBox.Text.Length;
return ShowDialog();
}
// ── Filters ──────────────────────────────────────────────────────────────
///
/// Parses Win32 filter syntax into the combo plus a parallel pattern list. A malformed
/// filter (odd number of segments) degrades to "all files" rather than throwing - a bad
/// filter string should not stop someone opening a file.
///
private void BuildFilters()
{
FilterCombo.Items.Clear();
_filterPatterns.Clear();
var parts = (Filter ?? "").Split('|');
for (int i = 0; i + 1 < parts.Length; i += 2)
{
var label = parts[i].Trim();
var pats = parts[i + 1].Split(';')
.Select(p => p.Trim())
.Where(p => p.Length > 0)
.ToArray();
if (label.Length == 0 || pats.Length == 0) continue;
FilterCombo.Items.Add(label);
_filterPatterns.Add(pats);
}
if (FilterCombo.Items.Count == 0)
{
FilterCombo.Items.Add(Loc("Str_Dlg_AllFiles"));
_filterPatterns.Add(["*.*"]);
}
int idx = FilterIndex - 1;
FilterCombo.SelectedIndex = idx >= 0 && idx < FilterCombo.Items.Count ? idx : 0;
FilterLabel.Visibility = FilterCombo.Visibility =
FilterCombo.Items.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
private void Filter_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (!_built) return;
FilterIndex = FilterCombo.SelectedIndex + 1;
// Save mode follows the Win32 dialogs: switching the type swaps the typed name's
// extension - but only when the current one belongs to another entry of THIS filter.
// An extension the user typed by hand is theirs and is left alone.
if (_mode == FileDialogMode.Save)
{
string? newExt = ActiveFilterExt();
string name = FileNameBox.Text?.Trim() ?? "";
string cur = name.Length == 0 ? "" : Path.GetExtension(name);
if (newExt != null && cur.Length > 0 &&
!cur.Equals(newExt, StringComparison.OrdinalIgnoreCase) &&
AllFilterExts().Contains(cur, StringComparer.OrdinalIgnoreCase))
{
FileNameBox.Text = Path.ChangeExtension(name, newExt);
}
}
ApplySort();
}
///
/// The active filter entry's own extension (".csv"), or null when its first pattern is a
/// wildcard-any or a multi-pattern catch-all that names no single extension.
///
private string? ActiveFilterExt()
{
int i = FilterCombo.SelectedIndex;
if (i < 0 || i >= _filterPatterns.Count) return null;
string p = _filterPatterns[i][0];
if (p.Length > 2 && p.StartsWith("*.") && p.IndexOfAny(['*', '?'], 2) < 0)
return p.Substring(1);
return null;
}
/// Every concrete extension the filter list names, for the swap test above.
private IEnumerable AllFilterExts()
{
foreach (var pats in _filterPatterns)
foreach (var p in pats)
if (p.Length > 2 && p.StartsWith("*.") && p.IndexOfAny(['*', '?'], 2) < 0)
yield return p.Substring(1);
}
/// True when the name passes the active filter. Folders are never filtered out.
private bool PassesFilter(PickerEntry en)
{
if (en.IsFolder) return true;
int i = FilterCombo.SelectedIndex;
if (i < 0 || i >= _filterPatterns.Count) return true;
var pats = _filterPatterns[i];
return pats.Any(p => p == "*.*" || p == "*" || WildcardMatch(en.Name, p));
}
/// Case-insensitive glob. Anchored, so "*.ics" does not match "a.icsx".
private static bool WildcardMatch(string name, string pattern)
{
var rx = "^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$";
return Regex.IsMatch(name, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
}
// ── Quick places (pinned + drives) ───────────────────────────────────────
///
/// Pinned folders first (persisted, user-editable via right-click), then the ready
/// drives. Drives are enumerated live every build - they come and go with USB sticks -
/// and are not pinned, so they carry no remove menu.
///
private void BuildPlaces()
{
Places.Clear();
var added = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var p in PinnedPaths())
if (added.Add(p.TrimEnd('\\'))) AddPlace(LabelFor(p), p, pinned: true);
foreach (var place in ExplorerQuickAccessPlaces())
if (added.Add(place.Path.TrimEnd('\\'))) AddPlace(place.Label, place.Path);
foreach (var d in DriveInfo.GetDrives().Where(d => d.IsReady))
{
string label;
try { label = string.IsNullOrWhiteSpace(d.VolumeLabel) ? d.DriveType.ToString() : d.VolumeLabel.Trim(); }
catch { label = d.DriveType.ToString(); }
if (added.Add(d.RootDirectory.FullName.TrimEnd('\\')))
AddPlace($"{d.Name.TrimEnd('\\')} {label}", d.RootDirectory.FullName);
}
}
///
/// Explorer's Quick Access entries, read through the Shell.Application COM object.
///
/// Returns a materialized list rather than an iterator so the whole body can sit inside a
/// real try/catch: a yield-return method is only allowed a finally, so every failure here
/// used to escape into BuildPlaces and take the Open dialog down with it. Each call below
/// is late-bound COM and can fail outright - Wine and CrossOver register the
/// Shell.Application ProgID but implement no NameSpace, so the ProgID null check passes
/// and the binder then throws RuntimeBinderException. Quick Access is a convenience, so
/// any failure just drops it and leaves the pinned folders and the drives.
///
private static List<(string Label, string Path)> ExplorerQuickAccessPlaces()
{
const string QuickAccess = "shell:::{679f85cb-0220-4080-b29b-5540cc05aab6}";
var places = new List<(string Label, string Path)>();
object? shell = null, folder = null, items = null;
try
{
var type = Type.GetTypeFromProgID("Shell.Application");
if (type == null) return places;
shell = Activator.CreateInstance(type);
folder = ((dynamic)shell!).NameSpace(QuickAccess);
if (folder == null) return places;
items = ((dynamic)folder).Items();
int count = ((dynamic)items).Count;
for (int i = 0; i < count; i++)
{
object? item = null;
try
{
item = ((dynamic)items).Item(i);
if (item == null) continue;
dynamic quickItem = item;
if (!Convert.ToBoolean(quickItem.IsFolder)) continue;
string path = Convert.ToString(quickItem.Path) ?? "";
string name = Convert.ToString(quickItem.Name) ?? "";
if (Directory.Exists(path)) places.Add((name.Length > 0 ? name : LabelFor(path), path));
}
catch { /* one unreadable entry must not lose the rest */ }
finally { if (item != null && Marshal.IsComObject(item)) Marshal.FinalReleaseComObject(item); }
}
}
catch { /* no shell, or a shell without Quick Access: keep whatever was read */ }
finally
{
if (items != null && Marshal.IsComObject(items)) Marshal.FinalReleaseComObject(items);
if (folder != null && Marshal.IsComObject(folder)) Marshal.FinalReleaseComObject(folder);
if (shell != null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
}
return places;
}
///
/// The persisted pin list. First run (key absent, null) seeds the five standard folders;
/// an EMPTY stored value means the user unpinned everything and must stay empty.
///
private static List PinnedPaths()
{
string? saved = App.GetSetting(PinnedKey);
if (saved != null)
return [.. saved.Split('|').Where(s => s.Length > 0)];
return [.. new List
{
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
Environment.GetFolderPath(Environment.SpecialFolder.Desktop),
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"),
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures),
}.Where(p => !string.IsNullOrEmpty(p))];
}
/// Localized label for the five standard folders, plain folder name otherwise.
private static string LabelFor(string path)
{
string p = path.TrimEnd('\\');
bool Is(string other) => other.Length > 0 &&
p.Equals(other.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase);
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))) return Loc("Str_QA_Home");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.Desktop))) return Loc("Str_QA_Desktop");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments))) return Loc("Str_QA_Documents");
if (Is(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads")))
return Loc("Str_QA_Downloads");
if (Is(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures))) return Loc("Str_QA_Pictures");
var name = Path.GetFileName(p);
return name.Length == 0 ? p : name;
}
private void AddPlace(string label, string path, bool pinned = false)
{
if (!string.IsNullOrEmpty(path) && Directory.Exists(path))
Places.Add(new PickerPlace(label, path, pinned));
}
private void PinPlace(string path)
{
var list = PinnedPaths();
if (list.Any(p => p.TrimEnd('\\').Equals(path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)))
return;
list.Add(path);
App.SetSetting(PinnedKey, string.Join("|", list));
BuildPlaces();
SyncPlacesSelection();
}
private PickerPlace? _placesMenuPlace;
private void Places_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_placesMenuPlace = ItemUnder(e.OriginalSource as DependencyObject);
// Drives are dynamic, not pinned - nothing to remove; empty space likewise.
if (_placesMenuPlace is not { Pinned: true }) e.Handled = true;
}
private void UnpinPlace_Click(object sender, RoutedEventArgs e)
{
if (_placesMenuPlace is not { Pinned: true } pl) return;
var list = PinnedPaths()
.Where(p => !p.TrimEnd('\\').Equals(pl.Path.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
.ToList();
App.SetSetting(PinnedKey, string.Join("|", list));
BuildPlaces();
SyncPlacesSelection();
}
private PickerEntry? _filesMenuEntry;
private void Files_ContextMenuOpening(object sender, ContextMenuEventArgs e)
{
_filesMenuEntry = ItemUnder(e.OriginalSource as DependencyObject);
if (_filesMenuEntry is not { IsFolder: true }) e.Handled = true; // only folders pin
}
private void FilePin_Click(object sender, RoutedEventArgs e)
{
if (_filesMenuEntry is { IsFolder: true } en) PinPlace(en.FullPath);
}
/// Marks the place matching the current folder, or clears the marker.
private void SyncPlacesSelection()
{
bool was = _navigating;
_navigating = true;
PlacesList.SelectedItem = _currentDir.Length == 0 ? null : Places.FirstOrDefault(p =>
p.Path.TrimEnd('\\').Equals(_currentDir.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase));
_navigating = was;
}
/// The row model under a right-click, resolved by walking up to the ListBoxItem.
private static T? ItemUnder(DependencyObject? d) where T : class
{
while (d != null)
{
if (d is ListBoxItem lbi) return lbi.DataContext as T;
d = d is System.Windows.Media.Visual or System.Windows.Media.Media3D.Visual3D
? System.Windows.Media.VisualTreeHelper.GetParent(d)
: LogicalTreeHelper.GetParent(d);
}
return null;
}
private static string Loc(string key)
=> Application.Current.TryFindResource(key) as string ?? key;
// ── Navigation ───────────────────────────────────────────────────────────
private void NavigateTo(string dir)
{
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir)) return;
_navigating = true;
UpdateImagePreview(null);
_currentDir = dir;
PathBox.Text = dir;
_raw.Clear();
try
{
// The toggle gates two things together: attribute Hidden/System AND leading-dot
// names - the Unix convention is all over a Windows home folder (.gradle, .ssh)
// and those carry no Hidden attribute. Same gate in the folder tree (FolderTree.cs).
foreach (var sub in Directory.EnumerateDirectories(dir))
{
DirectoryInfo info;
try { info = new DirectoryInfo(sub); } catch { continue; }
if (!_showHidden)
{
if ((info.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) continue;
if (info.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
_raw.Add(new PickerEntry(info.Name, sub, true, 0, SafeTime(() => info.LastWriteTime)));
}
foreach (var file in Directory.EnumerateFiles(dir))
{
FileInfo fi;
try { fi = new FileInfo(file); } catch { continue; }
if (!_showHidden)
{
if ((fi.Attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) continue;
if (fi.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
_raw.Add(new PickerEntry(fi.Name, file, false, SafeLen(fi), SafeTime(() => fi.LastWriteTime)));
}
}
catch { /* unauthorized / unreadable - show what we have */ }
ApplySort();
UpButton.IsEnabled = Directory.GetParent(dir) != null;
UpdateInfoSummary();
SyncPlacesSelection();
_navigating = false;
RecordRecent(dir);
_ = RevealInTree(dir);
}
private static DateTime SafeTime(Func get)
{
try { return get(); } catch { return DateTime.MinValue; }
}
private static long SafeLen(FileInfo fi)
{
try { return fi.Length; } catch { return 0; }
}
private void Up_Click(object sender, RoutedEventArgs e)
{
var parent = Directory.GetParent(_currentDir);
if (parent != null) NavigateTo(parent.FullName);
}
private void Places_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_navigating) return;
if (PlacesList.SelectedItem is PickerPlace p) NavigateTo(p.Path);
}
// ── Folder tree (ported from KillerShell, see Controls/FolderTree.cs) ────
/// Ready drives only - an empty optical drive or a dropped mapping would sit
/// there as a node that throws the moment anyone touches it.
private void InitTree()
{
if (TreeRoots.Count > 0) return;
FolderTreeCtl.ItemsSource = TreeRoots;
DriveInfo[] drives;
try { drives = DriveInfo.GetDrives(); }
catch (IOException) { return; }
foreach (var d in drives)
{
bool ready;
try { ready = d.IsReady; }
catch (IOException) { continue; }
catch (UnauthorizedAccessException) { continue; }
if (ready) TreeRoots.Add(new FolderNode(d));
}
// Edge fades follow the scroll position (KillerShell TreePanel.cs). ScrollChanged is
// handled at the TreeView rather than dug out of its template: it bubbles, so the
// inner ScrollViewer is reached without needing to have found it first. Loaded and
// SizeChanged cover the passes where nothing scrolled but the extent moved.
FolderTreeCtl.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); }));
FolderTreeCtl.SizeChanged += (_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); };
FolderTreeCtl.Loaded += (_, _) => { SyncTreeEdgeFades(); SyncTreeFade(); };
// The places list gets the same treatment (2026-07-30). No scrollbar lift:
// horizontal scrolling is disabled on it.
PlacesList.AddHandler(ScrollViewer.ScrollChangedEvent,
new ScrollChangedEventHandler((_, _) => SyncPlacesEdgeFades()));
PlacesList.SizeChanged += (_, _) => SyncPlacesEdgeFades();
PlacesList.Loaded += (_, _) => SyncPlacesEdgeFades();
}
/// Places-list twin of SyncTreeEdgeFades: same ramp, same rules.
private void SyncPlacesEdgeFades()
{
var sv = FindDescendant(PlacesList);
if (sv == null) return;
PlacesFadeTop.Opacity = Ramp(sv.VerticalOffset, PlacesFadeTop.Height, 18);
PlacesFadeBottom.Opacity = Ramp(sv.ExtentHeight - sv.ViewportHeight - sv.VerticalOffset,
PlacesFadeBottom.Height, 22);
}
///
/// Fade each edge only while there is something PAST it, ramped over the fade's own
/// height: none at the very top, none at the very bottom, full in between. A proportional
/// ramp rather than a flip - at one pixel of scroll it is one pixel's worth of fade, so
/// neither edge ever pops. (KillerShell TreePanel.SyncTreeEdgeFades, verbatim.)
///
private void SyncTreeEdgeFades()
{
var sv = FindDescendant(FolderTreeCtl);
if (sv == null) return;
TreeFadeTop.Opacity = Ramp(sv.VerticalOffset, TreeFadeTop.Height, 18);
TreeFadeBottom.Opacity = Ramp(sv.ExtentHeight - sv.ViewportHeight - sv.VerticalOffset,
TreeFadeBottom.Height, 22);
}
// Height is NaN until the border has been laid out, hence the fallback.
private static double Ramp(double distance, double height, double fallback)
{
double h = double.IsNaN(height) || height <= 0 ? fallback : height;
return Math.Min(1, Math.Max(0, distance) / h);
}
///
/// Keep the bottom edge fade sitting on the tree's last visible ROW rather than on the
/// horizontal scrollbar underneath it. The bar's real height is measured, not taken from
/// SystemParameters - the themed template is not the system metric. Base 4 is the tree's
/// own bottom margin. (KillerShell TreePanel.SyncTreeFade, adapted.)
///
private void SyncTreeFade()
{
var sv = FindDescendant(FolderTreeCtl);
double lift = 0;
if (sv != null && sv.ComputedHorizontalScrollBarVisibility == Visibility.Visible)
{
var bar = FindHorizontalBar(sv);
lift = bar?.ActualHeight ?? SystemParameters.HorizontalScrollBarHeight;
}
var m = TreeFadeBottom.Margin;
double want = 4 + lift;
if (Math.Abs(m.Bottom - want) < 0.5) return; // no churn on every layout pass
TreeFadeBottom.Margin = new Thickness(m.Left, m.Top, m.Right, want);
}
private void FolderTree_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
var sv = FindDescendant(FolderTreeCtl);
if (sv is null) return;
bool horizontal = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
if (horizontal)
{
if (sv.ScrollableWidth <= 0) return;
sv.ScrollToHorizontalOffset(sv.HorizontalOffset - e.Delta * 0.5);
}
else
{
if (sv.ScrollableHeight <= 0) return;
sv.ScrollToVerticalOffset(sv.VerticalOffset - e.Delta * 0.5);
}
e.Handled = true;
}
private static T? FindDescendant(DependencyObject root) where T : DependencyObject
{
int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < n; i++)
{
var c = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
if (c is T hit) return hit;
var deeper = FindDescendant(c);
if (deeper != null) return deeper;
}
return null;
}
// FindDescendant takes the FIRST match of a type, and a ScrollViewer has two scrollbars,
// so the orientation has to be checked rather than assumed.
private static System.Windows.Controls.Primitives.ScrollBar? FindHorizontalBar(DependencyObject root)
{
int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (int i = 0; i < n; i++)
{
var c = System.Windows.Media.VisualTreeHelper.GetChild(root, i);
if (c is System.Windows.Controls.Primitives.ScrollBar sb &&
sb.Orientation == Orientation.Horizontal) return sb;
var deeper = FindHorizontalBar(c);
if (deeper != null) return deeper;
}
return null;
}
// TreeViewItem.Expanded is attached at the TreeView, so this fires for every node at any
// depth - which is the point: one handler drives the whole lazy load.
private async void FolderTree_Expanded(object sender, RoutedEventArgs e)
{
if (e.OriginalSource is not TreeViewItem tvi) return;
if (tvi.DataContext is not FolderNode node) return;
await node.LoadChildrenAsync();
}
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs