chore: release v2.4
- 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>
This commit is contained in:
@@ -8,13 +8,37 @@ 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;
|
||||
|
||||
public FolderBrowserDialog(ClientContext ctx)
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -22,24 +46,19 @@ public partial class FolderBrowserDialog : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load libraries
|
||||
var web = _ctx.Web;
|
||||
var lists = _ctx.LoadQuery(web.Lists
|
||||
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.RootFolder)
|
||||
.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 libNode = new TreeViewItem
|
||||
{
|
||||
Header = list.Title,
|
||||
Tag = new FolderNodeInfo(list.Title, string.Empty),
|
||||
};
|
||||
// Add dummy child for expand arrow
|
||||
libNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
libNode.Expanded += LibNode_Expanded;
|
||||
var rootUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||
var libNode = MakeFolderNode(list.Title,
|
||||
new FolderNodeInfo(list.Title, string.Empty, rootUrl));
|
||||
FolderTree.Items.Add(libNode);
|
||||
}
|
||||
|
||||
@@ -51,52 +70,116 @@ public partial class FolderBrowserDialog : Window
|
||||
}
|
||||
}
|
||||
|
||||
private async void LibNode_Expanded(object sender, RoutedEventArgs e)
|
||||
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...")
|
||||
// Only load children once.
|
||||
if (!(node.Items.Count == 1
|
||||
&& node.Items[0] is TreeViewItem dummy
|
||||
&& dummy.Header?.ToString() == "Loading..."))
|
||||
return;
|
||||
|
||||
node.Items.Clear();
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
var folderUrl = string.IsNullOrEmpty(info.FolderPath)
|
||||
? GetLibraryRootUrl(info.LibraryTitle)
|
||||
: info.FolderPath;
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
|
||||
var folder = _ctx.Web.GetFolderByServerRelativeUrl(folderUrl);
|
||||
_ctx.Load(folder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
|
||||
var progress = new Progress<Core.Models.OperationProgress>();
|
||||
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(_ctx, progress, CancellationToken.None);
|
||||
var childRelative = string.IsNullOrEmpty(info.RelativePath)
|
||||
? subFolder.Name
|
||||
: $"{info.RelativePath}/{subFolder.Name}";
|
||||
|
||||
foreach (var subFolder in folder.Folders)
|
||||
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)
|
||||
{
|
||||
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||
continue;
|
||||
// Library-relative path for the file (used by the transfer service)
|
||||
var fileRel = string.IsNullOrEmpty(info.RelativePath)
|
||||
? file.Name
|
||||
: $"{info.RelativePath}/{file.Name}";
|
||||
|
||||
var childNode = new TreeViewItem
|
||||
var cb = new CheckBox
|
||||
{
|
||||
Header = subFolder.Name,
|
||||
Tag = new FolderNodeInfo(info.LibraryTitle, subFolder.ServerRelativeUrl),
|
||||
Content = $"{file.Name} ({FormatSize(file.Length)})",
|
||||
Tag = new FileNodeInfo(info.LibraryTitle, fileRel),
|
||||
Margin = new Thickness(4, 2, 0, 2),
|
||||
};
|
||||
childNode.Items.Add(new TreeViewItem { Header = "Loading..." });
|
||||
childNode.Expanded += LibNode_Expanded;
|
||||
node.Items.Add(childNode);
|
||||
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}" });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
node.Items.Add(new TreeViewItem { Header = $"Error: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLibraryRootUrl(string libraryTitle)
|
||||
private static string FormatFolderHeader(string name, long fileCount, long totalBytes)
|
||||
{
|
||||
var uri = new Uri(_ctx.Url);
|
||||
return $"{uri.AbsolutePath.TrimEnd('/')}/{libraryTitle}";
|
||||
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)
|
||||
@@ -104,13 +187,83 @@ public partial class FolderBrowserDialog : Window
|
||||
if (e.NewValue is TreeViewItem node && node.Tag is FolderNodeInfo info)
|
||||
{
|
||||
SelectedLibrary = info.LibraryTitle;
|
||||
SelectedFolderPath = info.FolderPath;
|
||||
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();
|
||||
}
|
||||
@@ -121,5 +274,6 @@ public partial class FolderBrowserDialog : Window
|
||||
Close();
|
||||
}
|
||||
|
||||
private record FolderNodeInfo(string LibraryTitle, string FolderPath);
|
||||
private record FolderNodeInfo(string LibraryTitle, string RelativePath, string ServerRelativeUrl);
|
||||
private record FileNodeInfo(string LibraryTitle, string RelativePath);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user