From 060732cbcd9ed41fc2d468e107c0a01c9bd6bacc Mon Sep 17 00:00:00 2001 From: Sal Date: Fri, 10 Jul 2026 15:50:35 -0400 Subject: [PATCH 01/21] add debouncing to filter box in field selection dialog --- src/Directory.Packages.props | 6 +- .../Types/IByteArrayValue.cs | 7 +- .../FieldSelectionDialog.Designer.cs | 11 +- src/ParquetViewer/FieldSelectionDialog.cs | 2 +- src/ParquetViewer/FieldSelectionDialog.resx | 388 +++++++++--------- src/ParquetViewer/Properties/AssemblyInfo.cs | 2 +- 6 files changed, 207 insertions(+), 209 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 72252482..a3c3faa2 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,16 +3,16 @@ true - + - + - + diff --git a/src/ParquetViewer.Engine/Types/IByteArrayValue.cs b/src/ParquetViewer.Engine/Types/IByteArrayValue.cs index 72c51e24..82306df4 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/FieldSelectionDialog.Designer.cs b/src/ParquetViewer/FieldSelectionDialog.Designer.cs index 3d680e13..3dac116b 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 261060ae..7749665c 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 bc8c7662..d14a0285 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/Properties/AssemblyInfo.cs b/src/ParquetViewer/Properties/AssemblyInfo.cs index 27893307..e2cd5fb4 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 From e93da67aa6078406bc035c3f7ce1d0f7c196ff5d Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 11:50:24 -0400 Subject: [PATCH 02/21] don't lock opened files anymore to increase ease of use but also detect if the file is modified or deleted --- .../ParquetEngine.cs | 6 +- src/ParquetViewer/Analytics/AllEvents.cs | 3 +- src/ParquetViewer/MainForm.Designer.cs | 8 ++ src/ParquetViewer/MainForm.EventHandlers.cs | 100 ++++++++++++++++++ src/ParquetViewer/MainForm.cs | 5 + src/ParquetViewer/MainForm.resx | 9 ++ .../Resources/Strings.Designer.cs | 18 ++++ src/ParquetViewer/Resources/Strings.resx | 8 ++ src/ParquetViewer/Resources/Strings.tr.resx | 6 ++ 9 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index 0019770e..32d2425a 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs @@ -96,7 +96,8 @@ public static async Task OpenFileAsync(string parquetFilePath, Ca try { - var parquetReader = await ParquetReader.CreateAsync(parquetFilePath, _defaultParquetOptions, cancellationToken); + var 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, parquetReader); } catch (Exception ex) @@ -120,7 +121,8 @@ public static async Task OpenFolderAsync(string folderPath, Cance try { - var parquetReader = await ParquetReader.CreateAsync(file, _defaultParquetOptions, cancellationToken); + var 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()); diff --git a/src/ParquetViewer/Analytics/AllEvents.cs b/src/ParquetViewer/Analytics/AllEvents.cs index b4f3a93d..d8bc0c90 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/MainForm.Designer.cs b/src/ParquetViewer/MainForm.Designer.cs index f36408b6..3f182dae 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 33dd544c..7481b4c1 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -3,10 +3,12 @@ using ParquetViewer.Exceptions; using ParquetViewer.Helpers; using System; +using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Drawing; using System.Globalization; +using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Windows.Forms; @@ -288,5 +290,103 @@ private void languageToolStripMenuItem_Click(object sender, EventArgs e) AppSettings.UserSelectedCulture = newCultureInfo; UtilityMethods.RestartApplication(); } + + private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) + { + if (this.fileIntegrityCheckingTimer.Tag is not null) + return; //already handled + + if (this.OpenFileOrFolderPath is null) + return; //no file open + + this.fileIntegrityCheckingTimer.Stop(); + try + { + var fileDeletedSuffix = $" ({Resources.Strings.OpenFileNoLongerExistsTitleSuffix})"; + var alreadyHasDeletedSuffix = this.Text.EndsWith(fileDeletedSuffix); + var lastModifiedInfo = TryGetLastModifiedInfo(this.OpenFileOrFolderPath); + if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) + { + //File or folder no longer exists. In this case lets not mark this timer as handled + //and let it keep running in case the file/folder is restored later. + this.Text += fileDeletedSuffix; + } + else if (lastModifiedInfo is not null && alreadyHasDeletedSuffix) + { + this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); + } + + var fileModifiedSuffix = $" ({Resources.Strings.OpenFileWasModifiedTitleSuffix})"; + if (lastModifiedInfo is not null) + { + if (_lastModifiedInfo is null) + { + _lastModifiedInfo = lastModifiedInfo; + + if (this.Text.EndsWith(fileModifiedSuffix)) + this.Text = this.Text.Replace(fileModifiedSuffix, string.Empty); + } + else if (_lastModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) + { + this.Text += fileModifiedSuffix; + } + } + } + finally + { + this.fileIntegrityCheckingTimer.Start(); + } + + /// + /// Returns the last modified date and size of a file, or the most recent last modified + /// date and total combined size of all files in a folder (including subfolders). + /// + (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo(string fileOrFolderPath) + { + if (File.Exists(fileOrFolderPath)) + { + var info = new FileInfo(fileOrFolderPath); + return (info.LastWriteTimeUtc, info.Length); + } + + if (Directory.Exists(fileOrFolderPath)) + { + DateTime latest = Directory.GetCreationTimeUtc(fileOrFolderPath); + long totalLength = 0; + bool foundAny = false; + + foreach (var filePath in EnumerateFilesSafely(fileOrFolderPath)) + { + var info = new FileInfo(filePath); + totalLength += info.Length; + + if (!foundAny || info.LastWriteTimeUtc > latest) + { + latest = info.LastWriteTimeUtc; + foundAny = true; + } + } + + return (latest, totalLength); + } + + return null; //file or folder does not exist; + } + + /// + /// Enumerates files recursively, skipping subfolders that throw + /// UnauthorizedAccessException or similar IO errors instead of failing the whole scan. + /// + static IEnumerable EnumerateFilesSafely(string rootPath) + { + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true + }; + + return Directory.EnumerateFiles(rootPath, "*", options); + } + } } } \ No newline at end of file diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 1094c573..0b7faed3 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -140,6 +140,8 @@ private DataTable? MainDataSource } private IParquetEngine? _openParquetEngine = null; + + private (DateTime LastWriteTimeUtc, long Length)? _lastModifiedInfo; #endregion public MainForm() @@ -315,6 +317,8 @@ void SwapEngines(IParquetEngine newEngine) #else await this.LoadFileToGridviewImpl(this._openParquetEngine); #endif + + this._lastModifiedInfo = null; } private async Task LoadFileToGridviewImpl(IParquetEngine engine) @@ -455,6 +459,7 @@ private async Task OpenNewFileOrFolder(string fileOrFolderPath) if (fieldList is not null) { this.SelectedFields = fieldList; //triggers a file load + this.fileIntegrityCheckingTimer.Tag = null; //reset the timer tag so we start checking for file integrity again AppSettings.OpenedFileCount++; Program.AskUserForFileExtensionAssociation(); } diff --git a/src/ParquetViewer/MainForm.resx b/src/ParquetViewer/MainForm.resx index 29c51ea1..a67eafaa 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/Resources/Strings.Designer.cs b/src/ParquetViewer/Resources/Strings.Designer.cs index 7b8f060c..620bf304 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. /// diff --git a/src/ParquetViewer/Resources/Strings.resx b/src/ParquetViewer/Resources/Strings.resx index ac5f7e77..df9348f7 100644 --- a/src/ParquetViewer/Resources/Strings.resx +++ b/src/ParquetViewer/Resources/Strings.resx @@ -398,4 +398,12 @@ 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. + \ No newline at end of file diff --git a/src/ParquetViewer/Resources/Strings.tr.resx b/src/ParquetViewer/Resources/Strings.tr.resx index 06c955e3..8addd96f 100644 --- a/src/ParquetViewer/Resources/Strings.tr.resx +++ b/src/ParquetViewer/Resources/Strings.tr.resx @@ -336,4 +336,10 @@ Tercihinizi Düzen menüsünden istediğiniz zaman değiştirebilirsiniz. Dondur + + SİLİNDİ + + + DEĞİŞTİRİLMİŞ + \ No newline at end of file From aea5a6dc23a4f3d3b66f9b659d97d2d34137c431 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 11:51:27 -0400 Subject: [PATCH 03/21] mask field names in ParquetException's --- .../ParquetEngine.Processor.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.Processor.cs index 765d20ef..e3527d04 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 From 3b45ae821716c3b90708a216795c9b02d69597d0 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 21:00:34 -0400 Subject: [PATCH 04/21] simplify file integrity check and add ability to toggle word wrapping --- .../ParquetEngine.cs | 2 + .../ParquetEngine.cs | 30 ++++---- src/ParquetViewer.Engine/IParquetEngine.cs | 2 + src/ParquetViewer/Controls/ParquetGridView.cs | 77 ++++++++++++++++++- src/ParquetViewer/MainForm.EventHandlers.cs | 68 +++++++--------- src/ParquetViewer/MainForm.cs | 1 - 6 files changed, 121 insertions(+), 59 deletions(-) diff --git a/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs b/src/ParquetViewer.Engine.DuckDB/ParquetEngine.cs index 5cb3b16d..8b61b1a8 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.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index 32d2425a..aa7a1bc4 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.FirstOrDefault().Reader; 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; @@ -98,7 +98,7 @@ public static async Task OpenFileAsync(string parquetFilePath, Ca { var 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, parquetReader); + return new ParquetEngine(parquetFilePath, (parquetFilePath, parquetReader)); } catch (Exception ex) { @@ -114,7 +114,7 @@ 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(); @@ -125,10 +125,10 @@ public static async Task OpenFolderAsync(string folderPath, Cance 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) { @@ -152,7 +152,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() @@ -161,7 +161,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); } @@ -174,13 +174,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; } } @@ -232,7 +232,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)) @@ -281,5 +281,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 f68987db..6169353e 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/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index cdb0b5d0..e1268c02 100644 --- a/src/ParquetViewer/Controls/ParquetGridView.cs +++ b/src/ParquetViewer/Controls/ParquetGridView.cs @@ -625,6 +625,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 +807,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)); @@ -1204,6 +1205,40 @@ 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("Word Wrap") + { Checked = isWordWrapEnabled }; + + menuItem.Click += (object? _, EventArgs _) => + { + var isWordWrapEnabled = column.DefaultCellStyle.WrapMode == DataGridViewTriState.True; + column.DefaultCellStyle.WrapMode = isWordWrapEnabled ? DataGridViewTriState.False : DataGridViewTriState.True; + }; + + items.Add(menuItem); + } + private void StyleFrozenColumns() { //First reset styles for all column headers @@ -1415,5 +1450,43 @@ private enum FloatDisplayFormat Scientific = 0, Decimal } + + /// + /// Simple tool to guess if text is long enough to be cut off in a cell. + /// + private bool IsCellTextCutOff(int rowIndex, int columnIndex) + { + var cell = this.Rows[rowIndex].Cells[columnIndex]; + 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/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 7481b4c1..82768b9a 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -293,38 +293,38 @@ private void languageToolStripMenuItem_Click(object sender, EventArgs e) private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) { - if (this.fileIntegrityCheckingTimer.Tag is not null) - return; //already handled - if (this.OpenFileOrFolderPath is null) return; //no file open - this.fileIntegrityCheckingTimer.Stop(); + this.fileIntegrityCheckingTimer.Stop(); try { var fileDeletedSuffix = $" ({Resources.Strings.OpenFileNoLongerExistsTitleSuffix})"; + var fileModifiedSuffix = $" ({Resources.Strings.OpenFileWasModifiedTitleSuffix})"; + + if (this._lastModifiedInfo is null) + { + //reset + 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); + } + var alreadyHasDeletedSuffix = this.Text.EndsWith(fileDeletedSuffix); - var lastModifiedInfo = TryGetLastModifiedInfo(this.OpenFileOrFolderPath); + var lastModifiedInfo = TryGetLastModifiedInfo(); if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) { //File or folder no longer exists. In this case lets not mark this timer as handled //and let it keep running in case the file/folder is restored later. this.Text += fileDeletedSuffix; } - else if (lastModifiedInfo is not null && alreadyHasDeletedSuffix) - { - this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); - } - var fileModifiedSuffix = $" ({Resources.Strings.OpenFileWasModifiedTitleSuffix})"; if (lastModifiedInfo is not null) { if (_lastModifiedInfo is null) { _lastModifiedInfo = lastModifiedInfo; - - if (this.Text.EndsWith(fileModifiedSuffix)) - this.Text = this.Text.Replace(fileModifiedSuffix, string.Empty); } else if (_lastModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) { @@ -338,26 +338,25 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) } /// - /// Returns the last modified date and size of a file, or the most recent last modified - /// date and total combined size of all files in a folder (including subfolders). + /// 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. /// - (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo(string fileOrFolderPath) + (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo() { - if (File.Exists(fileOrFolderPath)) - { - var info = new FileInfo(fileOrFolderPath); - return (info.LastWriteTimeUtc, info.Length); - } - - if (Directory.Exists(fileOrFolderPath)) + if (this._openParquetEngine is not null) { - DateTime latest = Directory.GetCreationTimeUtc(fileOrFolderPath); + DateTime latest = Directory.Exists(this.OpenFileOrFolderPath) ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; long totalLength = 0; bool foundAny = false; - - foreach (var filePath in EnumerateFilesSafely(fileOrFolderPath)) + + foreach (var filePath in this._openParquetEngine.GetOpenParquetFilePaths()) { var info = new FileInfo(filePath); + if (!info.Exists) + { + return null; //file was deleted + } + totalLength += info.Length; if (!foundAny || info.LastWriteTimeUtc > latest) @@ -370,22 +369,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) return (latest, totalLength); } - return null; //file or folder does not exist; - } - - /// - /// Enumerates files recursively, skipping subfolders that throw - /// UnauthorizedAccessException or similar IO errors instead of failing the whole scan. - /// - static IEnumerable EnumerateFilesSafely(string rootPath) - { - var options = new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true - }; - - return Directory.EnumerateFiles(rootPath, "*", options); + return null; //no open file; } } } diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 0b7faed3..259dbe27 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -459,7 +459,6 @@ private async Task OpenNewFileOrFolder(string fileOrFolderPath) if (fieldList is not null) { this.SelectedFields = fieldList; //triggers a file load - this.fileIntegrityCheckingTimer.Tag = null; //reset the timer tag so we start checking for file integrity again AppSettings.OpenedFileCount++; Program.AskUserForFileExtensionAssociation(); } From 8cf06191b2f634b8e8cae267efec06d9eb6ec5ba Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 21:17:03 -0400 Subject: [PATCH 05/21] add translations --- src/ParquetViewer/Controls/ParquetGridView.cs | 6 ++++-- src/ParquetViewer/Resources/Strings.Designer.cs | 9 +++++++++ src/ParquetViewer/Resources/Strings.resx | 4 ++++ src/ParquetViewer/Resources/Strings.tr.resx | 5 ++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/ParquetViewer/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index e1268c02..f8b1ca95 100644 --- a/src/ParquetViewer/Controls/ParquetGridView.cs +++ b/src/ParquetViewer/Controls/ParquetGridView.cs @@ -1227,7 +1227,7 @@ private void AddWordWrapOption(ToolStripItemCollection items, int columnIndex) return; } - var menuItem = new ToolStripMenuItem("Word Wrap") + var menuItem = new ToolStripMenuItem(Resources.Strings.WordWrapContextMenuItemText) { Checked = isWordWrapEnabled }; menuItem.Click += (object? _, EventArgs _) => @@ -1457,8 +1457,10 @@ private enum FloatDisplayFormat private bool IsCellTextCutOff(int rowIndex, int columnIndex) { var cell = this.Rows[rowIndex].Cells[columnIndex]; - var text = cell.FormattedValue?.ToString() ?? string.Empty; + 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; diff --git a/src/ParquetViewer/Resources/Strings.Designer.cs b/src/ParquetViewer/Resources/Strings.Designer.cs index 620bf304..8f11b166 100644 --- a/src/ParquetViewer/Resources/Strings.Designer.cs +++ b/src/ParquetViewer/Resources/Strings.Designer.cs @@ -666,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 df9348f7..52f9b007 100644 --- a/src/ParquetViewer/Resources/Strings.resx +++ b/src/ParquetViewer/Resources/Strings.resx @@ -406,4 +406,8 @@ You can always toggle this on/off from the Edit menu. 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 8addd96f..13554847 100644 --- a/src/ParquetViewer/Resources/Strings.tr.resx +++ b/src/ParquetViewer/Resources/Strings.tr.resx @@ -340,6 +340,9 @@ Tercihinizi Düzen menüsünden istediğiniz zaman değiştirebilirsiniz.SİLİNDİ - DEĞİŞTİRİLMİŞ + DEĞİŞTİRİLDİ + + + Metni Kaydır \ No newline at end of file From de3adb42ea48f055a50cfcf46e146b325705f75d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 13 Jul 2026 01:18:13 +0000 Subject: [PATCH 06/21] Auto-generated file update --- .github/ISSUE_TEMPLATE/translation_template.csv | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/translation_template.csv b/.github/ISSUE_TEMPLATE/translation_template.csv index 3c4f560b..51fcd507 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","" From 8c8a8fb1151ad83796a831db945e2819d1cbc67a Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 22:15:45 -0400 Subject: [PATCH 07/21] address pr feedback --- .../ParquetEngine.cs | 2 +- src/ParquetViewer/Controls/ParquetGridView.cs | 18 +++++++++++---- src/ParquetViewer/MainForm.EventHandlers.cs | 22 ++++++++++++++----- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index aa7a1bc4..19f01c25 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs @@ -13,7 +13,7 @@ public partial class ParquetEngine : IParquetEngine, IDisposable private readonly (string ParquetFilePath, ParquetReader Reader)[] _parquetFiles; private long? _recordCount; - private ParquetReader _defaultReader => _parquetFiles.FirstOrDefault().Reader; + private ParquetReader _defaultReader => _parquetFiles.First().Reader; private FileMetaData _thriftMetadata => _defaultReader.Metadata ?? throw new ParquetEngineException("No thrift metadata was found"); diff --git a/src/ParquetViewer/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index f8b1ca95..f168eefc 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) @@ -1232,8 +1233,19 @@ private void AddWordWrapOption(ToolStripItemCollection items, int columnIndex) menuItem.Click += (object? _, EventArgs _) => { - var isWordWrapEnabled = column.DefaultCellStyle.WrapMode == DataGridViewTriState.True; - column.DefaultCellStyle.WrapMode = isWordWrapEnabled ? DataGridViewTriState.False : DataGridViewTriState.True; + var isWordWrapCurrentlyEnabled = column.DefaultCellStyle.WrapMode == DataGridViewTriState.True; + column.DefaultCellStyle.WrapMode = isWordWrapCurrentlyEnabled ? DataGridViewTriState.False : DataGridViewTriState.True; + + var doesAnyColumnHaveWordWrapEnabled = !isWordWrapCurrentlyEnabled || this.Columns.Cast() + .Any(col => col.DefaultCellStyle.WrapMode == DataGridViewTriState.True); + if (doesAnyColumnHaveWordWrapEnabled) + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.DisplayedCells; + } + else + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; + } }; items.Add(menuItem); @@ -1471,8 +1483,6 @@ private bool IsCellTextCutOff(int rowIndex, int columnIndex) /// /// Returns the row indexes for rows that are currently visible /// - /// - /// private IEnumerable GetVisibleRowIndexes() { int firstIndex = this.FirstDisplayedScrollingRowIndex; diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 82768b9a..dbbd76f1 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -293,7 +293,7 @@ private void languageToolStripMenuItem_Click(object sender, EventArgs e) private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) { - if (this.OpenFileOrFolderPath is null) + if (this.OpenFileOrFolderPath is null || this._openParquetEngine is null) return; //no file open this.fileIntegrityCheckingTimer.Stop(); @@ -318,6 +318,11 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) //File or folder no longer exists. In this case lets 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) + { + this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); } if (lastModifiedInfo is not null) @@ -357,12 +362,19 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) return null; //file was deleted } - totalLength += info.Length; + try + { + totalLength += info.Length; - if (!foundAny || info.LastWriteTimeUtc > latest) + if (!foundAny || info.LastWriteTimeUtc > latest) + { + latest = info.LastWriteTimeUtc; + foundAny = true; + } + } + catch (FileNotFoundException) { - latest = info.LastWriteTimeUtc; - foundAny = true; + return null; } } From 05dfc26e977a556de2fae220e5271fc610151db0 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 22:29:49 -0400 Subject: [PATCH 08/21] add ability to play/pause even if audio player is too small via right clicks --- .../Controls/AudioPlayerDataGridViewCell.cs | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs b/src/ParquetViewer/Controls/AudioPlayerDataGridViewCell.cs index 67030b0e..cf36cad7 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(); } } From 0dcc1ee366e20e5a0900314340372b80b0da14ea Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 22:30:06 -0400 Subject: [PATCH 09/21] add analytics for new word wrapping functionality --- src/ParquetViewer/Controls/ParquetGridView.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ParquetViewer/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index f168eefc..703f2ea6 100644 --- a/src/ParquetViewer/Controls/ParquetGridView.cs +++ b/src/ParquetViewer/Controls/ParquetGridView.cs @@ -1150,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; @@ -1166,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; @@ -1234,6 +1234,10 @@ private void AddWordWrapOption(ToolStripItemCollection items, int columnIndex) 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 doesAnyColumnHaveWordWrapEnabled = !isWordWrapCurrentlyEnabled || this.Columns.Cast() From 97caec27ad9131681ef658088082a6858d63c061 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 23:12:08 -0400 Subject: [PATCH 10/21] pr feedback and tiny io efficiency improvement --- .../ParquetEngine.cs | 8 ++++++-- src/ParquetViewer/MainForm.EventHandlers.cs | 4 ++-- src/ParquetViewer/MainForm.cs | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index 19f01c25..51dc8c12 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs @@ -94,14 +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 readOnlyNonLockingStream = new FileStream(parquetFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + 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); } } @@ -119,9 +121,10 @@ public static async Task OpenFolderAsync(string folderPath, Cance { cancellationToken.ThrowIfCancellationRequested(); + Stream? readOnlyNonLockingStream = null; try { - var readOnlyNonLockingStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + 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)) { @@ -132,6 +135,7 @@ public static async Task OpenFolderAsync(string folderPath, Cance } catch (Exception ex) { + readOnlyNonLockingStream?.Dispose(); skippedFiles.Add(System.IO.Path.GetRelativePath(folderPath, file), ex); } } diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index dbbd76f1..d6ba8c26 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -350,7 +350,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) { if (this._openParquetEngine is not null) { - DateTime latest = Directory.Exists(this.OpenFileOrFolderPath) ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; + DateTime latest = this._isDirectoryOpen ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; long totalLength = 0; bool foundAny = false; @@ -372,7 +372,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) foundAny = true; } } - catch (FileNotFoundException) + catch { return null; } diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 259dbe27..8f857f82 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -22,6 +22,7 @@ public partial class MainForm : FormBase #region Members private readonly string? fileToLoadOnLaunch = null; + private bool _isDirectoryOpen = false; private string? _openFileOrFolderPath; private string? OpenFileOrFolderPath @@ -50,6 +51,7 @@ private string? OpenFileOrFolderPath this.mainGridView.ClearQuickPeekForms(); this.mainGridView.ClearColumnFormatOverrides(); this.ResetGetSQLCreateTableScriptToolStripMenuItemToolTipText(); + this._isDirectoryOpen = false; if (string.IsNullOrWhiteSpace(this._openFileOrFolderPath)) { @@ -57,9 +59,15 @@ 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._isDirectoryOpen = true; + } this.changeFieldsMenuStripButton.Enabled = true; this.saveAsToolStripMenuItem.Enabled = true; this.getSQLCreateTableScriptToolStripMenuItem.Enabled = true; @@ -420,7 +428,7 @@ private async Task LoadFileToGridviewImpl(IParquetEngine engine) : FileOpenEvent.ParquetEngineTypeId.DuckDB; FileOpenEvent.FireAndForget( - Directory.Exists(this.OpenFileOrFolderPath), + this._isDirectoryOpen, engine.NumberOfPartitions, engine.RecordCount, engine.Metadata.RowGroups.Count, From a8cf1535f72289e1dfd40a7c155738c6c085dc01 Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 12 Jul 2026 23:35:24 -0400 Subject: [PATCH 11/21] pr feedback --- src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs index 51dc8c12..e22eb8f0 100644 --- a/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs +++ b/src/ParquetViewer.Engine.ParquetNET/ParquetEngine.cs @@ -13,7 +13,7 @@ public partial class ParquetEngine : IParquetEngine, IDisposable private readonly (string ParquetFilePath, ParquetReader Reader)[] _parquetFiles; private long? _recordCount; - private ParquetReader _defaultReader => _parquetFiles.First().Reader; + 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"); From 846ee0dee632957bfef04a38d4f059e9906f6adb Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 00:18:08 -0400 Subject: [PATCH 12/21] use FileSystemWatcher for even more efficiency --- src/ParquetViewer/MainForm.Designer.cs | 2 +- src/ParquetViewer/MainForm.EventHandlers.cs | 4 +++ src/ParquetViewer/MainForm.cs | 40 +++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/ParquetViewer/MainForm.Designer.cs b/src/ParquetViewer/MainForm.Designer.cs index 3f182dae..0cfc88f1 100644 --- a/src/ParquetViewer/MainForm.Designer.cs +++ b/src/ParquetViewer/MainForm.Designer.cs @@ -452,7 +452,7 @@ private void InitializeComponent() // fileIntegrityCheckingTimer // fileIntegrityCheckingTimer.Enabled = true; - fileIntegrityCheckingTimer.Interval = 2500; + fileIntegrityCheckingTimer.Interval = 1000; fileIntegrityCheckingTimer.Tick += fileIntegrityCheckingTimer_Tick; // // MainForm diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index d6ba8c26..39379e7a 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -296,6 +296,10 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) if (this.OpenFileOrFolderPath is null || this._openParquetEngine is null) return; //no file open + if (!this._didFilesChange) + return; + + this._didFilesChange = false; //reset the flag so we don't keep checking until the next change this.fileIntegrityCheckingTimer.Stop(); try { diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 8f857f82..7bff54cc 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -23,6 +23,8 @@ public partial class MainForm : FormBase #region Members private readonly string? fileToLoadOnLaunch = null; private bool _isDirectoryOpen = false; + private FileSystemWatcher? _fileSystemWatcher = null; + private volatile bool _didFilesChange = false; //We update this in a background thread and check it in the UI thread, so we need to mark it volatile to avoid caching issues in the CPU private string? _openFileOrFolderPath; private string? OpenFileOrFolderPath @@ -52,6 +54,9 @@ private string? OpenFileOrFolderPath this.mainGridView.ClearColumnFormatOverrides(); this.ResetGetSQLCreateTableScriptToolStripMenuItemToolTipText(); this._isDirectoryOpen = false; + this._fileSystemWatcher?.DisposeSafely(); + this._fileSystemWatcher = null; + this._didFilesChange = false; if (string.IsNullOrWhiteSpace(this._openFileOrFolderPath)) { @@ -62,12 +67,40 @@ private string? OpenFileOrFolderPath if (File.Exists(this._openFileOrFolderPath)) { this.Text = string.Format(Resources.Strings.MainWindowOpenFileTitleFormat, this._openFileOrFolderPath); + + this._fileSystemWatcher = new FileSystemWatcher + { + Path = Path.GetDirectoryName(this._openFileOrFolderPath) ?? string.Empty, + Filter = Path.GetFileName(this._openFileOrFolderPath), + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + EnableRaisingEvents = true + }; } else { this.Text = string.Format(Resources.Strings.MainWindowOpenFolderTitleFormat, this._openFileOrFolderPath); this._isDirectoryOpen = true; + + this._fileSystemWatcher = new FileSystemWatcher + { + Path = this._openFileOrFolderPath, + IncludeSubdirectories = true, + Filter = "*.parquet", //TODO: We're not handling all the extension cases in Helpers.ListParquetFiles() + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + EnableRaisingEvents = true + }; + } + + this._fileSystemWatcher?.Changed += TriggerFileIntegrityCheck; + this._fileSystemWatcher?.Deleted += TriggerFileIntegrityCheck; + this._fileSystemWatcher?.Renamed += TriggerFileIntegrityCheck; + this._fileSystemWatcher?.Created += TriggerFileIntegrityCheck; + + void TriggerFileIntegrityCheck(object sender, FileSystemEventArgs e) + { + this._didFilesChange = true; } + this.changeFieldsMenuStripButton.Enabled = true; this.saveAsToolStripMenuItem.Enabled = true; this.getSQLCreateTableScriptToolStripMenuItem.Enabled = true; @@ -76,6 +109,13 @@ private string? OpenFileOrFolderPath } } + private enum FileIntegrityEnum + { + None = 0, + Deleted, + Modified + } + private List? selectedFields = null; private List? SelectedFields { From 1fac8eaa33898b623a1430e3aeba8233451b2518 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 00:29:55 -0400 Subject: [PATCH 13/21] pr feedback --- src/ParquetViewer/MainForm.EventHandlers.cs | 21 +++++++++++++-------- src/ParquetViewer/MainForm.cs | 12 +----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 39379e7a..332592e1 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -3,7 +3,6 @@ using ParquetViewer.Exceptions; using ParquetViewer.Helpers; using System; -using System.Collections.Generic; using System.Data; using System.Diagnostics; using System.Drawing; @@ -308,17 +307,14 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) if (this._lastModifiedInfo is null) { - //reset - 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); + ResetTitle(); } var alreadyHasDeletedSuffix = this.Text.EndsWith(fileDeletedSuffix); var lastModifiedInfo = TryGetLastModifiedInfo(); if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) { + ResetTitle(); //File or folder no longer exists. In this case lets not mark this timer as handled //and let it keep running in case the file/folder is restored later. this.Text += fileDeletedSuffix; @@ -326,7 +322,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) } else if (lastModifiedInfo is not null && alreadyHasDeletedSuffix) { - this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); + ResetTitle(); } if (lastModifiedInfo is not null) @@ -337,9 +333,18 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) } else if (_lastModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) { + ResetTitle(); this.Text += fileModifiedSuffix; } } + + 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); + } } finally { @@ -354,7 +359,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) { if (this._openParquetEngine is not null) { - DateTime latest = this._isDirectoryOpen ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; + DateTime latest = Directory.Exists(this.OpenFileOrFolderPath) ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; long totalLength = 0; bool foundAny = false; diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 7bff54cc..79d05270 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 bool _isDirectoryOpen = false; private FileSystemWatcher? _fileSystemWatcher = null; private volatile bool _didFilesChange = false; //We update this in a background thread and check it in the UI thread, so we need to mark it volatile to avoid caching issues in the CPU @@ -53,7 +52,6 @@ private string? OpenFileOrFolderPath this.mainGridView.ClearQuickPeekForms(); this.mainGridView.ClearColumnFormatOverrides(); this.ResetGetSQLCreateTableScriptToolStripMenuItemToolTipText(); - this._isDirectoryOpen = false; this._fileSystemWatcher?.DisposeSafely(); this._fileSystemWatcher = null; this._didFilesChange = false; @@ -79,7 +77,6 @@ private string? OpenFileOrFolderPath else { this.Text = string.Format(Resources.Strings.MainWindowOpenFolderTitleFormat, this._openFileOrFolderPath); - this._isDirectoryOpen = true; this._fileSystemWatcher = new FileSystemWatcher { @@ -109,13 +106,6 @@ void TriggerFileIntegrityCheck(object sender, FileSystemEventArgs e) } } - private enum FileIntegrityEnum - { - None = 0, - Deleted, - Modified - } - private List? selectedFields = null; private List? SelectedFields { @@ -468,7 +458,7 @@ private async Task LoadFileToGridviewImpl(IParquetEngine engine) : FileOpenEvent.ParquetEngineTypeId.DuckDB; FileOpenEvent.FireAndForget( - this._isDirectoryOpen, + Directory.Exists(this.OpenFileOrFolderPath), engine.NumberOfPartitions, engine.RecordCount, engine.Metadata.RowGroups.Count, From 2bbd1d511a036bcd59742899c39e19ee4a3cc13d Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 09:55:07 -0400 Subject: [PATCH 14/21] pr feedback --- src/ParquetViewer/MainForm.EventHandlers.cs | 6 +++--- src/ParquetViewer/MainForm.cs | 13 ++++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 332592e1..3428008b 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -295,8 +295,8 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) if (this.OpenFileOrFolderPath is null || this._openParquetEngine is null) return; //no file open - if (!this._didFilesChange) - return; + if (!this._didFilesChange && this._lastModifiedInfo is not null) + return; //Nothing to do this._didFilesChange = false; //reset the flag so we don't keep checking until the next change this.fileIntegrityCheckingTimer.Stop(); @@ -315,7 +315,7 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) { ResetTitle(); - //File or folder no longer exists. In this case lets not mark this timer as handled + //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; diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 79d05270..3c5a4ce5 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -68,10 +68,9 @@ private string? OpenFileOrFolderPath this._fileSystemWatcher = new FileSystemWatcher { - Path = Path.GetDirectoryName(this._openFileOrFolderPath) ?? string.Empty, + Path = Path.GetDirectoryName(Path.GetFullPath(this._openFileOrFolderPath)) ?? string.Empty, Filter = Path.GetFileName(this._openFileOrFolderPath), NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - EnableRaisingEvents = true }; } else @@ -84,14 +83,14 @@ private string? OpenFileOrFolderPath IncludeSubdirectories = true, Filter = "*.parquet", //TODO: We're not handling all the extension cases in Helpers.ListParquetFiles() NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - EnableRaisingEvents = true }; } - this._fileSystemWatcher?.Changed += TriggerFileIntegrityCheck; - this._fileSystemWatcher?.Deleted += TriggerFileIntegrityCheck; - this._fileSystemWatcher?.Renamed += TriggerFileIntegrityCheck; - this._fileSystemWatcher?.Created += TriggerFileIntegrityCheck; + this._fileSystemWatcher.Changed += TriggerFileIntegrityCheck; + this._fileSystemWatcher.Deleted += TriggerFileIntegrityCheck; + this._fileSystemWatcher.Renamed += TriggerFileIntegrityCheck; + this._fileSystemWatcher.Created += TriggerFileIntegrityCheck; + this._fileSystemWatcher.EnableRaisingEvents = true; void TriggerFileIntegrityCheck(object sender, FileSystemEventArgs e) { From d344e5c18870d15c708ad4d6e995c62724569772 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 10:01:23 -0400 Subject: [PATCH 15/21] add ctrl+r shortcut to allow reloads --- src/ParquetViewer/MainForm.EventHandlers.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 3428008b..71c35572 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -161,6 +161,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) From af07097efd4604831716a24f924b0989a1656e2c Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 10:08:27 -0400 Subject: [PATCH 16/21] pr feedback --- src/ParquetViewer/MainForm.EventHandlers.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 71c35572..9422dc22 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -387,7 +387,8 @@ void ResetTitle() } catch { - return null; + //We can't figure out what happened to the file, so act like nothing happened by returning the last modified info. + return this._lastModifiedInfo; } } From 5715e99aef88833a0e636aaf4ba2969a89651b07 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 13:06:25 -0400 Subject: [PATCH 17/21] remove file system watcher, put file checks in the background thread, and address some PR feedback --- src/ParquetViewer/Controls/ParquetGridView.cs | 17 ++++-- src/ParquetViewer/Helpers/UtilityMethods.cs | 2 +- src/ParquetViewer/MainForm.Designer.cs | 2 +- src/ParquetViewer/MainForm.EventHandlers.cs | 54 +++++++++++++------ src/ParquetViewer/MainForm.cs | 40 +------------- 5 files changed, 56 insertions(+), 59 deletions(-) diff --git a/src/ParquetViewer/Controls/ParquetGridView.cs b/src/ParquetViewer/Controls/ParquetGridView.cs index 703f2ea6..66bd2666 100644 --- a/src/ParquetViewer/Controls/ParquetGridView.cs +++ b/src/ParquetViewer/Controls/ParquetGridView.cs @@ -1240,15 +1240,23 @@ private void AddWordWrapOption(ToolStripItemCollection items, int columnIndex) column.DefaultCellStyle.WrapMode = isWordWrapCurrentlyEnabled ? DataGridViewTriState.False : DataGridViewTriState.True; - var doesAnyColumnHaveWordWrapEnabled = !isWordWrapCurrentlyEnabled || this.Columns.Cast() - .Any(col => col.DefaultCellStyle.WrapMode == DataGridViewTriState.True); - if (doesAnyColumnHaveWordWrapEnabled) + var didWeJustEnableWordWrap = !isWordWrapCurrentlyEnabled; + if (didWeJustEnableWordWrap) { AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.DisplayedCells; } else { - AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; + var doesAnyColumnHaveWordWrapEnabled = this.Columns.Cast() + .Any(col => col.DefaultCellStyle.WrapMode == DataGridViewTriState.True); + if (doesAnyColumnHaveWordWrapEnabled) + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.DisplayedCells; + } + else + { + AutoSizeRowsMode = DataGridViewAutoSizeRowsMode.None; + } } }; @@ -1469,6 +1477,7 @@ private enum FloatDisplayFormat /// /// 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) { diff --git a/src/ParquetViewer/Helpers/UtilityMethods.cs b/src/ParquetViewer/Helpers/UtilityMethods.cs index 07eafaf9..f97096e1 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 0cfc88f1..3f182dae 100644 --- a/src/ParquetViewer/MainForm.Designer.cs +++ b/src/ParquetViewer/MainForm.Designer.cs @@ -452,7 +452,7 @@ private void InitializeComponent() // fileIntegrityCheckingTimer // fileIntegrityCheckingTimer.Enabled = true; - fileIntegrityCheckingTimer.Interval = 1000; + fileIntegrityCheckingTimer.Interval = 2500; fileIntegrityCheckingTimer.Tick += fileIntegrityCheckingTimer_Tick; // // MainForm diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 9422dc22..75ce31a6 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -10,6 +10,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; +using System.Threading.Tasks; using System.Windows.Forms; namespace ParquetViewer @@ -294,28 +295,30 @@ private void languageToolStripMenuItem_Click(object sender, EventArgs e) UtilityMethods.RestartApplication(); } - private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) + /// 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 - if (!this._didFilesChange && this._lastModifiedInfo is not null) - return; //Nothing to do - - this._didFilesChange = false; //reset the flag so we don't keep checking until the next change this.fileIntegrityCheckingTimer.Stop(); try { var fileDeletedSuffix = $" ({Resources.Strings.OpenFileNoLongerExistsTitleSuffix})"; var fileModifiedSuffix = $" ({Resources.Strings.OpenFileWasModifiedTitleSuffix})"; - if (this._lastModifiedInfo is null) + if (this._originalModifiedInfo is null) { ResetTitle(); } var alreadyHasDeletedSuffix = this.Text.EndsWith(fileDeletedSuffix); - var lastModifiedInfo = TryGetLastModifiedInfo(); + + //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 lastModifiedInfo = await Task.Run(TryGetLastModifiedInfo); + if (lastModifiedInfo is null && !alreadyHasDeletedSuffix) { ResetTitle(); @@ -331,11 +334,11 @@ private void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) if (lastModifiedInfo is not null) { - if (_lastModifiedInfo is null) + if (_originalModifiedInfo is null) { - _lastModifiedInfo = lastModifiedInfo; + _originalModifiedInfo = lastModifiedInfo; } - else if (_lastModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) + else if (_originalModifiedInfo != lastModifiedInfo && !this.Text.EndsWith(fileModifiedSuffix)) { ResetTitle(); this.Text += fileModifiedSuffix; @@ -350,15 +353,26 @@ void ResetTitle() this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); } } + catch (IOException) + { + //swallow to not overload the user with error message dialogs + } + catch (UnauthorizedAccessException) + { + //swallow 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 + 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. - /// + //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. (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo() { if (this._openParquetEngine is not null) @@ -366,9 +380,17 @@ void ResetTitle() DateTime latest = Directory.Exists(this.OpenFileOrFolderPath) ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; long totalLength = 0; bool foundAny = false; + var counter = 0; foreach (var filePath in this._openParquetEngine.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) { @@ -388,8 +410,10 @@ void ResetTitle() catch { //We can't figure out what happened to the file, so act like nothing happened by returning the last modified info. - return this._lastModifiedInfo; + return this._originalModifiedInfo; } + + counter++; } return (latest, totalLength); diff --git a/src/ParquetViewer/MainForm.cs b/src/ParquetViewer/MainForm.cs index 3c5a4ce5..9f4a51fd 100644 --- a/src/ParquetViewer/MainForm.cs +++ b/src/ParquetViewer/MainForm.cs @@ -22,9 +22,6 @@ public partial class MainForm : FormBase #region Members private readonly string? fileToLoadOnLaunch = null; - private FileSystemWatcher? _fileSystemWatcher = null; - private volatile bool _didFilesChange = false; //We update this in a background thread and check it in the UI thread, so we need to mark it volatile to avoid caching issues in the CPU - private string? _openFileOrFolderPath; private string? OpenFileOrFolderPath { @@ -52,9 +49,6 @@ private string? OpenFileOrFolderPath this.mainGridView.ClearQuickPeekForms(); this.mainGridView.ClearColumnFormatOverrides(); this.ResetGetSQLCreateTableScriptToolStripMenuItemToolTipText(); - this._fileSystemWatcher?.DisposeSafely(); - this._fileSystemWatcher = null; - this._didFilesChange = false; if (string.IsNullOrWhiteSpace(this._openFileOrFolderPath)) { @@ -63,40 +57,10 @@ private string? OpenFileOrFolderPath else { if (File.Exists(this._openFileOrFolderPath)) - { this.Text = string.Format(Resources.Strings.MainWindowOpenFileTitleFormat, this._openFileOrFolderPath); - - this._fileSystemWatcher = new FileSystemWatcher - { - Path = Path.GetDirectoryName(Path.GetFullPath(this._openFileOrFolderPath)) ?? string.Empty, - Filter = Path.GetFileName(this._openFileOrFolderPath), - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - }; - } else - { this.Text = string.Format(Resources.Strings.MainWindowOpenFolderTitleFormat, this._openFileOrFolderPath); - this._fileSystemWatcher = new FileSystemWatcher - { - Path = this._openFileOrFolderPath, - IncludeSubdirectories = true, - Filter = "*.parquet", //TODO: We're not handling all the extension cases in Helpers.ListParquetFiles() - NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, - }; - } - - this._fileSystemWatcher.Changed += TriggerFileIntegrityCheck; - this._fileSystemWatcher.Deleted += TriggerFileIntegrityCheck; - this._fileSystemWatcher.Renamed += TriggerFileIntegrityCheck; - this._fileSystemWatcher.Created += TriggerFileIntegrityCheck; - this._fileSystemWatcher.EnableRaisingEvents = true; - - void TriggerFileIntegrityCheck(object sender, FileSystemEventArgs e) - { - this._didFilesChange = true; - } - this.changeFieldsMenuStripButton.Enabled = true; this.saveAsToolStripMenuItem.Enabled = true; this.getSQLCreateTableScriptToolStripMenuItem.Enabled = true; @@ -178,7 +142,7 @@ private DataTable? MainDataSource private IParquetEngine? _openParquetEngine = null; - private (DateTime LastWriteTimeUtc, long Length)? _lastModifiedInfo; + private (DateTime LastWriteTimeUtc, long Length)? _originalModifiedInfo; #endregion public MainForm() @@ -355,7 +319,7 @@ void SwapEngines(IParquetEngine newEngine) await this.LoadFileToGridviewImpl(this._openParquetEngine); #endif - this._lastModifiedInfo = null; + this._originalModifiedInfo = null; } private async Task LoadFileToGridviewImpl(IParquetEngine engine) From a75ef2ac80b1cbf8b4bc577829560c6c712d34b3 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 13:34:27 -0400 Subject: [PATCH 18/21] pr feedback --- src/ParquetViewer/MainForm.EventHandlers.cs | 96 +++++++++++---------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 75ce31a6..926cdee8 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; @@ -20,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)) @@ -302,7 +305,7 @@ 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(); + this.fileIntegrityCheckingTimer.Stop(); try { var fileDeletedSuffix = $" ({Resources.Strings.OpenFileNoLongerExistsTitleSuffix})"; @@ -317,7 +320,10 @@ private async void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) //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 lastModifiedInfo = await Task.Run(TryGetLastModifiedInfo); + 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) { @@ -345,6 +351,8 @@ private async void fileIntegrityCheckingTimer_Tick(object sender, EventArgs e) } } + this._failedFileIntegrityCheckCount = 0; + void ResetTitle() { if (this.Text.EndsWith(fileModifiedSuffix)) @@ -353,18 +361,18 @@ void ResetTitle() this.Text = this.Text.Replace(fileDeletedSuffix, string.Empty); } } - catch (IOException) - { - //swallow to not overload the user with error message dialogs - } - catch (UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { - //swallow to not overload the user with error message dialogs + //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 - ExceptionEvent.FireAndForget(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 { @@ -373,53 +381,53 @@ void ResetTitle() //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. - (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo() + static (DateTime LastModifiedUtc, long Length)? TryGetLastModifiedInfo(IParquetEngine engine, string openFileOrFolderPath) { - if (this._openParquetEngine is not null) + if (engine is null) { - DateTime latest = Directory.Exists(this.OpenFileOrFolderPath) ? Directory.GetCreationTimeUtc(this.OpenFileOrFolderPath) : DateTime.MinValue; - long totalLength = 0; - bool foundAny = false; - var counter = 0; + 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 this._openParquetEngine.GetOpenParquetFilePaths()) + foreach (var filePath in engine.GetOpenParquetFilePaths()) + { + if (counter > 250) { - 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; - } + //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 - } + var info = new FileInfo(filePath); + if (!info.Exists) + { + return null; //file was deleted + } - try - { - totalLength += info.Length; + try + { + totalLength += info.Length; - if (!foundAny || info.LastWriteTimeUtc > latest) - { - latest = info.LastWriteTimeUtc; - foundAny = true; - } - } - catch + if (!foundAny || info.LastWriteTimeUtc > latest) { - //We can't figure out what happened to the file, so act like nothing happened by returning the last modified info. - return this._originalModifiedInfo; + latest = info.LastWriteTimeUtc; + foundAny = true; } - - counter++; + } + catch (Exception ex) + { + //Throw an IOException to be caught by the caller's try/catch + throw new FileLoadException($"Failed to get file info for {filePath}.", ex); } - return (latest, totalLength); + counter++; } - return null; //no open file; + return (latest, totalLength); } } } From a812b963801833391a7806237ddfe01b9fd11662 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 13:39:49 -0400 Subject: [PATCH 19/21] final pr feedback --- src/ParquetViewer/MainForm.EventHandlers.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index 926cdee8..faf1d7db 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -395,7 +395,7 @@ void ResetTitle() foreach (var filePath in engine.GetOpenParquetFilePaths()) { - if (counter > 250) + 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. @@ -421,7 +421,7 @@ void ResetTitle() catch (Exception ex) { //Throw an IOException to be caught by the caller's try/catch - throw new FileLoadException($"Failed to get file info for {filePath}.", ex); + throw new IOException($"Failed to get file info for {filePath}.", ex); } counter++; From 314676c9642d836c6fd7e5cea6aaa9d32109db29 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 13:41:10 -0400 Subject: [PATCH 20/21] simplify try-catch --- src/ParquetViewer/MainForm.EventHandlers.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/ParquetViewer/MainForm.EventHandlers.cs b/src/ParquetViewer/MainForm.EventHandlers.cs index faf1d7db..cb0649f2 100644 --- a/src/ParquetViewer/MainForm.EventHandlers.cs +++ b/src/ParquetViewer/MainForm.EventHandlers.cs @@ -408,20 +408,14 @@ void ResetTitle() return null; //file was deleted } - try - { - totalLength += info.Length; + //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; - } - } - catch (Exception ex) + if (!foundAny || info.LastWriteTimeUtc > latest) { - //Throw an IOException to be caught by the caller's try/catch - throw new IOException($"Failed to get file info for {filePath}.", ex); + latest = info.LastWriteTimeUtc; + foundAny = true; } counter++; From ca7b787dce44a9973f94d4a3e206d459676fd603 Mon Sep 17 00:00:00 2001 From: Sal Date: Mon, 13 Jul 2026 15:06:38 -0400 Subject: [PATCH 21/21] Update build-test-publish.yaml --- .github/workflows/build-test-publish.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-test-publish.yaml b/.github/workflows/build-test-publish.yaml index 3b7da2ee..e4f17e66 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