f4cc81bb71
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
C#
280 lines
11 KiB
C#
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using Microsoft.SharePoint.Client;
|
|
using SharepointToolbox.Core.Helpers;
|
|
|
|
namespace SharepointToolbox.Views.Dialogs;
|
|
|
|
public partial class FolderBrowserDialog : Window
|
|
{
|
|
private readonly ClientContext _ctx;
|
|
private readonly bool _allowFileSelection;
|
|
private readonly bool _allowFolderCreation;
|
|
|
|
public string SelectedLibrary { get; private set; } = string.Empty;
|
|
public string SelectedFolderPath { get; private set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Library-relative file paths checked by the user. Only populated when
|
|
/// <paramref name="allowFileSelection"/> was true. Empty if the user picked
|
|
/// a folder node instead.
|
|
/// </summary>
|
|
public IReadOnlyList<string> SelectedFilePaths { get; private set; } = Array.Empty<string>();
|
|
|
|
private readonly List<CheckBox> _fileCheckboxes = new();
|
|
|
|
/// <summary>
|
|
/// Dialog for browsing library folders. Set <paramref name="allowFileSelection"/>
|
|
/// to show individual files (with sizes) and allow ticking them for targeted
|
|
/// transfer. Set <paramref name="allowFolderCreation"/> to expose a "New
|
|
/// Folder" button that creates a folder under the selected node.
|
|
/// </summary>
|
|
public FolderBrowserDialog(ClientContext ctx,
|
|
bool allowFileSelection = false,
|
|
bool allowFolderCreation = false)
|
|
{
|
|
InitializeComponent();
|
|
_ctx = ctx;
|
|
_allowFileSelection = allowFileSelection;
|
|
_allowFolderCreation = allowFolderCreation;
|
|
if (allowFolderCreation)
|
|
ActionBar.Visibility = Visibility.Visible;
|
|
Loaded += OnLoaded;
|
|
}
|
|
|
|
private async void OnLoaded(object sender, RoutedEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var web = _ctx.Web;
|
|
var lists = _ctx.LoadQuery(web.Lists
|
|
.Include(l => l.Title, l => l.Hidden, l => l.BaseType,
|
|
l => l.RootFolder.ServerRelativeUrl)
|
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary));
|
|
var progress = new Progress<Core.Models.OperationProgress>();
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
|
|
|
foreach (var list in lists)
|
|
{
|
|
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
|
var libNode = MakeFolderNode(list.Title,
|
|
new FolderNodeInfo(list.Title, string.Empty, rootUrl));
|
|
FolderTree.Items.Add(libNode);
|
|
}
|
|
|
|
StatusText.Text = $"{FolderTree.Items.Count} libraries loaded.";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusText.Text = $"Error: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private TreeViewItem MakeFolderNode(string name, FolderNodeInfo info)
|
|
{
|
|
var node = new TreeViewItem
|
|
{
|
|
Header = name,
|
|
Tag = info,
|
|
};
|
|
// Placeholder child so the expand arrow appears.
|
|
node.Items.Add(new TreeViewItem { Header = "Loading..." });
|
|
node.Expanded += FolderNode_Expanded;
|
|
return node;
|
|
}
|
|
|
|
private async void FolderNode_Expanded(object sender, RoutedEventArgs e)
|
|
{
|
|
if (sender is not TreeViewItem node || node.Tag is not FolderNodeInfo info)
|
|
return;
|
|
|
|
// Only load children once.
|
|
if (!(node.Items.Count == 1
|
|
&& node.Items[0] is TreeViewItem dummy
|
|
&& dummy.Header?.ToString() == "Loading..."))
|
|
return;
|
|
|
|
node.Items.Clear();
|
|
try
|
|
{
|
|
var folder = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
|
_ctx.Load(folder, f => f.StorageMetrics.TotalSize,
|
|
f => f.StorageMetrics.TotalFileCount,
|
|
f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl,
|
|
sf => sf.StorageMetrics.TotalSize,
|
|
sf => sf.StorageMetrics.TotalFileCount),
|
|
f => f.Files.Include(fi => fi.Name, fi => fi.Length,
|
|
fi => fi.ServerRelativeUrl));
|
|
var progress = new Progress<Core.Models.OperationProgress>();
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
|
|
|
// Annotate the parent node header with total metrics now that they loaded.
|
|
node.Header = FormatFolderHeader(info.LibraryTitle == info.RelativePath || string.IsNullOrEmpty(info.RelativePath)
|
|
? (string)node.Header!
|
|
: System.IO.Path.GetFileName(info.RelativePath),
|
|
folder.StorageMetrics.TotalFileCount,
|
|
folder.StorageMetrics.TotalSize);
|
|
|
|
// Child folders first
|
|
foreach (var subFolder in folder.Folders)
|
|
{
|
|
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
|
continue;
|
|
|
|
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
|
? subFolder.Name
|
|
: $"{info.RelativePath}/{subFolder.Name}";
|
|
|
|
var childInfo = new FolderNodeInfo(
|
|
info.LibraryTitle, childRelative, subFolder.ServerRelativeUrl);
|
|
|
|
var childNode = MakeFolderNode(
|
|
FormatFolderHeader(subFolder.Name,
|
|
subFolder.StorageMetrics.TotalFileCount,
|
|
subFolder.StorageMetrics.TotalSize),
|
|
childInfo);
|
|
node.Items.Add(childNode);
|
|
}
|
|
|
|
// Files under this folder — only shown when selection is enabled.
|
|
if (_allowFileSelection)
|
|
{
|
|
foreach (var file in folder.Files)
|
|
{
|
|
// Library-relative path for the file (used by the transfer service)
|
|
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
|
? file.Name
|
|
: $"{info.RelativePath}/{file.Name}";
|
|
|
|
var cb = new CheckBox
|
|
{
|
|
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
|
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
|
Margin = new Thickness(4, 2, 0, 2),
|
|
};
|
|
cb.Checked += FileCheckbox_Toggled;
|
|
cb.Unchecked += FileCheckbox_Toggled;
|
|
_fileCheckboxes.Add(cb);
|
|
|
|
var fileItem = new TreeViewItem { Header = cb, Focusable = false };
|
|
node.Items.Add(fileItem);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
|
}
|
|
}
|
|
|
|
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
|
|
{
|
|
if (fileCount <= 0) return name;
|
|
return $"{name} ({fileCount} files, {FormatSize(totalBytes)})";
|
|
}
|
|
|
|
private static string FormatSize(long bytes)
|
|
{
|
|
if (bytes <= 0) return "0 B";
|
|
if (bytes >= 1_073_741_824L) return $"{bytes / 1_073_741_824.0:F2} GB";
|
|
if (bytes >= 1_048_576L) return $"{bytes / 1_048_576.0:F2} MB";
|
|
if (bytes >= 1024L) return $"{bytes / 1024.0:F2} KB";
|
|
return $"{bytes} B";
|
|
}
|
|
|
|
private void FolderTree_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs<object> e)
|
|
{
|
|
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
|
|
{
|
|
SelectedLibrary = info.LibraryTitle;
|
|
SelectedFolderPath = info.RelativePath;
|
|
SelectButton.IsEnabled = true;
|
|
NewFolderButton.IsEnabled = _allowFolderCreation;
|
|
}
|
|
else
|
|
{
|
|
// File nodes have CheckBox headers, not FolderNodeInfo tags.
|
|
NewFolderButton.IsEnabled = false;
|
|
}
|
|
}
|
|
|
|
private void FileCheckbox_Toggled(object sender, RoutedEventArgs e)
|
|
{
|
|
// Enable "Select" as soon as any file is checked — user can confirm
|
|
// purely via file selection without also picking a folder node.
|
|
if (_fileCheckboxes.Any(c => c.IsChecked == true))
|
|
SelectButton.IsEnabled = true;
|
|
}
|
|
|
|
private async void NewFolder_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
if (FolderTree.SelectedItem is not TreeViewItem node ||
|
|
node.Tag is not FolderNodeInfo info)
|
|
return;
|
|
|
|
var dlg = new InputDialog("New folder name:", string.Empty)
|
|
{
|
|
Owner = this
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
var folderName = dlg.ResponseText.Trim();
|
|
if (string.IsNullOrEmpty(folderName)) return;
|
|
|
|
try
|
|
{
|
|
var parent = _ctx.Web.GetFolderByServerRelativeUrl(info.ServerRelativeUrl);
|
|
var created = parent.Folders.Add(folderName);
|
|
_ctx.Load(created, f => f.ServerRelativeUrl, f => f.Name);
|
|
var progress = new Progress<Core.Models.OperationProgress>();
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
|
|
|
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
|
? created.Name
|
|
: $"{info.RelativePath}/{created.Name}";
|
|
var childInfo = new FolderNodeInfo(
|
|
info.LibraryTitle, childRelative, created.ServerRelativeUrl);
|
|
var childNode = MakeFolderNode(created.Name, childInfo);
|
|
|
|
// Expand the parent so the fresh folder is visible immediately.
|
|
node.IsExpanded = true;
|
|
node.Items.Add(childNode);
|
|
StatusText.Text = $"Created: {created.ServerRelativeUrl}";
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusText.Text = $"Error: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void Select_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
// Harvest checked files (library-relative paths).
|
|
SelectedFilePaths = _fileCheckboxes
|
|
.Where(c => c.IsChecked == true && c.Tag is FileNodeInfo)
|
|
.Select(c => ((FileNodeInfo)c.Tag!).RelativePath)
|
|
.ToList();
|
|
|
|
// If files were picked but no folder node was selected, borrow the
|
|
// library from the first file so the caller still has a valid target.
|
|
if (SelectedFilePaths.Count > 0 && string.IsNullOrEmpty(SelectedLibrary))
|
|
{
|
|
var firstTag = (FileNodeInfo)_fileCheckboxes
|
|
.First(c => c.IsChecked == true && c.Tag is FileNodeInfo).Tag!;
|
|
SelectedLibrary = firstTag.LibraryTitle;
|
|
SelectedFolderPath = string.Empty;
|
|
}
|
|
|
|
DialogResult = true;
|
|
Close();
|
|
}
|
|
|
|
private void Cancel_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
DialogResult = false;
|
|
Close();
|
|
}
|
|
|
|
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
|
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
|
}
|