using System.IO.Abstractions; using System.Text.RegularExpressions; using Terminal.Gui.Resources; namespace Terminal.Gui; /// /// Modal dialog for selecting files/directories. Has auto-complete and expandable navigation pane (Recent, Root /// drives etc). /// public class FileDialog : Dialog { private const int alignmentGroupInput = 32; private const int alignmentGroupComplete = 55; /// Gets the Path separators for the operating system 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 _btnToggleSplitterCollapse; private readonly Button _btnUp; private readonly IFileSystem _fileSystem; private readonly FileDialogHistory _history; private readonly SpinnerView _spinnerView; private readonly TileView _splitContainer; private readonly TableView _tableView; private readonly TextField _tbFind; private readonly TextField _tbPath; private readonly TreeView _treeView; private MenuBarItem _allowedTypeMenu; private MenuBar _allowedTypeMenuBar; private MenuItem [] _allowedTypeMenuItems; 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 FileDialogStyle (fileSystem); _btnOk = new Button { X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, alignmentGroupComplete), Y = Pos.AnchorEnd (), IsDefault = true, Text = Style.OkButtonText }; _btnOk.Accepting += (s, e) => Accept (true); _btnCancel = new Button { X = Pos.Align (Alignment.End, AlignmentModes.AddSpaceBetweenItems, alignmentGroupComplete), Y = Pos.AnchorEnd(), Text = Strings.btnCancel }; _btnCancel.Accepting += (s, e) => { Canceled = true; Application.RequestStop (); }; _btnUp = new Button { X = 0, Y = 1, NoPadding = true }; _btnUp.Text = GetUpButtonText (); _btnUp.Accepting += (s, e) => _history.Up (); _btnBack = new Button { X = Pos.Right (_btnUp) + 1, Y = 1, NoPadding = true }; _btnBack.Text = GetBackButtonText (); _btnBack.Accepting += (s, e) => _history.Back (); _btnForward = new Button { X = Pos.Right (_btnBack) + 1, Y = 1, NoPadding = true }; _btnForward.Text = GetForwardButtonText (); _btnForward.Accepting += (s, e) => _history.Forward (); _tbPath = new TextField { Width = Dim.Fill (), CaptionColor = new Color (Color.Black) }; _tbPath.KeyDown += (s, k) => { ClearFeedback (); AcceptIf (k, KeyCode.Enter); SuppressIfBadChar (k); }; _tbPath.Autocomplete = new AppendAutocomplete (_tbPath); _tbPath.Autocomplete.SuggestionGenerator = new FilepathSuggestionGenerator (); _splitContainer = new TileView { X = 0, Y = Pos.Bottom (_btnBack), Width = Dim.Fill (), Height = Dim.Fill (Dim.Func (() => IsInitialized ? _btnOk.Frame.Height : 1)), }; Initialized += (s, e) => { _splitContainer.SetSplitterPos (0, 30); _splitContainer.Tiles.ElementAt (0).ContentView.Visible = false; }; // this.splitContainer.Border.BorderStyle = BorderStyle.None; _tableView = new TableView { Width = Dim.Fill (), Height = Dim.Fill (), FullRowSelect = true, CollectionNavigator = new FileDialogCollectionNavigator (this) }; _tableView.KeyBindings.ReplaceCommands (Key.Space, Command.Select); _tableView.MouseClick += OnTableViewMouseClick; _tableView.Style.InvertSelectedCellFirstCharacter = true; 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; _treeView = new TreeView { Width = Dim.Fill (), Height = Dim.Fill () }; var fileDialogTreeBuilder = new FileSystemTreeBuilder (); _treeView.TreeBuilder = fileDialogTreeBuilder; _treeView.AspectGetter = AspectGetter; Style.TreeStyle = _treeView.Style; _treeView.SelectionChanged += TreeView_SelectionChanged; _splitContainer.Tiles.ElementAt (0).ContentView.Add (_treeView); _splitContainer.Tiles.ElementAt (1).ContentView.Add (_tableView); _btnToggleSplitterCollapse = new Button { X = Pos.Align (Alignment.Start, AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput), Y = Pos.AnchorEnd (), Text = GetToggleSplitterText (false) }; _btnToggleSplitterCollapse.Accepting += (s, e) => { Tile tile = _splitContainer.Tiles.ElementAt (0); bool newState = !tile.ContentView.Visible; tile.ContentView.Visible = newState; _btnToggleSplitterCollapse.Text = GetToggleSplitterText (newState); LayoutSubviews (); }; _tbFind = new TextField { X = Pos.Align (Alignment.Start,AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput), CaptionColor = new Color (Color.Black), Width = 30, Y = Pos.Top (_btnToggleSplitterCollapse), HotKey = Key.F.WithAlt }; _spinnerView = new SpinnerView { X = Pos.Align (Alignment.Start, AlignmentModes.AddSpaceBetweenItems, alignmentGroupInput), Y = Pos.AnchorEnd (1), 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; } } }; _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 FileDialogHistory (this); _tbPath.TextChanged += (s, e) => PathChanged (); _tableView.CellActivated += CellActivate; _tableView.KeyUp += (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); AllowsMultipleSelection = false; UpdateNavigationVisibility (); Add (_tbPath); Add (_btnUp); Add (_btnBack); Add (_btnForward); Add (_splitContainer); Add (_btnToggleSplitterCollapse); Add (_tbFind); Add (_spinnerView); Add(_btnOk); Add(_btnCancel); } /// /// 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'). [SerializableConfigurationProperty (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); } /// public override void OnDrawContent (Rectangle viewport) { base.OnDrawContent (viewport); 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); Driver.SetAttribute (new Attribute (Color.Red, ColorScheme.Normal.Background)); Driver.AddStr (new string (' ', feedbackPadLeft)); Driver.AddStr (_feedback); Driver.AddStr (new string (' ', feedbackPadRight)); } } /// public override void OnLoaded () { base.OnLoaded (); if (_loaded) { return; } _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 (); _btnToggleSplitterCollapse.Text = GetToggleSplitterText (false); _tbPath.Caption = Style.PathCaption; _tbFind.Caption = Style.SearchCaption; _tbPath.Autocomplete.ColorScheme = new ColorScheme (_tbPath.ColorScheme) { Normal = new Attribute (Color.Black, _tbPath.ColorScheme.Normal.Background) }; _treeRoots = Style.TreeRootGetter (); Style.IconProvider.IsOpenGetter = _treeView.IsExpanded; _treeView.AddObjects (_treeRoots.Keys); // 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 MenuBarItem ( "", _allowedTypeMenuItems = AllowedTypes.Select ( (a, i) => new MenuItem ( a.ToString (), null, () => { AllowedTypeMenuClicked (i); }) ) .ToArray () ); _allowedTypeMenuBar = new MenuBar { 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.DrawContentComplete += (s, e) => { _allowedTypeMenuBar.Move (e.NewViewport.Width - 1, 0); Driver.AddRune (Glyphs.DownArrow); }; Add (_allowedTypeMenuBar); } // if no path has been provided if (_tbPath.Text.Length <= 0) { Path = Environment.CurrentDirectory; } // 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); } LayoutSubviews (); } /// 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 ?? new FileSystemInfoStats [0]; // 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) ); 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; SetNeedsDisplay (); 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)) { if (reason is { }) { _feedback = reason; SetNeedsDisplay (); } return; } FinishAccept (); } private void AcceptIf (Key keyEvent, KeyCode isKey) { if (!keyEvent.Handled && keyEvent.KeyCode == isKey) { keyEvent.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); } } 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 (); if (State is { }) { State.RefreshChildren (); WriteStateToTableView (); } } 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 () { 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 ColorScheme ColorGetter (CellColorGetterArgs args) { FileSystemInfoStats stats = RowToStats (args.RowIndex); if (!Style.UseColors) { return _tableView.ColorScheme; } 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 ColorScheme { Normal = new Attribute (color, black), HotNormal = new Attribute (color, black), Focus = new Attribute (black, color), HotFocus = new Attribute (black, color) }; } private void Delete () { IFileSystemInfo [] toDelete = GetFocusedFiles (); if (toDelete is { } && 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; Application.RequestStop (); } private string GetBackButtonText () { return Glyphs.LeftArrow + "-"; } private IFileSystemInfo [] GetFocusedFiles () { if (!_tableView.HasFocus || !_tableView.CanFocus || FileOperationsHandler is null) { 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 GetToggleSplitterText (bool isExpanded) { return isExpanded ? new string ((char)Glyphs.LeftArrow.Value, 2) : new string ((char)Glyphs.RightArrow.Value, 2); } 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 = null; 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]; if (add is { }) { toReturn.Add (add); } } } return toReturn; } private void New () { if (State is { }) { IFileSystemInfo created = FileOperationsHandler.New (_fileSystem, State.Directory); if (created is { }) { RefreshState (); RestoreSelection (created); } } } private void OnTableViewMouseClick (object sender, MouseEventEventArgs e) { Point? clickedCell = _tableView.ScreenToCell (e.MouseEvent.Position.X, e.MouseEvent.Position.Y, out int? clickedCol); if (clickedCol is { }) { if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button1Clicked)) { // left click in a header SortColumn (clickedCol.Value); } else if (e.MouseEvent.Flags.HasFlag (MouseFlags.Button3Clicked)) { // right click in a header ShowHeaderContextMenu (clickedCol.Value, e); } } else { if (clickedCell is { } && e.MouseEvent.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) { Path = newState.Directory.FullName; } State = newState; _tbPath.Autocomplete.GenerateSuggestions ( new AutocompleteFilepathContext (_tbPath.Text, _tbPath.CursorPosition, State) ); WriteStateToTableView (); if (clearForward) { _history.ClearForward (); } _tableView.RowOffset = 0; _tableView.SelectedRow = 0; SetNeedsDisplay (); 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); } } } // /// // public override bool OnHotKey (KeyEventArgs keyEvent) // { //#if BROKE_IN_2927 // // BUGBUG: Ctrl-F is forward in a TextField. // if (this.NavigateIf (keyEvent, Key.Alt | Key.F, this.tbFind)) { // return true; // } //#endif // ClearFeedback (); // if (allowedTypeMenuBar is { } && // keyEvent.ConsoleDriverKey == Key.Tab && // allowedTypeMenuBar.IsMenuOpen) { // allowedTypeMenuBar.CloseMenu (false, false, false); // } // return base.OnHotKey (keyEvent); // } 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, MouseEventEventArgs e) { if (clickedCell is null) { return; } var contextMenu = new ContextMenu { Position = new Point (e.MouseEvent.Position.X + 1, e.MouseEvent.Position.Y + 1) }; var menuItems = new MenuBarItem ( [ new MenuItem (Strings.fdCtxNew, string.Empty, New), new MenuItem (Strings.fdCtxRename, string.Empty, Rename), new MenuItem (Strings.fdCtxDelete, string.Empty, Delete) ] ); _tableView.SetSelection (clickedCell.Value.X, clickedCell.Value.Y, false); contextMenu.Show (menuItems); } private void ShowHeaderContextMenu (int clickedCol, MouseEventEventArgs e) { string sort = GetProposedNewSortOrder (clickedCol, out bool isAsc); var contextMenu = new ContextMenu { Position = new Point (e.MouseEvent.Position.X + 1, e.MouseEvent.Position.Y + 1) }; var menuItems = new MenuBarItem ( [ new MenuItem ( string.Format ( Strings.fdCtxHide, StripArrows (_tableView.Table.ColumnNames [clickedCol]) ), string.Empty, () => HideColumn (clickedCol) ), new MenuItem ( StripArrows (sort), string.Empty, () => SortColumn (clickedCol, isAsc)) ] ); contextMenu.Show (menuItems); } 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 + System.IO.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); if (stats is null) { return; } IFileSystemInfo dest; if (stats.IsParent) { dest = State.Directory; } else { dest = stats.FileSystemInfo; } try { _pushingState = true; Path = dest.FullName; State.Selected = stats; _tbPath.Autocomplete.ClearSuggestions (); } finally { _pushingState = false; } } private void TreeView_SelectionChanged (object sender, SelectionChangedEventArgs e) { if (e.NewValue is null) { return; } Path = e.NewValue.FullName; } private bool TryAcceptMulti () { IEnumerable multi = MultiRowToStats (); string reason = null; if (!multi.Any ()) { return false; } if (multi.All ( m => IsCompatibleWithOpenMode ( m.FileSystemInfo.FullName, out reason ) )) { Accept (multi); return true; } if (reason is { }) { _feedback = reason; SetNeedsDisplay (); } return false; } private void UpdateNavigationVisibility () { _btnBack.Visible = _history.CanBack (); _btnForward.Visible = _history.CanForward (); _btnUp.Visible = _history.CanUp (); } private void WriteStateToTableView () { if (State is null) { return; } _tableView.Table = new FileDialogTableSource (this, State, Style, _currentSortColumn, _currentSortIsAsc); ApplySort (); _tableView.Update (); } internal class FileDialogCollectionNavigator : CollectionNavigatorBase { private readonly FileDialog _fileDialog; public FileDialogCollectionNavigator (FileDialog fileDialog) { _fileDialog = fileDialog; } protected override object ElementAt (int idx) { object val = FileDialogTableSource.GetRawColumnValue ( _fileDialog._tableView.SelectedColumn, _fileDialog.State?.Children [idx] ); if (val is null) { return string.Empty; } return val.ToString ().Trim ('.'); } protected override int GetCollectionLength () { return _fileDialog.State?.Children.Length ?? 0; } } /// 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 = new FileSystemInfoStats [0]; 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.SetNeedsDisplay (); } ); } } }