diff --git a/.github/ISSUE_TEMPLATE/translation_template.csv b/.github/ISSUE_TEMPLATE/translation_template.csv index 3c4f560..51fcd50 100644 --- a/.github/ISSUE_TEMPLATE/translation_template.csv +++ b/.github/ISSUE_TEMPLATE/translation_template.csv @@ -76,6 +76,10 @@ "MetadataViewer.resx","$this.Text",,"Parquet Metadata Viewer","Parquet Metadata Önizleyicisi","" "MetadataViewer.resx","closeButton.Text",,"Close","Kapat","" "MetadataViewer.resx","loadingTab.Text",,"Loading...","Yükleniyor...","" +"QuickPeekForm.resx","$this.Text",,"Quick Peek","Hızlı Önizleme","" +"QuickPeekForm.resx","copyToClipboardToolStripMenuItem.Text",,"Copy to clipboard","Panoya kopyala","" +"QuickPeekForm.resx","saveImageToFileButton.Text",,"Save as PNG","PNG olarak Kaydet","" +"QuickPeekForm.resx","takeMeBackLinkLabel.Text",,"<<< back","<<< geri","" "Errors.resx","CopyAsWhereTooLargeErrorMessage","Shown when the user right-click's on too many cells and selects the Copy as WHERE... option","The selected data is too large. Please select less cells.","Çok fazla veri seçili. Lütfen daha az hücre seçin.","" "Errors.resx","CopyAsWhereTooLargeErrorTitle","Shown when the user right-click's on too many cells and selects the Copy as WHERE... option","Copy to clipboard failed","Panoya kopyalama başarısız oldu","" "Errors.resx","CopyErrorMessageText","Shown in the messagebox for unhandled exceptions to teach users how to copy the error text","(CTRL+C to copy)","(Kopyalamak için CTRL+C)","" @@ -210,6 +214,8 @@ Bu ayarı Yardim → Hakkinda sayfasında da yapabilirsiniz.","" "Strings.resx","MainWindowOpenFolderTitleFormat","Title format string when a new folder is opened","Folder: {0}","Klasör: {0}","" "Strings.resx","MetadataSuccessfullyExportedToFileMessageFormat","Shown when raw thrift metadata is successfully exported to a file","Metadata successfully exported to: {0}","Metadata başarıyla kaydedildi: {0}","" "Strings.resx","MetadataSuccessfullyExportedToFileMessageTitle","Shown when raw thrift metadata is successfully exported to a file","Export complete","Kayıt başarılı","" +"Strings.resx","OpenFileNoLongerExistsTitleSuffix","We don't lock opened files anymore. So if the source file/folder disappears we will append this suffix to the main form title as a visual indicator for the user that the data source no longer exists.","DELETED","SİLİNDİ","" +"Strings.resx","OpenFileWasModifiedTitleSuffix","We don't lock opened files anymore. So if the source file/folder is modified we will append this suffix to the main form title as a visual indicator for the user that the data they are looking at is stale.","MODIFIED","DEĞİŞTİRİLDİ","" "Strings.resx","PrivacyPolicyLabelText","Title to be shown before the privacy policy","Privacy policy","Gizlilik politikası","" "Strings.resx","QueryFinishedStatusText","Shown in the Query Editor status bar when the query finishes executing","Finished in:","Tamamlandı:","" "Strings.resx","QueryRunningStatusText","Shown in the Query Editor tool when the query is running","Running:","İşleniyor:","" @@ -269,7 +275,4 @@ Onun yerine sonuçlari {2} dosyasına aktarmak ister misiniz?","" "Strings.resx","TooManyFieldsErrorFormat","Shown on the Field Selection Dialog when there are too many fields to filter by","Too many fields: {0}","Desteklenmeyen sayıda alan: {0}","" "Strings.resx","TypeText","Shown in the title bar of quick peek windows for image previews","Type","Format","" "Strings.resx","UnsupportedFieldCountTextFormat","Shown in the field selection dialog to indicate how many fields are unsupported by ParquetViewer","Unsupported: {0}","Desteklenmeyen: {0}","" -"QuickPeekForm.resx","$this.Text",,"Quick Peek","Hızlı Önizleme","" -"QuickPeekForm.resx","copyToClipboardToolStripMenuItem.Text",,"Copy to clipboard","Panoya kopyala","" -"QuickPeekForm.resx","saveImageToFileButton.Text",,"Save as PNG","PNG olarak Kaydet","" -"QuickPeekForm.resx","takeMeBackLinkLabel.Text",,"<<< back","<<< geri","" +"Strings.resx","WordWrapContextMenuItemText","Right click context menu item shown when a column has cut off text and the user right clicks the column header.","Wrap Text","Metni Kaydır","" diff --git a/.github/workflows/build-test-publish.yaml b/.github/workflows/build-test-publish.yaml index 3b7da2e..e4f17e6 100644 --- a/.github/workflows/build-test-publish.yaml +++ b/.github/workflows/build-test-publish.yaml @@ -150,7 +150,7 @@ jobs: - uses: ncipollo/release-action@v1 with: - artifacts: "signed-package/ParquetViewer.exe,signed-package/ParquetViewer_SelfContained.exe" + artifacts: "/signed-package/ParquetViewer.exe,/signed-package/ParquetViewer_SelfContained.exe" body: "PR: #${{ env.PR_NUMBER }}" allowUpdates: ${{ env.BRANCH_NAME != 'main' }} omitBodyDuringUpdate: true diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 7225248..a3c3faa 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,16 +3,16 @@ true - + - + - + diff --git a/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs b/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs index 5cb3b16..8b61b1a 100644 --- a/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs @@ -459,5 +459,7 @@ private static string MakeColumnSafe(string columnName) public async Task WriteDataToParquetFileAsync(DataTable dataTable, string path, CancellationToken cancellationToken, IProgress progress, Dictionary? customMetadata) => throw new NotImplementedException(); + + public IEnumerable GetOpenParquetFilePaths() => this._dbs.Select(db => db.ParquetFilePath); } } \ No newline at end of file diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs index 765d20e..e3527d0 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs @@ -564,6 +564,11 @@ private DataTableLite BuildDataTable(ParquetSchemaElement? parent, List throw; } + catch (ParquetException ex) + { + var maskedExMessage = ex.Message.Replace($"'{field.Path}'", $"`{field.Path}`"); + throw new ParquetEngineException(maskedExMessage, ex); + } } } } \ No newline at end of file diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index 0019770..e22eb8f 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs @@ -10,10 +10,10 @@ namespace ParquetViewer.Engine.ParquetNET public partial class ParquetEngine : IParquetEngine, IDisposable { private static readonly ParquetOptions _defaultParquetOptions = new () { UseDateOnlyTypeForDates = true, UseTimeOnlyTypeForTimeMicros = true, UseTimeOnlyTypeForTimeMillis = true }; - private readonly ParquetReader[] _parquetFiles; + private readonly (string ParquetFilePath, ParquetReader Reader)[] _parquetFiles; private long? _recordCount; - private ParquetReader _defaultReader => _parquetFiles.FirstOrDefault() ?? throw new ParquetEngineException("No parquet readers available"); + private ParquetReader _defaultReader => _parquetFiles.Length > 0 ? _parquetFiles[0].Reader : throw new ParquetEngineException("No parquet readers available"); private FileMetaData _thriftMetadata => _defaultReader.Metadata ?? throw new ParquetEngineException("No thrift metadata was found"); @@ -21,7 +21,7 @@ public partial class ParquetEngine : IParquetEngine, IDisposable public Dictionary CustomMetadata => _defaultReader.CustomMetadata; - public long RecordCount => _recordCount ??= _parquetFiles.Sum(pf => pf.Metadata?.NumRows ?? 0); + public long RecordCount => _recordCount ??= _parquetFiles.Sum(pf => pf.Reader.Metadata?.NumRows ?? 0); public int NumberOfPartitions => _parquetFiles.Length; @@ -32,7 +32,7 @@ public partial class ParquetEngine : IParquetEngine, IDisposable ParquetMetadata? _metadata = null; public IParquetMetadata Metadata => _metadata ??= new ParquetMetadata(_thriftMetadata, BuildParquetSchemaTree(), (int)RecordCount); - private ParquetEngine(string fileOrFolderPath, params ParquetReader[] parquetFiles) + private ParquetEngine(string fileOrFolderPath, params (string FilePath, ParquetReader Reader)[] parquetFiles) { _parquetFiles = parquetFiles ?? throw new ArgumentNullException(nameof(parquetFiles), "No parquet readers provided"); Path = fileOrFolderPath; @@ -94,13 +94,16 @@ public static async Task OpenFileAsync(string parquetFilePath, Ca throw new FileNotFoundException($"Could not find parquet file at: {parquetFilePath}"); } + Stream? readOnlyNonLockingStream = null; try { - var parquetReader = await ParquetReader.CreateAsync(parquetFilePath, _defaultParquetOptions, cancellationToken); - return new ParquetEngine(parquetFilePath, parquetReader); + readOnlyNonLockingStream = new FileStream(parquetFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var parquetReader = await ParquetReader.CreateAsync(readOnlyNonLockingStream, _defaultParquetOptions, false, cancellationToken); + return new ParquetEngine(parquetFilePath, (parquetFilePath, parquetReader)); } catch (Exception ex) { + readOnlyNonLockingStream?.Dispose(); throw new FileReadException(ex); } } @@ -113,23 +116,26 @@ public static async Task OpenFolderAsync(string folderPath, Cance } var skippedFiles = new Dictionary(); - var fileGroups = new Dictionary>(); + var fileGroups = new Dictionary>(); foreach (var file in Engine.Helpers.ListParquetFiles(folderPath)) { cancellationToken.ThrowIfCancellationRequested(); + Stream? readOnlyNonLockingStream = null; try { - var parquetReader = await ParquetReader.CreateAsync(file, _defaultParquetOptions, cancellationToken); + readOnlyNonLockingStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var parquetReader = await ParquetReader.CreateAsync(readOnlyNonLockingStream, _defaultParquetOptions, false, cancellationToken); if (!fileGroups.ContainsKey(parquetReader.Schema)) { - fileGroups.Add(parquetReader.Schema, new List()); + fileGroups.Add(parquetReader.Schema, new List<(string, ParquetReader)>()); } - fileGroups[parquetReader.Schema].Add(parquetReader); + fileGroups[parquetReader.Schema].Add((file, parquetReader)); } catch (Exception ex) { + readOnlyNonLockingStream?.Dispose(); skippedFiles.Add(System.IO.Path.GetRelativePath(folderPath, file), ex); } } @@ -150,7 +156,7 @@ public static async Task OpenFolderAsync(string folderPath, Cance //We found more than one type of schema. foreach (var fileGroupList in fileGroups.Values) { - Engine.Helpers.EZDispose(fileGroupList); + Engine.Helpers.EZDispose(fileGroupList.Select(f => f.Reader)); } throw new MultipleSchemasFoundException(fileGroups.Keys.ToList() @@ -159,7 +165,7 @@ public static async Task OpenFolderAsync(string folderPath, Cance else if (skippedFiles.Count > 0) { //We found one schema but some files couldn't be read - Engine.Helpers.EZDispose(fileGroups.Values.First()); + Engine.Helpers.EZDispose(fileGroups.Values.First().Select(f => f.Reader)); throw new SomeFilesSkippedException(skippedFiles); } @@ -172,13 +178,13 @@ public static async Task OpenFolderAsync(string folderPath, Cance { foreach (var parquetFile in _parquetFiles) { - if (offset >= parquetFile.Metadata?.NumRows) + if (offset >= parquetFile.Reader.Metadata?.NumRows) { - offset -= parquetFile.Metadata.NumRows; + offset -= parquetFile.Reader.Metadata.NumRows; continue; } - yield return (offset, parquetFile); + yield return (offset, parquetFile.Reader); offset = 0; } } @@ -230,7 +236,7 @@ public async Task WriteDataToParquetFileAsync(DataTable dataTable, string path, } } - public void Dispose() => Engine.Helpers.EZDispose(_parquetFiles); + public void Dispose() => Engine.Helpers.EZDispose(_parquetFiles.Select(f => f.Reader)); private static System.Type GetNullableVersion(System.Type sourceType) => sourceType == null ? throw new ArgumentNullException(nameof(sourceType)) @@ -279,5 +285,7 @@ private static Array GetColumnValues(DataTable dataTable, System.Type type, stri return values; } + + public IEnumerable GetOpenParquetFilePaths() => this._parquetFiles.Select(db => db.ParquetFilePath); } } \ No newline at end of file diff --git a/src/ParquetViewer.Engine/IParquetEngine.cs b/src/ParquetViewer.Engine/IParquetEngine.cs index f68987d..6169353 100644 --- a/src/ParquetViewer.Engine/IParquetEngine.cs +++ b/src/ParquetViewer.Engine/IParquetEngine.cs @@ -16,5 +16,7 @@ Task> ReadRowsAsync(List selectedFields, int offse Task WriteDataToParquetFileAsync(DataTable dataTable, string path, CancellationToken cancellationToken, IProgress progress, Dictionary? customMetadata); + + IEnumerable GetOpenParquetFilePaths(); } } \ No newline at end of file diff --git a/src/ParquetViewer.Engine/Types/IByteArrayValue.cs b/src/ParquetViewer.Engine/Types/IByteArrayValue.cs index 72c51e2..82306df 100644 --- a/src/ParquetViewer.Engine/Types/IByteArrayValue.cs +++ b/src/ParquetViewer.Engine/Types/IByteArrayValue.cs @@ -1,10 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Net; -using System.Text; -using System.Threading.Tasks; namespace ParquetViewer.Engine.Types { diff --git a/src/ParquetViewer/Analytics/AllEvents.cs b/src/ParquetViewer/Analytics/AllEvents.cs index b4f3a93..d8bc0c9 100644 --- a/src/ParquetViewer/Analytics/AllEvents.cs +++ b/src/ParquetViewer/Analytics/AllEvents.cs @@ -132,11 +132,10 @@ public enum ActionId public class ExceptionEvent : AmplitudeEvent { public const string MASK_SENTINEL = "*****"; - private const string EVENT_TYPE = "exception.thrown"; [JsonIgnore] - public System.Exception Exception { get; } + public Exception Exception { get; } public string Message { diff --git a/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs b/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs index 67030b0..cf36cad 100644 --- a/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs +++ b/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs @@ -265,24 +265,29 @@ protected override void OnMouseUp(DataGridViewCellMouseEventArgs e) protected override void OnMouseClick(DataGridViewCellMouseEventArgs e) { base.OnMouseClick(e); - if (e.Button != MouseButtons.Left) - return; - - if (ContainsCursor(this._playPauseButtonBounds, e.Location) && !this._isCellTooSmall) - { - TogglePlayPause(); - } - else if (ContainsCursor(this._stopButtonBounds, e.Location) && !this._isCellTooSmall) - { - StopPlayback(); - } - else if (ContainsCursor(this._contextMenuButtonBounds, e.Location) && !this._isCellTooSmall) + if (e.Button == MouseButtons.Left) { - ShowContextMenu(e.Location); + if (ContainsCursor(this._playPauseButtonBounds, e.Location) && !this._isCellTooSmall) + { + TogglePlayPause(); + } + else if (ContainsCursor(this._stopButtonBounds, e.Location) && !this._isCellTooSmall) + { + StopPlayback(); + } + else if (ContainsCursor(this._contextMenuButtonBounds, e.Location) && !this._isCellTooSmall) + { + ShowContextMenu(e.Location); + } + else if (ContainsCursor(this._trackBarBounds, e.Location)) + { + Seek(e.Location); + } } - else if (ContainsCursor(this._trackBarBounds, e.Location)) + else if (e.Button == MouseButtons.Right) { - Seek(e.Location); + // Allow play/pause even when no ui buttons are rendered + TogglePlayPause(); } } diff --git a/src/ParquetViewer/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index cdb0b5d..66bd266 100644 --- a/src/ParquetViewer/Controls/ParquetGridView.cs +++ b/src/ParquetViewer/Controls/ParquetGridView.cs @@ -75,6 +75,7 @@ public ParquetGridView() : base() SelectionMode = DataGridViewSelectionMode.RowHeaderSelect; ClipboardCopyMode = DataGridViewClipboardCopyMode.EnableWithoutHeaderText; ShowCellToolTips = false; //tooltips for columns with very long strings cause performance issues. This was the easiest solution + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; //Leave as the default None as I'm concerned about performance to change the default. Needz moar testing to see if we can set to `DisplayedCells` by default } protected override void OnDataSourceChanged(EventArgs e) @@ -625,6 +626,7 @@ protected override void OnColumnHeaderMouseClick(DataGridViewCellMouseEventArgs AddFrozenOption(this._headerContextMenu.Items, e.ColumnIndex); AddDisplayFormatOptions(this._headerContextMenu.Items, e.ColumnIndex); + AddWordWrapOption(this._headerContextMenu.Items, e.ColumnIndex); if (this._headerContextMenu.Items.Count > 0) this._headerContextMenu.Show(Cursor.Position); @@ -806,8 +808,8 @@ private void AutoSizeColumns(int? forceAutoSizeColumnIndex = null) .Where(value => value is not null)!; } - // Get the longest string in the array. (Limit to 100k values to improve render time) - string? longestColString = colStringCollection.Take(forceAutoSizeColumnIndex is not null ? int.MaxValue : 100_000).MaxBy(stringValue => stringValue.Length); + // Get the longest string in the array. (Limit to 10k values to improve render time) + string? longestColString = colStringCollection.Take(forceAutoSizeColumnIndex is not null ? int.MaxValue : 10_000).MaxBy(stringValue => stringValue.Length); if (longestColString is not null) newColumnSize = Math.Max(newColumnSize, MeasureStringWidth(gfx, this.Font, longestColString, true)); @@ -1148,7 +1150,7 @@ private void AddDisplayFormatOptions(ToolStripItemCollection contextMenu, int co { Checked = displayFormat == FloatDisplayFormat.Scientific }; scientificNotationMenuItem.Click += (object? _, EventArgs _) => { - ColumnFormattedEvent.FireAndForget(scientificNotationMenuItem.Text); + ColumnFormattedEvent.FireAndForget("Scientific"); if (floatColumnsWithFormatOverrides.ContainsKey(columnName)) floatColumnsWithFormatOverrides[columnName] = FloatDisplayFormat.Scientific; @@ -1164,7 +1166,7 @@ private void AddDisplayFormatOptions(ToolStripItemCollection contextMenu, int co { Checked = displayFormat == FloatDisplayFormat.Decimal }; decimalNotationMenuItem.Click += (object? _, EventArgs _) => { - ColumnFormattedEvent.FireAndForget(decimalNotationMenuItem.Text); + ColumnFormattedEvent.FireAndForget("Decimal"); if (floatColumnsWithFormatOverrides.ContainsKey(columnName)) floatColumnsWithFormatOverrides[columnName] = FloatDisplayFormat.Decimal; @@ -1204,6 +1206,63 @@ private void AddFrozenOption(ToolStripItemCollection items, int columnIndex) items.Add(menuItem); } + private void AddWordWrapOption(ToolStripItemCollection items, int columnIndex) + { + var column = this.Columns[columnIndex]; + var isWordWrapEnabled = column.DefaultCellStyle.WrapMode == DataGridViewTriState.True; + + //If word wrap is already enabled, we want to show this option + if (!isWordWrapEnabled) + { + //If not, only show this option if the text in the currently displayed cells are cut off + var hasCutOffText = false; + foreach (var rowIndex in GetVisibleRowIndexes()) + { + if (IsCellTextCutOff(rowIndex, columnIndex)) + { + hasCutOffText = true; + break; + } + } + if (!hasCutOffText) + return; + } + + var menuItem = new ToolStripMenuItem(Resources.Strings.WordWrapContextMenuItemText) + { Checked = isWordWrapEnabled }; + + menuItem.Click += (object? _, EventArgs _) => + { + var isWordWrapCurrentlyEnabled = column.DefaultCellStyle.WrapMode == DataGridViewTriState.True; + + if (!isWordWrapCurrentlyEnabled) + ColumnFormattedEvent.FireAndForget("WordWrap"); + + column.DefaultCellStyle.WrapMode = isWordWrapCurrentlyEnabled ? DataGridViewTriState.False : DataGridViewTriState.True; + + var didWeJustEnableWordWrap = !isWordWrapCurrentlyEnabled; + if (didWeJustEnableWordWrap) + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.DisplayedCells; + } + else + { + var doesAnyColumnHaveWordWrapEnabled = this.Columns.Cast() + .Any(col => col.DefaultCellStyle.WrapMode == DataGridViewTriState.True); + if (doesAnyColumnHaveWordWrapEnabled) + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.DisplayedCells; + } + else + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; + } + } + }; + + items.Add(menuItem); + } + private void StyleFrozenColumns() { //First reset styles for all column headers @@ -1415,5 +1474,44 @@ private enum FloatDisplayFormat Scientific = 0, Decimal } + + /// + /// Simple tool to guess if text is long enough to be cut off in a cell. + /// Returns false for all non-string columns. + /// + private bool IsCellTextCutOff(int rowIndex, int columnIndex) + { + var cell = this.Rows[rowIndex].Cells[columnIndex]; + if (cell.OwningColumn?.ValueType != typeof(string)) + return false; //Only show word wrap for string columns + + var text = cell.FormattedValue?.ToString() ?? string.Empty; + if (string.IsNullOrEmpty(text)) + return false; + + Size textSize = TextRenderer.MeasureText(text, cell.InheritedStyle.Font ?? this.Font); + return textSize.Width > this.Columns[columnIndex].Width; + } + + /// + /// Returns the row indexes for rows that are currently visible + /// + private IEnumerable GetVisibleRowIndexes() + { + int firstIndex = this.FirstDisplayedScrollingRowIndex; + if (firstIndex < 0) + yield break; // no rows displayed (e.g., grid is empty) + + int displayedCount = this.DisplayedRowCount(true); // true = include partially visible rows + + for (int i = 0; i < displayedCount; i++) + { + int rowIndex = firstIndex + i; + if (rowIndex >= this.Rows.Count) + yield break; + + yield return rowIndex; + } + } } } \ No newline at end of file diff --git a/src/ParquetViewer/FieldSelectionDialog.Designer.cs b/src/ParquetViewer/FieldSelectionDialog.Designer.cs index 3d680e1..3dac116 100644 --- a/src/ParquetViewer/FieldSelectionDialog.Designer.cs +++ b/src/ParquetViewer/FieldSelectionDialog.Designer.cs @@ -1,4 +1,6 @@ -namespace ParquetViewer +using ParquetViewer.Controls; + +namespace ParquetViewer { partial class FieldsToLoadForm { @@ -35,7 +37,7 @@ private void InitializeComponent() showSelectedFieldsRadioButton = new System.Windows.Forms.RadioButton(); allFieldsRadioButton = new System.Windows.Forms.RadioButton(); rememberMyChoiceCheckBox = new StylableCheckBox(); - filterColumnsTextbox = new System.Windows.Forms.TextBox(); + filterColumnsTextbox = new DelayedOnChangedTextBox(); fieldsPanel = new System.Windows.Forms.Panel(); mainTableLayoutPanel.SuspendLayout(); SuspendLayout(); @@ -96,8 +98,9 @@ private void InitializeComponent() // resources.ApplyResources(filterColumnsTextbox, "filterColumnsTextbox"); mainTableLayoutPanel.SetColumnSpan(filterColumnsTextbox, 3); + filterColumnsTextbox.DelayedTextChangedTimeout = 275; filterColumnsTextbox.Name = "filterColumnsTextbox"; - filterColumnsTextbox.TextChanged += filterColumnsTextbox_TextChanged; + filterColumnsTextbox.DelayedTextChanged += filterColumnsTextbox_DelayedTextChanged; // // fieldsPanel // @@ -126,7 +129,7 @@ private void InitializeComponent() private System.Windows.Forms.TableLayoutPanel mainTableLayoutPanel; private System.Windows.Forms.Button doneButton; private System.Windows.Forms.Panel fieldsPanel; - private System.Windows.Forms.TextBox filterColumnsTextbox; + private DelayedOnChangedTextBox filterColumnsTextbox; private System.Windows.Forms.Button clearfilterColumnsButton; private System.Windows.Forms.RadioButton showSelectedFieldsRadioButton; private System.Windows.Forms.RadioButton allFieldsRadioButton; diff --git a/src/ParquetViewer/FieldSelectionDialog.cs b/src/ParquetViewer/FieldSelectionDialog.cs index 261060a..7749665 100644 --- a/src/ParquetViewer/FieldSelectionDialog.cs +++ b/src/ParquetViewer/FieldSelectionDialog.cs @@ -280,7 +280,7 @@ private void ShowError(Exception ex, string? customMessage = null, bool showStac MessageBox.Show(string.Concat(customMessage ?? $"{Resources.Errors.GenericErrorMessage}:", Environment.NewLine, showStackTrace ? ex.ToString() : ex.Message), ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); } - private void filterColumnsTextbox_TextChanged(object sender, EventArgs e) + private void filterColumnsTextbox_DelayedTextChanged(object sender, EventArgs e) { if (!string.IsNullOrWhiteSpace(this.filterColumnsTextbox.Text)) { diff --git a/src/ParquetViewer/FieldSelectionDialog.resx b/src/ParquetViewer/FieldSelectionDialog.resx index bc8c766..d14a028 100644 --- a/src/ParquetViewer/FieldSelectionDialog.resx +++ b/src/ParquetViewer/FieldSelectionDialog.resx @@ -117,311 +117,311 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 1 - - - - 7, 15 - - - X - - - 3 - - - True - - + True - - mainTableLayoutPanel - - - mainTableLayoutPanel + + + GrowAndShrink - - StylableCheckBox, ParquetViewer, Culture=neutral, PublicKeyToken=null + + 4 - - 92, 8 + + Top, Bottom, Right - - - Fill + + + 464, 381 - - Top, Bottom, Right + + + 4, 3, 4, 3 - - filterColumnsTextbox + + 113, 29 - - 10, 38 + + 3 - - 33, 105 + + Done - - Left, Right + + doneButton - - 1 + + System.Windows.Forms.Button, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - + mainTableLayoutPanel - - - 3, 4, 3, 3 + + 0 - - 284, 282 + + Top, Bottom, Right + + + False Segoe UI Semibold, 9pt, style=Bold - - Remember My Choice - - - All Fields + + 551, 73 - + 4, 3, 4, 3 - - 2 - - - 2 + + 26, 26 - - System.Windows.Forms.TableLayoutPanel, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 4 - - 551, 73 + + X - - 144, 19 + + clearfilterColumnsButton - - 6 + + System.Windows.Forms.Button, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - allFieldsRadioButton + + mainTableLayoutPanel - - 0 + + 1 Top, Bottom, Left - - 0 - - - 6 + + True - - False + + 10, 38 - - True + + 4, 3, 4, 3 - - 26, 26 + + 169, 29 - - 0, 0 + + 1 Selected Fields (Count: {0}): - - mainTableLayoutPanel - - - 464, 381 - - - GrowAndShrink + + showSelectedFieldsRadioButton - - rememberMyChoiceCheckBox + + System.Windows.Forms.RadioButton, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 0 + + mainTableLayoutPanel - - 6, 0, 4, 0 + + 2 - - 533, 23 + + Left - + True - + + 10, 8 + + 4, 3, 4, 3 - - Top, Bottom, Right + + 72, 19 - - clearfilterColumnsButton + + 0 - - FieldsToLoadForm + + All Fields - - 113, 29 + + allFieldsRadioButton - - 5 + + System.Windows.Forms.RadioButton, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - ParquetViewer.Controls.FormBase, ParquetViewer, Culture=neutral, PublicKeyToken=null + + mainTableLayoutPanel - - $this + + 3 - + Left - - 4, 3, 4, 3 + + True - - 4, 3, 4, 3 + + 112, 8 - - 4, 3, 4, 3 + + 3, 4, 3, 3 - - System.Windows.Forms.TextBox, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 144, 19 - - 4 + + 6 - - mainTableLayoutPanel + + Remember My Choice - - 585, 413 + + rememberMyChoiceCheckBox - - showSelectedFieldsRadioButton + + StylableCheckBox, ParquetViewer, Culture=neutral, PublicKeyToken=null - - 3 + + mainTableLayoutPanel - - 169, 29 + + 4 - - mainTableLayoutPanel + + Left, Right + + + False + + + Segoe UI, 9pt 10, 74 - - 4 - - + 4, 3, 4, 3 - - Done + + Search by name - - Select Fields to Load + + 533, 23 - - 544, 270 + + 0 - - False + + filterColumnsTextbox - - fieldsPanel + + ParquetViewer.Controls.DelayedOnChangedTextBox, ParquetViewer, Culture=neutral, PublicKeyToken=null - - System.Windows.Forms.RadioButton, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + mainTableLayoutPanel - - Segoe UI, 9pt + + 5 - - Search by name + + Top, Bottom, Left, Right + + + True False - - 1 + + 33, 105 - - System.Windows.Forms.RadioButton, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 4, 3, 4, 3 - - True + + 544, 270 + + + 2 + + + fieldsPanel System.Windows.Forms.Panel, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - 0 + + mainTableLayoutPanel - + + 6 + + + Fill + + + 0, 0 + + 4, 3, 4, 3 + + 6, 0, 4, 0 + + + 6 + 585, 413 + + 1 + mainTableLayoutPanel - - Top, Bottom, Left, Right - - - 4, 3, 4, 3 - - - System.Windows.Forms.Button, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + System.Windows.Forms.TableLayoutPanel, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - System.Windows.Forms.Button, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + $this - - Left + + 0 <?xml version="1.0" encoding="utf-16"?><TableLayoutSettings><Controls><Control Name="doneButton" Row="5" RowSpan="1" Column="2" ColumnSpan="2" /><Control Name="clearfilterColumnsButton" Row="2" RowSpan="1" Column="3" ColumnSpan="1" /><Control Name="showSelectedFieldsRadioButton" Row="1" RowSpan="1" Column="0" ColumnSpan="3" /><Control Name="allFieldsRadioButton" Row="0" RowSpan="1" Column="0" ColumnSpan="2" /><Control Name="rememberMyChoiceCheckBox" Row="0" RowSpan="1" Column="2" ColumnSpan="1" /><Control Name="filterColumnsTextbox" Row="2" RowSpan="1" Column="0" ColumnSpan="3" /><Control Name="fieldsPanel" Row="3" RowSpan="2" Column="1" ColumnSpan="3" /></Controls><Columns Styles="Absolute,23,Absolute,80,Percent,100,Absolute,34" /><Rows Styles="Absolute,35,Absolute,35,Absolute,32,Percent,100,Absolute,120,Absolute,35" /></TableLayoutSettings> - - doneButton + + True + + + 7, 15 - - mainTableLayoutPanel + + 585, 413 - - 4 + + 4, 3, 4, 3 - - 10, 8 + + 284, 282 - - 72, 19 + + Select Fields to Load - - 6 + + FieldsToLoadForm + + + ParquetViewer.Controls.FormBase, ParquetViewer, Culture=neutral, PublicKeyToken=null - - True - \ No newline at end of file diff --git a/src/ParquetViewer/Helpers/UtilityMethods.cs b/src/ParquetViewer/Helpers/UtilityMethods.cs index 07eafaf..f97096e 100644 --- a/src/ParquetViewer/Helpers/UtilityMethods.cs +++ b/src/ParquetViewer/Helpers/UtilityMethods.cs @@ -9,7 +9,7 @@ namespace ParquetViewer.Helpers public static class UtilityMethods { /// - /// Formats a value to make it is RFC-4180 compliant + /// Formats a value to make it RFC-4180 compliant /// /// Raw string value to be added to a CSV file /// Possibly formatted value diff --git a/src/ParquetViewer/MainForm.Designer.cs b/src/ParquetViewer/MainForm.Designer.cs index f36408b..3f182da 100644 --- a/src/ParquetViewer/MainForm.Designer.cs +++ b/src/ParquetViewer/MainForm.Designer.cs @@ -87,6 +87,7 @@ private void InitializeComponent() exportFileDialog = new SaveFileDialog(); openFolderDialog = new FolderBrowserDialog(); loadAllRowsButtonTooltip = new ToolTip(components); + fileIntegrityCheckingTimer = new Timer(components); mainTableLayoutPanel.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)mainGridView).BeginInit(); mainMenuStrip.SuspendLayout(); @@ -448,6 +449,12 @@ private void InitializeComponent() resources.ApplyResources(openFolderDialog, "openFolderDialog"); openFolderDialog.ShowNewFolderButton = false; // + // fileIntegrityCheckingTimer + // + fileIntegrityCheckingTimer.Enabled = true; + fileIntegrityCheckingTimer.Interval = 2500; + fileIntegrityCheckingTimer.Tick += fileIntegrityCheckingTimer_Tick; + // // MainForm // AllowDrop = true; @@ -529,6 +536,7 @@ private void InitializeComponent() private ToolStripMenuItem languageToolStripMenuItem; private ToolStripMenuItem englishToolStripMenuItem; private ToolStripMenuItem turkishToolStripMenuItem; + private Timer fileIntegrityCheckingTimer; } } diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 33dd544..cb0649f 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -1,4 +1,5 @@ using ParquetViewer.Analytics; +using ParquetViewer.Engine; using ParquetViewer.Engine.Types; using ParquetViewer.Exceptions; using ParquetViewer.Helpers; @@ -7,8 +8,10 @@ using System.Diagnostics; using System.Drawing; using System.Globalization; +using System.IO; using System.Linq; using System.Text.RegularExpressions; +using System.Threading.Tasks; using System.Windows.Forms; namespace ParquetViewer @@ -18,6 +21,8 @@ public partial class MainForm [GeneratedRegex("^WHERE ")] private static partial Regex QueryUselessPartRegex(); + private int _failedFileIntegrityCheckCount = 0; + private void offsetTextBox_KeyPress(object sender, KeyPressEventArgs e) { if (!char.IsControl(e.KeyChar) && !char.IsDigit(e.KeyChar)) @@ -160,6 +165,10 @@ private void MainForm_KeyDown(object sender, KeyEventArgs e) { this.loadAllRowsButton_Click(null, null); } + else if (e.Control && e.KeyCode == Keys.R && this._openParquetEngine is not null) //Reload shortcut + { + LoadFileToGridview(); + } } private void runQueryButton_Click(object sender, EventArgs? e) @@ -288,5 +297,132 @@ private void languageToolStripMenuItem_Click(object sender, EventArgs e) AppSettings.UserSelectedCulture = newCultureInfo; UtilityMethods.RestartApplication(); } + + /// Originally I implemented a FileSystemWatcher but it seems network drives are not reliable with that. + /// Not sure how common that is but this implementation without it is simpler and I'm hoping not too IO intensive + private async void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) + { + if (this.OpenFileOrFolderPath is null || this._openParquetEngine is null) + return; //no file open + + this.fileIntegrityCheckingTimer.Stop(); + try + { + var fileDeletedSuffix = $" ({Resources.Strings.OpenFileNoLongerExistsTitleSuffix})"; + var fileModifiedSuffix = $" ({Resources.Strings.OpenFileWasModifiedTitleSuffix})"; + + if (this._originalModifiedInfo is null) + { + ResetTitle(); + } + + var alreadyHasDeletedSuffix = this.Text.EndsWith(fileDeletedSuffix); + + //Perform file system checks in a background thread avoid blocking the UI thread. + //Only really relevant when opening a folder with many files on a network drive. + var engineSnapshot = this._openParquetEngine; + var lastModifiedInfo = await Task.Run(() => TryGetLastModifiedInfo(engineSnapshot, this.OpenFileOrFolderPath)); + if (!ReferenceEquals(engineSnapshot, this._openParquetEngine)) + return; //the user has opened a different file/folder while we were checking the file system, so ignore this result + + if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) + { + ResetTitle(); + //File or folder no longer exists. In this case let's not mark this timer as handled + //and let it keep running in case the file/folder is restored later. + this.Text += fileDeletedSuffix; + return; + } + else if (lastModifiedInfo is not null && alreadyHasDeletedSuffix) + { + ResetTitle(); + } + + if (lastModifiedInfo is not null) + { + if (_originalModifiedInfo is null) + { + _originalModifiedInfo = lastModifiedInfo; + } + else if (_originalModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) + { + ResetTitle(); + this.Text += fileModifiedSuffix; + } + } + + this._failedFileIntegrityCheckCount = 0; + + void ResetTitle() + { + if (this.Text.EndsWith(fileModifiedSuffix)) + this.Text = this.Text.Replace(fileModifiedSuffix, string.Empty); + else if (this.Text.EndsWith(fileDeletedSuffix)) + this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); + } + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + //swallow expected exceptions to not overload the user with error message dialogs + } + catch (Exception ex) + { + //Swallow to not overload the user with error message dialogs but log it as this is unexpected. + //Also make sure we don't spam exception events for repeated failures. + if (++this._failedFileIntegrityCheckCount == 1) + { + ExceptionEvent.FireAndForget(ex); + } + } + finally + { + this.fileIntegrityCheckingTimer.Start(); + } + + //Returns the last modified date and size of the open file, or the most recent last modified + //date and total combined size of all open files in the folder. + static (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo(IParquetEngine engine, string openFileOrFolderPath) + { + if (engine is null) + { + return null; //no open file; + } + + DateTime latest = Directory.Exists(openFileOrFolderPath) ? Directory.GetCreationTimeUtc(openFileOrFolderPath) : DateTime.MinValue; + long totalLength = 0; + bool foundAny = false; + var counter = 0; + + foreach (var filePath in engine.GetOpenParquetFilePaths()) + { + if (counter >= 250) + { + //We don't want to check too many files in case the user has a folder with a lot of files open. + //This is a safeguard against performance issues. + break; + } + + var info = new FileInfo(filePath); + if (!info.Exists) + { + return null; //file was deleted + } + + //There's a chance the file could be deleted between the time we check for existence above and when we access .Length and .LastWriteTimeUtc below. + //This is fine as the caller has a try-catch block that catches IOException types. + totalLength += info.Length; + + if (!foundAny || info.LastWriteTimeUtc > latest) + { + latest = info.LastWriteTimeUtc; + foundAny = true; + } + + counter++; + } + + return (latest, totalLength); + } + } } } \ No newline at end of file diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 1094c57..9f4a51f 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -22,7 +22,6 @@ public partial class MainForm : FormBase #region Members private readonly string? fileToLoadOnLaunch = null; - private string? _openFileOrFolderPath; private string? OpenFileOrFolderPath { @@ -57,9 +56,11 @@ private string? OpenFileOrFolderPath } else { - this.Text = string.Format( - File.Exists(this._openFileOrFolderPath) ? Resources.Strings.MainWindowOpenFileTitleFormat : Resources.Strings.MainWindowOpenFolderTitleFormat, - this._openFileOrFolderPath); + if (File.Exists(this._openFileOrFolderPath)) + this.Text = string.Format(Resources.Strings.MainWindowOpenFileTitleFormat, this._openFileOrFolderPath); + else + this.Text = string.Format(Resources.Strings.MainWindowOpenFolderTitleFormat, this._openFileOrFolderPath); + this.changeFieldsMenuStripButton.Enabled = true; this.saveAsToolStripMenuItem.Enabled = true; this.getSQLCreateTableScriptToolStripMenuItem.Enabled = true; @@ -140,6 +141,8 @@ private DataTable? MainDataSource } private IParquetEngine? _openParquetEngine = null; + + private (DateTime LastWriteTimeUtc, long Length)? _originalModifiedInfo; #endregion public MainForm() @@ -315,6 +318,8 @@ void SwapEngines(IParquetEngine newEngine) #else await this.LoadFileToGridviewImpl(this._openParquetEngine); #endif + + this._originalModifiedInfo = null; } private async Task LoadFileToGridviewImpl(IParquetEngine engine) diff --git a/src/ParquetViewer/MainForm.resx b/src/ParquetViewer/MainForm.resx index 29c51ea..a67eafa 100644 --- a/src/ParquetViewer/MainForm.resx +++ b/src/ParquetViewer/MainForm.resx @@ -975,6 +975,9 @@ Select a folder with parquet files + + 943, 17 + True @@ -1221,6 +1224,12 @@ System.Windows.Forms.ToolTip, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + fileIntegrityCheckingTimer + + + System.Windows.Forms.Timer, System.Windows.Forms, Culture=neutral, PublicKeyToken=b77a5c561934e089 + MainForm diff --git a/src/ParquetViewer/Properties/AssemblyInfo.cs b/src/ParquetViewer/Properties/AssemblyInfo.cs index 2789330..e2cd5fb 100644 --- a/src/ParquetViewer/Properties/AssemblyInfo.cs +++ b/src/ParquetViewer/Properties/AssemblyInfo.cs @@ -21,4 +21,4 @@ // Minor Version // Patch Version // Revision -[assembly: AssemblyVersion("4.1.1.0")] \ No newline at end of file +[assembly: AssemblyVersion("4.2.0.0")] \ No newline at end of file diff --git a/src/ParquetViewer/Resources/Strings.Designer.cs b/src/ParquetViewer/Resources/Strings.Designer.cs index 7b8f060..8f11b16 100644 --- a/src/ParquetViewer/Resources/Strings.Designer.cs +++ b/src/ParquetViewer/Resources/Strings.Designer.cs @@ -477,6 +477,24 @@ internal static string MetadataSuccessfullyExportedToFileMessageTitle { } } + /// + /// Looks up a localized string similar to DELETED. + /// + internal static string OpenFileNoLongerExistsTitleSuffix { + get { + return ResourceManager.GetString("OpenFileNoLongerExistsTitleSuffix", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to MODIFIED. + /// + internal static string OpenFileWasModifiedTitleSuffix { + get { + return ResourceManager.GetString("OpenFileWasModifiedTitleSuffix", resourceCulture); + } + } + /// /// Looks up a localized string similar to Privacy policy. /// @@ -648,5 +666,14 @@ internal static string UnsupportedFieldCountTextFormat { return ResourceManager.GetString("UnsupportedFieldCountTextFormat", resourceCulture); } } + + /// + /// Looks up a localized string similar to Wrap Text. + /// + internal static string WordWrapContextMenuItemText { + get { + return ResourceManager.GetString("WordWrapContextMenuItemText", resourceCulture); + } + } } } diff --git a/src/ParquetViewer/Resources/Strings.resx b/src/ParquetViewer/Resources/Strings.resx index ac5f7e7..52f9b00 100644 --- a/src/ParquetViewer/Resources/Strings.resx +++ b/src/ParquetViewer/Resources/Strings.resx @@ -398,4 +398,16 @@ You can always toggle this on/off from the Edit menu. Frozen When the user right-clicks on a column header this is the text shown for freezing the column. Freezing a column makes it so it is always visible no matter how much you scroll horizontally. + + DELETED + We don't lock opened files anymore. So if the source file/folder disappears we will append this suffix to the main form title as a visual indicator for the user that the data source no longer exists. + + + MODIFIED + We don't lock opened files anymore. So if the source file/folder is modified we will append this suffix to the main form title as a visual indicator for the user that the data they are looking at is stale. + + + Wrap Text + Right click context menu item shown when a column has cut off text and the user right clicks the column header. + \ No newline at end of file diff --git a/src/ParquetViewer/Resources/Strings.tr.resx b/src/ParquetViewer/Resources/Strings.tr.resx index 06c955e..1355484 100644 --- a/src/ParquetViewer/Resources/Strings.tr.resx +++ b/src/ParquetViewer/Resources/Strings.tr.resx @@ -336,4 +336,13 @@ Tercihinizi Düzen menüsünden istediğiniz zaman değiştirebilirsiniz. Dondur + + SİLİNDİ + + + DEĞİŞTİRİLDİ + + + Metni Kaydır + \ No newline at end of file