using System.IO.Abstractions; using System.Text.RegularExpressions; namespace Terminal.Gui.Views; /// /// The base-class for and /// public class FileDialog : Dialog, IDesignable { private const int ALIGNMENT_GROUP_COMPLETE = 55; /// Gets the Path separators for the operating system // ReSharper disable once InconsistentNaming internal static char [] Separators = [ System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar ]; /// /// Characters to prevent entry into . Note that this is not using /// because we do want to allow directory separators, arrow keys /// etc. /// private static readonly char [] _badChars = ['"', '<', '>', '|', '*', '?']; /// Locking object for ensuring only a single executes at once. internal object _onlyOneSearchLock = new (); private readonly Button _btnBack; private readonly Button _btnCancel; private readonly Button _btnForward; private readonly Button _btnOk; private readonly Button _btnUp; private readonly Button _btnTreeToggle; private readonly IFileSystem? _fileSystem; private readonly FileDialogHistory _history; private readonly SpinnerView _spinnerView; private readonly View _tableViewContainer; private readonly TableView _tableView; private readonly TextField _tbFind; private readonly TextField _tbPath; private readonly TreeView _treeView; #if MENU_V1 private MenuBarItem? _allowedTypeMenu; private MenuBar? _allowedTypeMenuBar; private MenuItem []? _allowedTypeMenuItems; #endif private int _currentSortColumn; private bool _currentSortIsAsc = true; private bool _disposed; private string? _feedback; private bool _loaded; private bool _pushingState; private Dictionary _treeRoots = new (); /// Initializes a new instance of the class. public FileDialog () : this (new FileSystem ()) { } /// Initializes a new instance of the class with a custom . /// This overload is mainly useful for testing. internal FileDialog (IFileSystem? fileSystem) { Height = Dim.Percent (80); Width = Dim.Percent (80); // Assume canceled Canceled = true; _fileSystem = fileSystem; Style = new (fileSystem); _btnOk = new () { X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, ALIGNMENT_GROUP_COMPLETE), Y = Pos.AnchorEnd (), IsDefault = true, Text = Style.OkButtonText }; _btnOk.Accepting += (s, e) => { if (e.Handled) { return; } Accept (true); e.Handled = true; }; _btnCancel = new () { X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, ALIGNMENT_GROUP_COMPLETE), Y = Pos.AnchorEnd (), Text = Strings.btnCancel }; _btnCancel.Accepting += (s, e) => { if (e.Handled) { return; } e.Handled = true; if (Modal) { Application.RequestStop (); } }; // Tree toggle button - shares alignment group with OK/Cancel _btnTreeToggle = new () { X = 0,//Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, ALIGNMENT_GROUP_COMPLETE), Y = Pos.AnchorEnd (), NoPadding = true }; _btnTreeToggle.Accepting += (s, e) => { e.Handled = true; ToggleTreeVisibility (); }; _btnUp = new () { X = 0, Y = 1, NoPadding = true }; _btnUp.Text = GetUpButtonText (); _btnUp.Accepting += (s, e) => { _history?.Up (); e.Handled = true; }; _btnBack = new () { X = Pos.Right (_btnUp) + 1, Y = 1, NoPadding = true }; _btnBack.Text = GetBackButtonText (); _btnBack.Accepting += (s, e) => { _history?.Back (); e.Handled = true; }; _btnForward = new () { X = Pos.Right (_btnBack) + 1, Y = 1, NoPadding = true }; _btnForward.Text = GetForwardButtonText (); _btnForward.Accepting += (s, e) => { _history?.Forward (); e.Handled = true; }; _tbPath = new () { Width = Dim.Fill () }; _tbPath.KeyDown += (s, k) => { ClearFeedback (); AcceptIf (k, KeyCode.Enter); SuppressIfBadChar (k); }; _tbPath.Autocomplete = new AppendAutocomplete (_tbPath); _tbPath.Autocomplete.SuggestionGenerator = new FilepathSuggestionGenerator (); // Create tree view container (left pane) _treeView = new () { X = 0, Y = Pos.Bottom (_btnBack), Width = Dim.Fill (Dim.Func (_ => IsInitialized ? _tableViewContainer!.Frame.Width - 30 : 30)), Height = Dim.Fill (Dim.Func (_ => IsInitialized ? _btnOk.Frame.Height : 1)), Visible = false }; // Create table view container (right pane) _tableViewContainer = new () { X = 0, Y = Pos.Bottom (_btnBack), Width = Dim.Fill (), Height = Dim.Fill (Dim.Func (_ => IsInitialized ? _btnOk.Frame.Height : 1)), Arrangement = ViewArrangement.LeftResizable, BorderStyle = LineStyle.Dashed, SuperViewRendersLineCanvas = true, CanFocus = true, Id = "_tableViewContainer" }; _tableView = new () { Width = Dim.Fill (), Height = Dim.Fill (1), FullRowSelect = true, Id = "_tableView" }; _tableView.CollectionNavigator = new FileDialogCollectionNavigator (this, _tableView); _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.Select); _tableView.MouseClick += OnTableViewMouseClick; Style.TableStyle = _tableView.Style; ColumnStyle nameStyle = Style.TableStyle.GetOrCreateColumnStyle (0); nameStyle.MinWidth = 10; nameStyle.ColorGetter = ColorGetter; ColumnStyle sizeStyle = Style.TableStyle.GetOrCreateColumnStyle (1); sizeStyle.MinWidth = 10; sizeStyle.ColorGetter = ColorGetter; ColumnStyle dateModifiedStyle = Style.TableStyle.GetOrCreateColumnStyle (2); dateModifiedStyle.MinWidth = 30; dateModifiedStyle.ColorGetter = ColorGetter; ColumnStyle typeStyle = Style.TableStyle.GetOrCreateColumnStyle (3); typeStyle.MinWidth = 6; typeStyle.ColorGetter = ColorGetter; var fileDialogTreeBuilder = new FileSystemTreeBuilder (); _treeView.TreeBuilder = fileDialogTreeBuilder; _treeView.AspectGetter = AspectGetter; Style.TreeStyle = _treeView.Style; _treeView.SelectionChanged += TreeView_SelectionChanged; _tableViewContainer.Add (_tableView); _tableView.Style.ShowHorizontalHeaderOverline = true; _tableView.Style.ShowVerticalCellLines = true; _tableView.Style.ShowVerticalHeaderLines = true; _tableView.Style.AlwaysShowHeaders = true; _tableView.Style.ShowHorizontalHeaderUnderline = true; _tableView.Style.ShowHorizontalScrollIndicators = true; _history = new (this); _tbPath.TextChanged += (s, e) => PathChanged (); _tableView.CellActivated += CellActivate; _tableView.KeyDown += (s, k) => k.Handled = TableView_KeyUp (k); _tableView.SelectedCellChanged += TableView_SelectedCellChanged; _tableView.KeyBindings.ReplaceCommands (Key.Home, Command.Start); _tableView.KeyBindings.ReplaceCommands (Key.End, Command.End); _tableView.KeyBindings.ReplaceCommands (Key.Home.WithShift, Command.StartExtend); _tableView.KeyBindings.ReplaceCommands (Key.End.WithShift, Command.EndExtend); _tbFind = new () { X = 0, Width = Dim.Fill (), Y = Pos.AnchorEnd (), Id = "_tbFind", }; _spinnerView = new () { // The spinner view is positioned over the last column of _tbFind X = Pos.Right (_tbFind) - 1, Y = Pos.Top (_tbFind), Visible = false }; _tbFind.TextChanged += (s, o) => RestartSearch (); _tbFind.KeyDown += (s, o) => { if (o.KeyCode == KeyCode.Enter) { RestartSearch (); o.Handled = true; } if (o.KeyCode == KeyCode.Esc) { if (CancelSearch ()) { o.Handled = true; } } }; AllowsMultipleSelection = false; UpdateNavigationVisibility (); base.Add (_tbPath); base.Add (_btnUp); base.Add (_btnBack); base.Add (_btnForward); base.Add (_treeView); base.Add (_tableViewContainer); _tableViewContainer.Add (_tbFind); _tableViewContainer.Add (_spinnerView); // Add the toggle along with OK/Cancel so they align as a group base.Add (_btnTreeToggle); base.Add (_btnOk); base.Add (_btnCancel); // Default: Tree hidden and splitter hidden SetTreeVisible (false); } /// /// Gets or Sets a collection of file types that the user can/must select. Only applies when /// is or . /// /// /// adds the option to select any type (*.*). If this collection is empty then any /// type is supported and no Types drop-down is shown. /// public List AllowedTypes { get; set; } = []; /// /// Gets or Sets a value indicating whether to allow selecting multiple existing files/directories. Defaults to /// false. /// public bool AllowsMultipleSelection { get => _tableView.MultiSelect; set => _tableView.MultiSelect = value; } /// The UI selected from combo box. May be null. public IAllowedType? CurrentFilter { get; private set; } /// /// Gets or sets behavior of the when the user attempts to delete a selected file(s). Set /// to null to prevent deleting. /// /// /// Ensure you use a try/catch block with appropriate error handling (e.g. showing a /// public IFileOperations FileOperationsHandler { get; set; } = new DefaultFileOperations (); /// The maximum number of results that will be collected when searching before stopping. /// This prevents performance issues e.g. when searching root of file system for a common letter (e.g. 'e'). [ConfigurationProperty (Scope = typeof (SettingsScope))] public static int MaxSearchResults { get; set; } = 10000; /// /// Gets all files/directories selected or an empty collection is /// or . /// /// If selecting only a single file/directory then you should use instead. public IReadOnlyList MultiSelected { get; private set; } = Enumerable.Empty ().ToList ().AsReadOnly (); /// /// True if the file/folder must exist already to be selected. This prevents user from entering the name of /// something that doesn't exist. Defaults to false. /// public bool MustExist { get; set; } /// /// Gets or Sets which type can be selected. Defaults to /// (i.e. or ). /// public virtual OpenMode OpenMode { get; set; } = OpenMode.Mixed; /// /// Gets or Sets the selected path in the dialog. This is the result that should be used if /// is off and is true. /// public string Path { get => _tbPath.Text; set { _tbPath.Text = value; _tbPath.MoveEnd (); } } /// /// Defines how the dialog matches files/folders when using the search box. Provide a custom implementation if you /// want to tailor how matching is performed. /// public ISearchMatcher SearchMatcher { get; set; } = new DefaultSearchMatcher (); /// /// Gets settings for controlling how visual elements behave. Style changes should be made before the /// is loaded and shown to the user for the first time. /// public FileDialogStyle Style { get; } /// Gets the currently open directory and known children presented in the dialog. internal FileDialogState? State { get; private set; } /// /// Event fired when user attempts to confirm a selection (or multi selection). Allows you to cancel the selection /// or undertake alternative behavior e.g. open a dialog "File already exists, Overwrite? yes/no". /// public event EventHandler? FilesSelected; /// /// Returns true if there are no or one of them agrees that /// . /// /// /// public bool IsCompatibleWithAllowedExtensions (IFileInfo file) { // no restrictions if (!AllowedTypes.Any ()) { return true; } return MatchesAllowedTypes (file); } /// protected override bool OnDrawingContent () { if (!string.IsNullOrWhiteSpace (_feedback)) { int feedbackWidth = _feedback.EnumerateRunes ().Sum (c => c.GetColumns ()); int feedbackPadLeft = (Viewport.Width - feedbackWidth) / 2 - 1; feedbackPadLeft = Math.Min (Viewport.Width, feedbackPadLeft); feedbackPadLeft = Math.Max (0, feedbackPadLeft); int feedbackPadRight = Viewport.Width - (feedbackPadLeft + feedbackWidth + 2); feedbackPadRight = Math.Min (Viewport.Width, feedbackPadRight); feedbackPadRight = Math.Max (0, feedbackPadRight); Move (0, Viewport.Height / 2); SetAttribute (new (Color.Red, GetAttributeForRole (VisualRole.Normal).Background)); AddStr (new (' ', feedbackPadLeft)); AddStr (_feedback); AddStr (new (' ', feedbackPadRight)); } return true; } /// public override void OnLoaded () { base.OnLoaded (); if (_loaded) { return; } Arrangement |= ViewArrangement.Resizable; _loaded = true; // May have been updated after instance was constructed _btnOk.Text = Style.OkButtonText; _btnCancel.Text = Style.CancelButtonText; _btnUp.Text = GetUpButtonText (); _btnBack.Text = GetBackButtonText (); _btnForward.Text = GetForwardButtonText (); _tbPath.Title = Style.PathCaption; _tbFind.Title = Style.SearchCaption; _tbPath.Autocomplete.Scheme = new (_tbPath.GetScheme ()) { Normal = new (Color.Black, _tbPath.GetAttributeForRole (VisualRole.Normal).Background) }; _treeRoots = Style.TreeRootGetter (); Style.IconProvider.IsOpenGetter = _treeView.IsExpanded; _treeView.AddObjects (_treeRoots.Keys); #if MENU_V1 // if filtering on file type is configured then create the ComboBox and establish // initial filtering by extension(s) if (AllowedTypes.Any ()) { CurrentFilter = AllowedTypes [0]; // Fiddle factor int width = AllowedTypes.Max (a => a.ToString ()!.Length) + 6; _allowedTypeMenu = new ( "", _allowedTypeMenuItems = AllowedTypes.Select ( (a, i) => new MenuItem ( a.ToString (), null, () => { AllowedTypeMenuClicked (i); }) ) .ToArray () ); _allowedTypeMenuBar = new () { Width = width, Y = 1, X = Pos.AnchorEnd (width), // TODO: Does not work, if this worked then we could tab to it instead // of having to hit F9 CanFocus = true, TabStop = TabBehavior.TabStop, Menus = [_allowedTypeMenu] }; AllowedTypeMenuClicked (0); // TODO: Using v1's menu bar here is a hack. Need to upgrade this. _allowedTypeMenuBar.DrawingContent += (s, e) => { _allowedTypeMenuBar.Move (e.NewViewport.Width - 1, 0); AddRune (Glyphs.DownArrow); }; Add (_allowedTypeMenuBar); } #endif // if no path has been provided if (_tbPath.Text.Length <= 0) { Path = _fileSystem!.Directory.GetCurrentDirectory (); } // to streamline user experience and allow direct typing of paths // with zero navigation we start with focus in the text box and any // default/current path fully selected and ready to be overwritten _tbPath.SetFocus (); _tbPath.SelectAll (); if (string.IsNullOrEmpty (Title)) { Title = GetDefaultTitle (); } if (Style.FlipOkCancelButtonLayoutOrder) { _btnCancel.X = Pos.Func (CalculateOkButtonPosX); _btnOk.X = Pos.Right (_btnCancel) + 1; MoveSubViewTowardsStart (_btnCancel); } // Ensure toggle button text matches current state after sizing SetTreeVisible (false); SetNeedsDraw (); SetNeedsLayout (); } /// protected override void Dispose (bool disposing) { _disposed = true; base.Dispose (disposing); CancelSearch (); } /// /// Gets a default dialog title, when is not set or empty, result of the function will be /// shown. /// protected virtual string GetDefaultTitle () { List titleParts = [Strings.fdOpen]; if (MustExist) { titleParts.Add (Strings.fdExisting); } switch (OpenMode) { case OpenMode.File: titleParts.Add (Strings.fdFile); break; case OpenMode.Directory: titleParts.Add (Strings.fdDirectory); break; } return string.Join (' ', titleParts); } internal void ApplySort () { FileSystemInfoStats [] stats = State?.Children ?? []; // This portion is never reordered (always .. at top then folders) IOrderedEnumerable forcedOrder = stats .OrderByDescending (f => f.IsParent) .ThenBy (f => f.IsDir ? -1 : 100); // This portion is flexible based on the column clicked (e.g. alphabetical) IOrderedEnumerable ordered = _currentSortIsAsc ? forcedOrder.ThenBy ( f => FileDialogTableSource.GetRawColumnValue (_currentSortColumn, f) ) : forcedOrder.ThenByDescending ( f => FileDialogTableSource.GetRawColumnValue (_currentSortColumn, f) ); if (State is { }) { State.Children = ordered.ToArray (); } _tableView.Update (); } /// Changes the dialog such that is being explored. /// /// /// /// /// Optional alternate string to set path to. internal void PushState ( IDirectoryInfo d, bool addCurrentStateToHistory, bool setPathText = true, bool clearForward = true, string? pathText = null ) { // no change of state if (d == State?.Directory) { return; } if (d.FullName == State?.Directory.FullName) { return; } PushState ( new FileDialogState (d, this), addCurrentStateToHistory, setPathText, clearForward, pathText ); } /// Select in the table view (if present) /// internal void RestoreSelection (IFileSystemInfo toRestore) { _tableView.SelectedRow = State!.Children.IndexOf (r => r.FileSystemInfo == toRestore); _tableView.EnsureSelectedCellIsVisible (); } internal void SortColumn (int col, bool isAsc) { // set a sort order _currentSortColumn = col; _currentSortIsAsc = isAsc; ApplySort (); } private void Accept (IEnumerable toMultiAccept) { if (!AllowsMultipleSelection) { return; } // Don't include ".." (IsParent) in multi-selections MultiSelected = toMultiAccept .Where (s => !s.IsParent) .Select (s => s.FileSystemInfo!.FullName) .ToList () .AsReadOnly (); Path = MultiSelected.Count == 1 ? MultiSelected [0] : string.Empty; FinishAccept (); } private void Accept (IFileInfo f) { if (!IsCompatibleWithOpenMode (f.FullName, out string reason)) { _feedback = reason; SetNeedsDraw (); return; } Path = f.FullName; if (AllowsMultipleSelection) { MultiSelected = new List { f.FullName }.AsReadOnly (); } FinishAccept (); } private void Accept (bool allowMulti) { if (allowMulti && TryAcceptMulti ()) { return; } if (!IsCompatibleWithOpenMode (_tbPath.Text, out string reason)) { _feedback = reason; SetNeedsDraw (); return; } FinishAccept (); } private void AcceptIf (Key key, KeyCode isKey) { if (!key.Handled && key.KeyCode == isKey) { key.Handled = true; // User hit Enter in text box so probably wants the // contents of the text box as their selection not // whatever lingering selection is in TableView Accept (false); } } #if MENU_V1 private void AllowedTypeMenuClicked (int idx) { IAllowedType allow = AllowedTypes [idx]; for (var i = 0; i < AllowedTypes.Count; i++) { _allowedTypeMenuItems! [i].Checked = i == idx; } _allowedTypeMenu!.Title = allow.ToString ()!; CurrentFilter = allow; _tbPath.ClearAllSelection (); _tbPath.Autocomplete.ClearSuggestions (); State?.RefreshChildren (); WriteStateToTableView (); } #endif private string AspectGetter (object o) { var fsi = (IFileSystemInfo)o; if (o is IDirectoryInfo dir && _treeRoots.ContainsKey (dir)) { // Directory has a special name e.g. 'Pictures' return _treeRoots [dir]; } return (Style.IconProvider.GetIconWithOptionalSpace (fsi) + fsi.Name).Trim (); } private int CalculateOkButtonPosX (View? _) { if (!IsInitialized || !_btnOk.IsInitialized || !_btnCancel.IsInitialized) { return 0; } return Viewport.Width - _btnOk.Viewport.Width - _btnCancel.Viewport.Width - 1 // TODO: Fiddle factor, seems the Viewport are wrong for someone - 2; } private bool CancelSearch () { if (State is SearchState search) { return search.Cancel (); } return false; } private void CellActivate (object? sender, CellActivatedEventArgs obj) { if (TryAcceptMulti ()) { return; } FileSystemInfoStats stats = RowToStats (obj.Row); if (stats.FileSystemInfo is IDirectoryInfo d) { PushState (d, true); //if (d == State?.Directory || d.FullName == State?.Directory.FullName) //{ // FinishAccept (); //} return; } if (stats.FileSystemInfo is IFileInfo f) { Accept (f); } } private void ClearFeedback () { _feedback = null; } private Scheme ColorGetter (CellColorGetterArgs args) { FileSystemInfoStats stats = RowToStats (args.RowIndex); if (!Style.UseColors) { return _tableView.GetScheme (); } Color color = Style.ColorProvider.GetColor (stats.FileSystemInfo!) ?? new Color (Color.White); var black = new Color (Color.Black); // TODO: Add some kind of cache for this return new () { Normal = new (color, black), HotNormal = new (color, black), Focus = new (black, color), HotFocus = new (black, color) }; } private void Delete () { IFileSystemInfo [] toDelete = GetFocusedFiles ()!; if (FileOperationsHandler.Delete (toDelete)) { RefreshState (); } } private void FinishAccept () { var e = new FilesSelectedEventArgs (this); FilesSelected?.Invoke (this, e); if (e.Cancel) { return; } // if user uses Path selection mode (e.g. Enter in text box) // then also copy to MultiSelected if (AllowsMultipleSelection && !MultiSelected.Any ()) { MultiSelected = string.IsNullOrWhiteSpace (Path) ? Enumerable.Empty ().ToList ().AsReadOnly () : new List { Path }.AsReadOnly (); } Canceled = false; if (Modal) { Application.RequestStop (); } } private string GetBackButtonText () { return Glyphs.LeftArrow + "-"; } private IFileSystemInfo? []? GetFocusedFiles () { if (!_tableView.HasFocus || !_tableView.CanFocus) { return null; } _tableView.EnsureValidSelection (); if (_tableView.SelectedRow < 0) { return null; } return _tableView.GetAllSelectedCells () .Select (c => c.Y) .Distinct () .Select (RowToStats) .Where (s => !s.IsParent) .Select (d => d.FileSystemInfo) .ToArray (); } private string GetForwardButtonText () { return "-" + Glyphs.RightArrow; } private string GetProposedNewSortOrder (int clickedCol, out bool isAsc) { // work out new sort order if (_currentSortColumn == clickedCol && _currentSortIsAsc) { isAsc = false; return string.Format (Strings.fdCtxSortDesc, _tableView.Table.ColumnNames [clickedCol]); } isAsc = true; return string.Format (Strings.fdCtxSortAsc, _tableView.Table.ColumnNames [clickedCol]); } private string GetUpButtonText () { return Style.UseUnicodeCharacters ? "◭" : "▲"; } private void HideColumn (int clickedCol) { ColumnStyle style = _tableView.Style.GetOrCreateColumnStyle (clickedCol); style.Visible = false; _tableView.Update (); } private bool IsCompatibleWithAllowedExtensions (string path) { // no restrictions if (!AllowedTypes.Any ()) { return true; } return AllowedTypes.Any (t => t.IsAllowed (path)); } private bool IsCompatibleWithOpenMode (string s, out string reason) { reason = string.Empty; if (string.IsNullOrWhiteSpace (s)) { return false; } if (!IsCompatibleWithAllowedExtensions (s)) { reason = Style.WrongFileTypeFeedback; return false; } switch (OpenMode) { case OpenMode.Directory: if (MustExist && !Directory.Exists (s)) { reason = Style.DirectoryMustExistFeedback; return false; } if (File.Exists (s)) { reason = Style.FileAlreadyExistsFeedback; return false; } return true; case OpenMode.File: if (MustExist && !File.Exists (s)) { reason = Style.FileMustExistFeedback; return false; } if (Directory.Exists (s)) { reason = Style.DirectoryAlreadyExistsFeedback; return false; } return true; case OpenMode.Mixed: if (MustExist && !File.Exists (s) && !Directory.Exists (s)) { reason = Style.FileOrDirectoryMustExistFeedback; return false; } return true; default: throw new ArgumentOutOfRangeException (nameof (OpenMode)); } } /// Returns true if any matches . /// /// private bool MatchesAllowedTypes (IFileInfo file) { return AllowedTypes.Any (t => t.IsAllowed (file.FullName)); } /// /// If is this returns a union of all in the /// selection. /// /// private IEnumerable MultiRowToStats () { HashSet toReturn = new (); if (AllowsMultipleSelection && _tableView.MultiSelectedRegions.Any ()) { foreach (Point p in _tableView.GetAllSelectedCells ()) { FileSystemInfoStats add = State?.Children [p.Y]!; toReturn.Add (add); } } return toReturn; } private void New () { { IFileSystemInfo created = FileOperationsHandler.New (_fileSystem!, State!.Directory); if (created is { }) { RefreshState (); RestoreSelection (created); } } } private void OnTableViewMouseClick (object? sender, MouseEventArgs e) { Point? clickedCell = _tableView.ScreenToCell (e.Position.X, e.Position.Y, out int? clickedCol); if (clickedCol is { }) { if (e.Flags.HasFlag (MouseFlags.Button1Clicked)) { // left click in a header SortColumn (clickedCol.Value); } else if (e.Flags.HasFlag (MouseFlags.Button3Clicked)) { // right click in a header ShowHeaderContextMenu (clickedCol.Value, e); } } else { if (clickedCell is { } && e.Flags.HasFlag (MouseFlags.Button3Clicked)) { // right click in rest of table ShowCellContextMenu (clickedCell, e); } } } private void PathChanged () { // avoid re-entry if (_pushingState) { return; } string path = _tbPath.Text; if (string.IsNullOrWhiteSpace (path)) { return; } IDirectoryInfo dir = StringToDirectoryInfo (path); if (dir.Exists) { PushState (dir, true, false); } else if (dir.Parent?.Exists ?? false) { PushState (dir.Parent, true, false); } _tbPath.Autocomplete.GenerateSuggestions ( new AutocompleteFilepathContext (_tbPath.Text, _tbPath.CursorPosition, State) ); } private void PushState ( FileDialogState newState, bool addCurrentStateToHistory, bool setPathText = true, bool clearForward = true, string? pathText = null ) { if (State is SearchState search) { search.Cancel (); } try { _pushingState = true; // push the old state to history if (addCurrentStateToHistory) { _history.Push (State, clearForward); } _tbPath.Autocomplete.ClearSuggestions (); if (pathText is { }) { Path = pathText; } else if (setPathText) { SetPathToSelectedObject (newState.Directory); } State = newState; _tbPath.Autocomplete.GenerateSuggestions ( new AutocompleteFilepathContext (_tbPath.Text, _tbPath.CursorPosition, State) ); WriteStateToTableView (); if (clearForward) { _history.ClearForward (); } _tableView.RowOffset = 0; _tableView.SelectedRow = 0; SetNeedsDraw (); UpdateNavigationVisibility (); } finally { _pushingState = false; } ClearFeedback (); } private void RefreshState () { State!.RefreshChildren (); PushState (State, false, false, false); } private void Rename () { IFileSystemInfo [] toRename = GetFocusedFiles ()!; if (toRename?.Length == 1) { IFileSystemInfo newNamed = FileOperationsHandler.Rename (_fileSystem!, toRename.Single ()); if (newNamed is { }) { RefreshState (); RestoreSelection (newNamed); } } } private void RestartSearch () { if (_disposed || State?.Directory is null) { return; } if (State is SearchState oldSearch) { oldSearch.Cancel (); } // user is clearing search terms if (_tbFind.Text is null || _tbFind.Text.Length == 0) { // Wait for search cancellation (if any) to finish // then push the current dir state lock (_onlyOneSearchLock) { PushState (new FileDialogState (State.Directory, this), false); } return; } PushState (new SearchState (State?.Directory!, this, _tbFind.Text), true); } private FileSystemInfoStats RowToStats (int rowIndex) { return State?.Children [rowIndex]!; } private void ShowCellContextMenu (Point? clickedCell, MouseEventArgs e) { if (clickedCell is null) { return; } PopoverMenu? contextMenu = new ( [ new (Strings.fdCtxNew, string.Empty, New), new (Strings.fdCtxRename, string.Empty, Rename), new (Strings.fdCtxDelete, string.Empty, Delete) ]); _tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false); // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. App!.Popover?.Register (contextMenu); contextMenu?.MakeVisible (e.ScreenPosition); } private void ShowHeaderContextMenu (int clickedCol, MouseEventArgs e) { string sort = GetProposedNewSortOrder (clickedCol, out bool isAsc); PopoverMenu? contextMenu = new ( [ new ( string.Format ( Strings.fdCtxHide, StripArrows (_tableView.Table.ColumnNames [clickedCol]) ), string.Empty, () => HideColumn (clickedCol) ), new ( StripArrows (sort), string.Empty, () => SortColumn (clickedCol, isAsc)) ] ); // Registering with the PopoverManager will ensure that the context menu is closed when the view is no longer focused // and the context menu is disposed when it is closed. App!.Popover?.Register (contextMenu); contextMenu?.MakeVisible (e.ScreenPosition); } private void SortColumn (int clickedCol) { GetProposedNewSortOrder (clickedCol, out bool isAsc); SortColumn (clickedCol, isAsc); _tableView.Table = new FileDialogTableSource (this, State, Style, _currentSortColumn, _currentSortIsAsc); } private IDirectoryInfo StringToDirectoryInfo (string path) { // if you pass new DirectoryInfo("C:") you get a weird object // where the FullName is in fact the current working directory. // really not what most users would expect if (Regex.IsMatch (path, "^\\w:$")) { return _fileSystem!.DirectoryInfo.New (path + _fileSystem.Path.DirectorySeparatorChar); } return _fileSystem!.DirectoryInfo.New (path); } private static string StripArrows (string columnName) { return columnName.Replace (" (▼)", string.Empty).Replace (" (▲)", string.Empty); } private void SuppressIfBadChar (Key k) { // don't let user type bad letters var ch = (char)k; if (_badChars.Contains (ch)) { k.Handled = true; } } private bool TableView_KeyUp (Key keyEvent) { if (keyEvent.KeyCode == KeyCode.Backspace) { return _history.Back (); } if (keyEvent.KeyCode == (KeyCode.ShiftMask | KeyCode.Backspace)) { return _history.Forward (); } if (keyEvent.KeyCode == KeyCode.Delete) { Delete (); return true; } if (keyEvent.KeyCode == (KeyCode.CtrlMask | KeyCode.R)) { Rename (); return true; } if (keyEvent.KeyCode == (KeyCode.CtrlMask | KeyCode.N)) { New (); return true; } return false; } private void TableView_SelectedCellChanged (object? sender, SelectedCellChangedEventArgs obj) { if (!_tableView.HasFocus || obj.NewRow == -1 || obj.Table.Rows == 0) { return; } if (_tableView.MultiSelect && _tableView.MultiSelectedRegions.Any ()) { return; } FileSystemInfoStats? stats = RowToStats (obj.NewRow); IFileSystemInfo? dest; if (stats.IsParent) { dest = State!.Directory; } else { dest = stats.FileSystemInfo; } try { _pushingState = true; SetPathToSelectedObject (dest); State!.Selected = stats; _tbPath.Autocomplete.ClearSuggestions (); } finally { _pushingState = false; } } private void TreeView_SelectionChanged (object? sender, SelectionChangedEventArgs e) { SetPathToSelectedObject (e.NewValue); } private void SetPathToSelectedObject (IFileSystemInfo? selected) { if (selected is null) { return; } if (selected is IDirectoryInfo && Style.PreserveFilenameOnDirectoryChanges) { if (!string.IsNullOrWhiteSpace (Path) && !_fileSystem!.Directory.Exists (Path)) { var currentFile = _fileSystem.Path.GetFileName (Path); if (!string.IsNullOrWhiteSpace (currentFile)) { Path = _fileSystem.Path.Combine (selected.FullName, currentFile); return; } } } Path = selected.FullName; } private bool TryAcceptMulti () { IEnumerable multi = MultiRowToStats (); string? reason = null; IEnumerable fileSystemInfoStatsEnumerable = multi as FileSystemInfoStats [] ?? multi.ToArray (); if (!fileSystemInfoStatsEnumerable.Any ()) { return false; } if (fileSystemInfoStatsEnumerable.All ( m => m.FileSystemInfo is { } && IsCompatibleWithOpenMode ( m.FileSystemInfo.FullName, out reason ) )) { Accept (fileSystemInfoStatsEnumerable); return true; } if (reason is { }) { _feedback = reason; SetNeedsDraw (); } return false; } private void UpdateNavigationVisibility () { _btnBack.Visible = _history.CanBack (); _btnForward.Visible = _history.CanForward (); _btnUp.Visible = _history.CanUp (); } private void WriteStateToTableView () { _tableView.Table = new FileDialogTableSource (this, State, Style, _currentSortColumn, _currentSortIsAsc); ApplySort (); _tableView.Update (); } // --- Tree visibility management --- private void ToggleTreeVisibility () { SetTreeVisible (!_treeView.Visible); } private void SetTreeVisible (bool visible) { _treeView.Enabled = visible; _treeView.Visible = visible; if (visible) { // When visible, the table view's left edge is a splitter next to the tree _treeView.Width = Dim.Fill (Dim.Func (_ => IsInitialized ? _tableViewContainer!.Frame.Width - 30 : 30)); _tableViewContainer.X = 30; _tableViewContainer.Arrangement = ViewArrangement.LeftResizable; _tableViewContainer.Border!.Thickness = new (1, 0, 0, 0); } else { // When hidden, table occupies full width and splitter is hidden/disabled _treeView.Width = 0; _tableViewContainer.X = 0; _tableViewContainer.Width = Dim.Fill (); _tableViewContainer.Arrangement = ViewArrangement.Fixed; _tableViewContainer.Border!.Thickness = new (0, 0, 0, 0); } _btnTreeToggle.Text = GetTreeToggleText (visible); SetNeedsLayout (); SetNeedsDraw (); } private string GetTreeToggleText (bool visible) { return visible ? $"{Glyphs.LeftArrow}{Strings.fdTree}" : $"{Glyphs.RightArrow}{Strings.fdTree}"; } /// State representing a recursive search from downwards. internal class SearchState : FileDialogState { // TODO: Add thread safe child adding private readonly List _found = []; private readonly object _oLockFound = new (); private readonly CancellationTokenSource _token = new (); private bool _cancel; private bool _finished; public SearchState (IDirectoryInfo dir, FileDialog parent, string searchTerms) : base (dir, parent) { parent.SearchMatcher.Initialize (searchTerms); Children = []; BeginSearch (); } /// /// Cancels the current search (if any). Returns true if a search was running and cancellation was successfully /// set. /// /// internal bool Cancel () { bool alreadyCancelled = _token.IsCancellationRequested || _cancel; _cancel = true; _token.Cancel (); return !alreadyCancelled; } internal override void RefreshChildren () { } private void BeginSearch () { Task.Run ( () => { RecursiveFind (Directory); _finished = true; } ); Task.Run (UpdateChildren); } private void RecursiveFind (IDirectoryInfo directory) { foreach (FileSystemInfoStats f in GetChildren (directory)) { if (_cancel) { return; } if (f.IsParent) { continue; } lock (_oLockFound) { if (_found.Count >= MaxSearchResults) { _finished = true; return; } } if (Parent.SearchMatcher.IsMatch (f.FileSystemInfo!)) { lock (_oLockFound) { _found.Add (f); } } if (f.FileSystemInfo is IDirectoryInfo sub) { RecursiveFind (sub); } } } private void UpdateChildren () { lock (Parent._onlyOneSearchLock) { while (!_cancel && !_finished) { try { Task.Delay (250).Wait (_token.Token); } catch (OperationCanceledException) { _cancel = true; } if (_cancel || _finished) { break; } UpdateChildrenToFound (); } if (_finished && !_cancel) { UpdateChildrenToFound (); } Application.Invoke ((_) => { Parent._spinnerView.Visible = false; }); } } private void UpdateChildrenToFound () { lock (_oLockFound) { Children = _found.ToArray (); } Application.Invoke ( (_) => { Parent._tbPath.Autocomplete.GenerateSuggestions ( new AutocompleteFilepathContext ( Parent._tbPath.Text, Parent._tbPath.CursorPosition, this ) ); Parent.WriteStateToTableView (); Parent._spinnerView.Visible = true; Parent._spinnerView.SetNeedsDraw (); } ); } } bool IDesignable.EnableForDesign () { Modal = false; OnLoaded (); return true; } }