using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
namespace KillerPDF.Controls
{
// ═══════════════════════════════════════════════════════════
// FOLDER TREE - the file dialog's left pane, below places
// ═══════════════════════════════════════════════════════════
// Ported from KillerShell's FolderTree.cs (the family reference), minus what a modal file
// dialog does not need: no demo mode, no expansion persistence (the dialog reveals the
// current folder on open instead), no shell context menu suite.
//
// One node per folder, children loaded only when a node is actually expanded. A tree that
// eagerly walked the disk would hang on the first drive with a deep tree on it. The lazy
// load is the standard placeholder trick: every node that might have children gets a single
// dummy child so WPF draws an expander arrow, and the real children replace it on first
// expand. "Might have children" is deliberately optimistic - proving a folder empty costs
// the very enumeration being deferred.
public sealed class FolderNode : INotifyPropertyChanged
{
private static readonly FolderNode Placeholder = new("", "", false);
/// Set by FileDialog from its persisted toggle, BEFORE the tree loads. Gates
/// attribute-Hidden/System folders AND leading-dot names, same as the file list.
internal static bool ShowHidden;
public string Path { get; }
public string Name { get; }
// Drives get their own treatment: always expandable, never disappear mid-session, and
// their label is "Local Disk (C:)" rather than a bare folder name.
public bool IsDrive { get; }
public ObservableCollection Children { get; } = [];
public FolderNode(string path, string name, bool mayHaveChildren)
{
Path = path;
Name = name;
if (mayHaveChildren) Children.Add(Placeholder);
}
public FolderNode(DriveInfo d)
{
Path = d.RootDirectory.FullName;
IsDrive = true;
Name = DriveLabel(d);
Children.Add(Placeholder);
}
/// "Local Disk (C:)" style label, or the bare letter when the volume cannot be read.
internal static string DriveLabel(DriveInfo d)
{
string letter = d.Name.TrimEnd('\\');
try
{
// VolumeLabel throws on a drive that is not ready (empty optical, disconnected
// share), which is exactly when we still want to show the letter.
if (d.IsReady && !string.IsNullOrWhiteSpace(d.VolumeLabel))
return d.VolumeLabel + " (" + letter + ")";
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
return letter;
}
public bool IsLoaded { get; private set; }
// A drive's REAL icon (USB, network, optical) via the real-path query; plain folders get
// the shared generic folder icon so the tree never touches the disk per row.
public ImageSource? Icon
=> IsDrive ? Services.ShellIcons.Place(Path) : Services.ShellIcons.Small(Path, true);
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { if (_isExpanded != value) { _isExpanded = value; Raise(); } }
}
private bool _isSelected;
public bool IsSelected
{
get => _isSelected;
set { if (_isSelected != value) { _isSelected = value; Raise(); } }
}
///
/// Replaces the placeholder with the real subfolders. Enumeration happens off the UI
/// thread - a slow or disconnected network drive would otherwise freeze the dialog for
/// as long as the SMB timeout takes.
///
public async Task LoadChildrenAsync()
{
if (IsLoaded) return;
IsLoaded = true;
string path = Path;
List kids = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
Children.Clear();
foreach (var k in kids) Children.Add(k);
}
///
/// Re-enumerates this node's children in place, keeping whatever the user had open.
/// Used when the show-hidden filter changes. Reconciled IN PLACE rather than cleared
/// and refilled: Clear() removes the container holding the tree's selection, and WPF
/// answers a lost selection by selecting the PARENT node - which would navigate the
/// dialog up one folder on a toggle that has nothing to do with where you are.
///
internal async Task RefreshAsync()
{
if (!IsLoaded) return;
string path = Path;
var fresh = await Task.Run(() => EnumerateChildren(path)).ConfigureAwait(true);
var byName = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var n in fresh) byName[n.Name] = n;
for (int i = Children.Count - 1; i >= 0; i--)
if (!byName.ContainsKey(Children[i].Name)) Children.RemoveAt(i);
var have = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var c in Children) have[c.Name] = c;
for (int i = 0; i < fresh.Count; i++)
{
if (have.TryGetValue(fresh[i].Name, out var existing))
{
// Anything the user has opened stays the SAME node object, so its own subtree
// and IsExpanded survive; only genuinely new entries get fresh nodes.
int at = Children.IndexOf(existing);
if (at != i) Children.Move(at, i);
await existing.RefreshAsync(); // its children are stale for the same reason
}
else Children.Insert(i, fresh[i]);
}
}
private static List EnumerateChildren(string path)
{
var list = new List();
try
{
foreach (var d in new DirectoryInfo(path).EnumerateDirectories())
{
// Same gate the file list applies: attribute Hidden/System AND leading-dot
// names, so the two panes never disagree about what exists. System is grouped
// with hidden rather than given its own switch - Explorer's separate option
// guards a handful of roots nobody browses to on purpose.
if (!ShowHidden)
{
var a = d.Attributes;
if ((a & FileAttributes.Hidden) != 0 || (a & FileAttributes.System) != 0) continue;
if (d.Name.StartsWith(".", StringComparison.Ordinal)) continue;
}
list.Add(new FolderNode(d.FullName, d.Name, mayHaveChildren: true));
}
}
catch (UnauthorizedAccessException) { /* show what we can see */ }
catch (IOException) { }
list.Sort((x, y) => string.Compare(x.Name, y.Name, StringComparison.CurrentCultureIgnoreCase));
return list;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void Raise([CallerMemberName] string? p = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
}
// True when a TreeViewItem is the last child of its parent - which is the one thing the
// folder tree's connecting lines need to know. Every node draws a vertical line down its own
// left edge; for the LAST child that line has to stop at the elbow instead of running past
// the bottom of the node into empty space. There is no "IsLastItem" property in WPF and no
// way to ask in pure XAML, hence this. Bound with the item itself as the source, so it
// re-evaluates when the container is recycled onto a different node.
public sealed class LastChildConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not DependencyObject d) return false;
var parent = ItemsControl.ItemsControlFromItemContainer(d);
if (parent == null) return false;
int index = parent.ItemContainerGenerator.IndexFromContainer(d);
return index >= 0 && index == parent.Items.Count - 1;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}