From 110f78b8185d93ea0db87e1fc6b47625aec1de9a Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Thu, 20 Dec 2018 11:05:34 -0800 Subject: [PATCH] Add `-RepeatHeader` to `Format-Table` to enable repeating header for each screen full (#8481) When using a screen reader or just getting the output of a table with lots of rows, the header is no longer on the screen and the columns may no longer make sense without the context. This change adds a `-RepeatHeader` switch to `Format-Table` to enable re-outputting the header after every screen full (minus 1 row). Expectation is that the user is piping the output to a pager (e.g. less) which uses the bottom row for pager information. I followed the `AutoSize` parameter as the way to get the parameter from the cmdlet into the deep formatting object where it's needed. --- .../common/BaseFormattingCommand.cs | 11 +++ .../common/BaseFormattingCommandParameters.cs | 6 ++ .../common/BaseOutputtingCommand.cs | 75 ++++++++++++++----- .../DisplayDatabase/displayDescriptionData.cs | 5 ++ .../common/FormatViewGenerator.cs | 15 ++++ .../common/FormatViewGenerator_Table.cs | 14 ++++ .../common/FormattingObjects.cs | 1 + .../common/FormattingObjectsDeserializer.cs | 1 + .../FormatAndOutput/common/TableWriter.cs | 71 +++++++++++------- .../engine/Utils.cs | 3 +- .../Format-Table.Tests.ps1 | 8 ++ 11 files changed, 163 insertions(+), 47 deletions(-) diff --git a/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommand.cs b/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommand.cs index 2e455a782a..b65109d168 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommand.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommand.cs @@ -758,6 +758,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } private Nullable _autosize = null; + /// + /// Gets or sets if header is repeated per screen. + /// + [Parameter] + public SwitchParameter RepeatHeader { get; set; } + /// /// optional, non positional parameter /// @@ -809,6 +815,11 @@ namespace Microsoft.PowerShell.Commands.Internal.Format if (_autosize.HasValue) parameters.autosize = _autosize.Value; + if (RepeatHeader) + { + parameters.repeatHeader = true; + } + parameters.groupByParameter = this.ProcessGroupByParameter(); TableSpecificParameters tableParameters = new TableSpecificParameters(); diff --git a/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommandParameters.cs b/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommandParameters.cs index 9d34035cea..2a263bcaaf 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommandParameters.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/BaseFormattingCommandParameters.cs @@ -45,6 +45,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format /// internal Nullable autosize = null; + /// + /// If true, the header for a table is repeated after each screen full + /// of content. + /// + internal bool repeatHeader = false; + /// /// errors are shown as out of band messages /// diff --git a/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs b/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs index b556cb673a..f56f12e6e7 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs @@ -250,6 +250,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format private enum PreprocessingState { raw, processed, error } private const int DefaultConsoleWidth = 120; + private const int DefaultConsoleHeight = int.MaxValue; internal const int StackAllocThreshold = 120; /// @@ -743,12 +744,6 @@ namespace Microsoft.PowerShell.Commands.Internal.Format _formattingHint = hint; } - /// - /// In cases like implicit remoting, there is no console so reading the console width results in an exception. - /// Instead of handling exception every time we cache this value to increase performance. - /// - static private bool _noConsole = false; - /// /// Tables and Wides need to use spaces for padding to maintain table look even if console window is resized. /// For all other output, we use int.MaxValue if the user didn't explicitly specify a width. @@ -756,7 +751,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format /// However, if we can't read that (for example, implicit remoting has no console window), we default /// to something reasonable: 120 columns. /// - static private int GetConsoleWindowWidth(int columnNumber) + private static int GetConsoleWindowWidth(int columnNumber) { if (InternalTestHooks.SetConsoleWidthToZero) { @@ -765,10 +760,6 @@ namespace Microsoft.PowerShell.Commands.Internal.Format if (columnNumber == int.MaxValue) { - if (_noConsole) - { - return DefaultConsoleWidth; - } try { // if Console width is set to 0, the default width is returned so that the output string is not null. @@ -777,13 +768,40 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } catch { - _noConsole = true; return DefaultConsoleWidth; } } + return columnNumber; } + /// + /// Return the console height.null If not available (like when remoting), treat as Int.MaxValue. + /// + private static int GetConsoleWindowHeight(int rowNumber) + { + if (InternalTestHooks.SetConsoleHeightToZero) + { + return DefaultConsoleHeight; + } + + if (rowNumber <= 0) + { + try + { + // if Console height is set to 0, the default height is returned. + // This can happen in environments where TERM is not set. + return (Console.WindowHeight > 0) ? Console.WindowHeight : DefaultConsoleHeight; + } + catch + { + return DefaultConsoleHeight; + } + } + + return rowNumber; + } + /// /// base class for all the formatting hints /// @@ -925,6 +943,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format private sealed class TableOutputContext : TableOutputContextBase { + private int _rowCount = 0; + private int _consoleHeight = -1; + private int _consoleWidth = -1; + private const int WhitespaceAndPagerLineCount = 2; + private bool _repeatHeader = false; + /// /// construct a context to push on the stack /// @@ -936,6 +960,13 @@ namespace Microsoft.PowerShell.Commands.Internal.Format GroupStartData formatData) : base(cmd, parentContext, formatData) { + if (parentContext is FormatOutputContext foc) + { + if (foc.Data.shapeInfo is TableHeaderInfo thi) + { + _repeatHeader = thi.repeatHeader; + } + } } /// @@ -952,7 +983,8 @@ namespace Microsoft.PowerShell.Commands.Internal.Format columnWidthsHint = tableHint.columnWidths; } - int columnsOnTheScreen = GetConsoleWindowWidth(this.InnerCommand._lo.ColumnNumber); + _consoleHeight = GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber); + _consoleWidth = GetConsoleWindowWidth(this.InnerCommand._lo.ColumnNumber); int columns = this.CurrentTableHeaderInfo.tableColumnInfoList.Count; if (columns == 0) @@ -971,7 +1003,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format alignment[k] = tci.alignment; k++; } - this.Writer.Initialize(0, columnsOnTheScreen, columnWidths, alignment, this.CurrentTableHeaderInfo.hideHeader); + this.Writer.Initialize(0, _consoleWidth, columnWidths, alignment, this.CurrentTableHeaderInfo.hideHeader); } /// @@ -992,7 +1024,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format { properties[k++] = tci.label ?? tci.propertyName; } - this.Writer.GenerateHeader(properties, this.InnerCommand._lo); + _rowCount += this.Writer.GenerateHeader(properties, this.InnerCommand._lo); } /// @@ -1008,6 +1040,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format return; } + if (_repeatHeader && _rowCount >= _consoleHeight - WhitespaceAndPagerLineCount) + { + this.InnerCommand._lo.WriteLine(string.Empty); + _rowCount = this.Writer.GenerateHeader(null, this.InnerCommand._lo); + } + TableRowEntry tre = fed.formatEntryInfo as TableRowEntry; // need to make sure we have matching counts: the header count will have to prevail @@ -1029,7 +1067,8 @@ namespace Microsoft.PowerShell.Commands.Internal.Format alignment[k] = TextAlignment.Left; // hard coded default } } - this.Writer.GenerateRow(values, this.InnerCommand._lo, tre.multiLine, alignment, InnerCommand._lo.DisplayCells); + this.Writer.GenerateRow(values, this.InnerCommand._lo, tre.multiLine, alignment, InnerCommand._lo.DisplayCells, generatedRows: null); + _rowCount++; } private TableHeaderInfo CurrentTableHeaderInfo @@ -1182,7 +1221,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format alignment[k] = TextAlignment.Left; } - this.Writer.Initialize(0, columnsOnTheScreen, columnWidths, alignment, false); + this.Writer.Initialize(0, columnsOnTheScreen, columnWidths, alignment, false, GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber)); } /// @@ -1239,7 +1278,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format else values[k] = string.Empty; } - this.Writer.GenerateRow(values, this.InnerCommand._lo, false, null, InnerCommand._lo.DisplayCells); + this.Writer.GenerateRow(values, this.InnerCommand._lo, false, null, InnerCommand._lo.DisplayCells, generatedRows: null); _buffer.Reset(); } diff --git a/src/System.Management.Automation/FormatAndOutput/common/DisplayDatabase/displayDescriptionData.cs b/src/System.Management.Automation/FormatAndOutput/common/DisplayDatabase/displayDescriptionData.cs index fb0672ea2f..4b1478925f 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/DisplayDatabase/displayDescriptionData.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/DisplayDatabase/displayDescriptionData.cs @@ -424,6 +424,11 @@ namespace Microsoft.PowerShell.Commands.Internal.Format /// RULE: valid only for table and wide only /// internal bool? autosize = null; + + /// + /// RULE: only valid for table + /// + internal bool repeatHeader = false; } /// diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator.cs b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator.cs index 1e661bdf1a..10d46a731a 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator.cs @@ -63,6 +63,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format InitializeFormatErrorManager(); InitializeGroupBy(); InitializeAutoSize(); + InitializeRepeatHeader(); } private void InitializeFormatErrorManager() @@ -147,6 +148,14 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } } + private void InitializeRepeatHeader() + { + if (parameters != null) + { + _repeatHeader = parameters.repeatHeader; + } + } + internal virtual FormatStartData GenerateStartData(PSObject so) { FormatStartData startFormat = new FormatStartData(); @@ -312,6 +321,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } private bool _autosize = false; + protected bool RepeatHeader + { + get { return _repeatHeader; } + } + private bool _repeatHeader = false; + protected class DataBaseInfo { internal TypeInfoDataBase db = null; diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs index fbcbd81950..6f7f5e8489 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs @@ -156,6 +156,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format bool dummy; List activeRowItemDefinitionList = GetActiveTableRowDefinition(_tableBody, so, out dummy); thi.hideHeader = this.HideHeaders; + thi.repeatHeader = this.RepeatHeader; int col = 0; foreach (TableRowItemDefinition rowItem in activeRowItemDefinitionList) @@ -293,6 +294,19 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } } + private bool RepeatHeaders + { + get + { + if (this.parameters != null) + { + return this.parameters.repeatHeader; + } + + return false; + } + } + private static int ComputeDefaultAlignment(PSObject so, PSPropertyExpression ex) { List rList = ex.GetValues(so); diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs index 81bb639734..8f75f692a7 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs @@ -220,6 +220,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format public override string ClassId2e4f51ef21dd47e99d3c952918aff9cd { get { return CLSID; } } public bool hideHeader; + public bool repeatHeader; public List tableColumnInfoList; } diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjectsDeserializer.cs b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjectsDeserializer.cs index 1aed8ae637..93606da509 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjectsDeserializer.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjectsDeserializer.cs @@ -575,6 +575,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format { base.Deserialize(so, deserializer); this.hideHeader = deserializer.DeserializeBoolMemberVariable(so, "hideHeader"); + this.hideHeader = deserializer.DeserializeBoolMemberVariable(so, "repeatHeader"); FormatInfoDataListDeserializer.ReadList(so, "tableColumnInfoList", this.tableColumnInfoList, deserializer); } } diff --git a/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs b/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs index ea5adba7f1..4f864eaf71 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs @@ -27,6 +27,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format private class ScreenInfo { internal int screenColumns = 0; + internal int screenRows = 0; internal const int separatorCharacterCount = 1; @@ -40,6 +41,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format private ScreenInfo _si; private const char ESC = '\u001b'; private const string ResetConsoleVt100Code = "\u001b[m"; + private List _header; internal static int ComputeWideViewBestItemsPerRowFit(int stringLen, int screenColumns) { @@ -73,35 +75,35 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } /// - /// Initialize the table specifying the width of each column + /// Initialize the table specifying the width of each column. /// - /// left margin indentation - /// number of character columns on the screen - /// array of specified column widths - /// array of alignment flags - /// if true, suppress header printing - internal void Initialize(int leftMarginIndent, int screenColumns, Span columnWidths, ReadOnlySpan alignment, bool suppressHeader) + /// Left margin indentation + /// Number of character columns on the screen + /// Array of specified column widths + /// Array of alignment flags + /// If true, suppress header printing + /// Number of rows on the screen + internal void Initialize(int leftMarginIndent, int screenColumns, Span columnWidths, ReadOnlySpan alignment, bool suppressHeader, int screenRows = int.MaxValue) { - //Console.WriteLine(" 1 2 3 4 5 6 7"); - //Console.WriteLine("01234567890123456789012345678901234567890123456789012345678901234567890123456789"); - if (leftMarginIndent < 0) { leftMarginIndent = 0; } + if (screenColumns - leftMarginIndent < ScreenInfo.minimumScreenColumns) { _disabled = true; return; } - _startColumn = leftMarginIndent; + _startColumn = leftMarginIndent; _hideHeader = suppressHeader; // make sure the column widths are correct; if not, take appropriate action - ColumnWidthManager manager = new ColumnWidthManager(screenColumns - leftMarginIndent, - ScreenInfo.minimumColumnWidth, - ScreenInfo.separatorCharacterCount); + ColumnWidthManager manager = new ColumnWidthManager( + screenColumns - leftMarginIndent, + ScreenInfo.minimumColumnWidth, + ScreenInfo.separatorCharacterCount); manager.CalculateColumnWidths(columnWidths); @@ -126,6 +128,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format // now set the run time data structures _si = new ScreenInfo(); _si.screenColumns = screenColumns; + _si.screenRows = screenRows; _si.columnInfo = new ColumnInfo[columnWidths.Length]; int startCol = _startColumn; @@ -136,20 +139,29 @@ namespace Microsoft.PowerShell.Commands.Internal.Format _si.columnInfo[k].width = columnWidths[k]; _si.columnInfo[k].alignment = alignment[k]; startCol += columnWidths[k] + ScreenInfo.separatorCharacterCount; - //Console.WriteLine("start = {0} width = {1}", si.columnInfo[k].startCol, si.columnInfo[k].width); } } - internal void GenerateHeader(string[] values, LineOutput lo) + internal int GenerateHeader(string[] values, LineOutput lo) { - if (_disabled) - return; + if (_disabled || _hideHeader) + { + return 0; + } + else if (_header != null) + { + foreach (string line in _header) + { + lo.WriteLine(line); + } - if (_hideHeader) - return; + return _header.Count; + } + + _header = new List(); // generate the row with the header labels - GenerateRow(values, lo, true, null, lo.DisplayCells); + GenerateRow(values, lo, true, null, lo.DisplayCells, _header); // generate an array of "--" as header markers below // the column header labels @@ -175,10 +187,12 @@ namespace Microsoft.PowerShell.Commands.Internal.Format // would be invalidated breakLine[k] = StringUtil.DashPadding(count); } - GenerateRow(breakLine, lo, false, null, lo.DisplayCells); + + GenerateRow(breakLine, lo, false, null, lo.DisplayCells, _header); + return _header.Count; } - internal void GenerateRow(string[] values, LineOutput lo, bool multiLine, ReadOnlySpan alignment, DisplayCells dc) + internal void GenerateRow(string[] values, LineOutput lo, bool multiLine, ReadOnlySpan alignment, DisplayCells dc, List generatedRows) { if (_disabled) return; @@ -207,16 +221,17 @@ namespace Microsoft.PowerShell.Commands.Internal.Format if (multiLine) { - string[] lines = GenerateTableRow(values, currentAlignment, lo.DisplayCells); - - for (int k = 0; k < lines.Length; k++) + foreach (string line in GenerateTableRow(values, currentAlignment, lo.DisplayCells)) { - lo.WriteLine(lines[k]); + generatedRows?.Add(line); + lo.WriteLine(line); } } else { - lo.WriteLine(GenerateRow(values, currentAlignment, dc)); + string line = GenerateRow(values, currentAlignment, dc); + generatedRows?.Add(line); + lo.WriteLine(line); } } diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 0b7b68226d..5a1201fb68 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -1556,7 +1556,7 @@ namespace System.Management.Automation WriteVerbose(ps, string.Format(CultureInfo.CurrentCulture, ParserStrings.ImplicitRemotingPipelineBatchingException, ex.Message)); } } - + return false; } @@ -1806,6 +1806,7 @@ namespace System.Management.Automation.Internal internal static bool StopwatchIsNotHighResolution; internal static bool DisableGACLoading; internal static bool SetConsoleWidthToZero; + internal static bool SetConsoleHeightToZero; // A location to test PSEdition compatibility functionality for Windows PowerShell modules with // since we can't manipulate the System32 directory in a test diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 index 5d08c9fc83..264121b7c2 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 @@ -791,4 +791,12 @@ A Name B [system.management.automation.internal.internaltesthooks]::SetTestHook('SetConsoleWidthToZero', $false) } } + + It "-RepeatHeader should output the header at every screen full" -Skip:([Console]::WindowHeight -eq 0) { + $numHeaders = 4 + $numObjects = [Console]::WindowHeight * $numHeaders + $out = 1..$numObjects | ForEach-Object { @{foo=$_} } | Format-Table -RepeatHeader | Out-String + $lines = $out.Split([System.Environment]::NewLine) + ($lines | Select-String "Name\s*Value").Count | Should -Be ($numHeaders + 1) + } }