diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index fee03142083a..14e6e769064e 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -68,6 +68,8 @@ Noto Roboto Segoe +# Interfaces +ISQ # IN URLs diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml b/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml new file mode 100644 index 000000000000..822a94d71161 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml.cs b/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml.cs new file mode 100644 index 000000000000..d34db9b70f25 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Controls/SQLiteControl.xaml.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Globalization; + +using CommunityToolkit.WinUI.UI.Controls; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Data; +using Peek.Common.Helpers; +using Peek.FilePreviewer.Previewers; +using Peek.FilePreviewer.Previewers.SQLitePreviewer; +using Peek.FilePreviewer.Previewers.SQLitePreviewer.Models; + +namespace Peek.FilePreviewer.Controls +{ + public sealed partial class SQLiteControl : UserControl + { + public static readonly DependencyProperty TablesProperty = DependencyProperty.Register( + nameof(Tables), + typeof(ObservableCollection), + typeof(SQLitePreviewer), + new PropertyMetadata(null, OnTablesPropertyChanged)); + + public static readonly DependencyProperty LoadingStateProperty = DependencyProperty.Register( + nameof(LoadingState), + typeof(PreviewState), + typeof(SQLitePreviewer), + new PropertyMetadata(PreviewState.Uninitialized)); + + public static readonly DependencyProperty TableCountProperty = DependencyProperty.Register( + nameof(TableCount), + typeof(string), + typeof(SQLitePreviewer), + new PropertyMetadata(null)); + + private double _lastColumnAutoWidth = double.NaN; + + public ObservableCollection? Tables + { + get => (ObservableCollection?)GetValue(TablesProperty); + set => SetValue(TablesProperty, value); + } + + public PreviewState? LoadingState + { + get => (PreviewState)GetValue(LoadingStateProperty); + set => SetValue(LoadingStateProperty, value); + } + + public string? TableCount + { + get => (string?)GetValue(TableCountProperty); + set => SetValue(TableCountProperty, value); + } + + public SQLiteControl() + { + this.InitializeComponent(); + } + + private static void OnTablesPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + var control = (SQLiteControl)d; + + if (e.OldValue is ObservableCollection oldCollection) + { + oldCollection.CollectionChanged -= control.OnTablesCollectionChanged; + } + + control.TableTreeView.RootNodes.Clear(); + control.ClearDataView(); + + if (e.NewValue is ObservableCollection newCollection) + { + newCollection.CollectionChanged += control.OnTablesCollectionChanged; + foreach (var table in newCollection) + { + control.AddTableNode(table); + } + } + } + + private void OnTablesCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null) + { + foreach (SQLiteTableInfo table in e.NewItems) + { + AddTableNode(table); + } + } + else if (e.Action == NotifyCollectionChangedAction.Reset) + { + TableTreeView.RootNodes.Clear(); + ClearDataView(); + } + } + + private void AddTableNode(SQLiteTableInfo table) + { + var tableNode = new TreeViewNode { Content = table }; + foreach (var col in table.Columns) + { + tableNode.Children.Add(new TreeViewNode { Content = col.DisplayText }); + } + + TableTreeView.RootNodes.Add(tableNode); + } + + private void TableTreeView_ItemInvoked(TreeView sender, TreeViewItemInvokedEventArgs args) + { + if (args.InvokedItem is TreeViewNode { Content: SQLiteTableInfo table }) + { + ShowTableData(table); + } + } + + private void ShowTableData(SQLiteTableInfo table) + { + _lastColumnAutoWidth = double.NaN; + TableDataGrid.Columns.Clear(); + foreach (var col in table.Columns) + { + TableDataGrid.Columns.Add(new DataGridTextColumn + { + Header = col.Name, + Binding = new Binding { Path = new PropertyPath($"[{col.Name}]") }, + IsReadOnly = true, + }); + } + + TableDataGrid.ItemsSource = table.Rows; + + // After columns and rows are set, defer measurement until layout has completed + // so ActualWidth values are valid when we decide whether to stretch the last column. + TableDataGrid.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, AdjustLastColumnWidth); + + RecordCountText.Text = string.Format( + CultureInfo.CurrentCulture, + ResourceLoaderInstance.ResourceLoader.GetString("SQLite_Row_Count"), + table.RowCount); + + RecordCountHeader.Visibility = Visibility.Visible; + TableDataGrid.Visibility = Visibility.Visible; + NoSelectionText.Visibility = Visibility.Collapsed; + } + + private void TableDataGrid_SizeChanged(object sender, SizeChangedEventArgs e) + { + AdjustLastColumnWidth(); + } + + private void AdjustLastColumnWidth() + { + if (TableDataGrid.Columns.Count == 0 || TableDataGrid.ActualWidth <= 0) + { + return; + } + + var lastCol = TableDataGrid.Columns[TableDataGrid.Columns.Count - 1]; + + // Capture the last column's natural auto-width the first time it is measured. + // Once the column is Star-stretched we keep using the stored value so that + // window resizes can correctly revert to Auto when the grid becomes too narrow. + if (!lastCol.Width.IsStar && lastCol.ActualWidth > 0) + { + _lastColumnAutoWidth = lastCol.ActualWidth; + } + + if (double.IsNaN(_lastColumnAutoWidth) || _lastColumnAutoWidth <= 0) + { + return; + } + + double otherColumnsWidth = 0; + for (int i = 0; i < TableDataGrid.Columns.Count - 1; i++) + { + otherColumnsWidth += TableDataGrid.Columns[i].ActualWidth; + } + + if (otherColumnsWidth + _lastColumnAutoWidth < TableDataGrid.ActualWidth) + { + lastCol.Width = new DataGridLength(1, DataGridLengthUnitType.Star); + } + else + { + lastCol.Width = new DataGridLength(1, DataGridLengthUnitType.Auto); + } + } + + private void ClearDataView() + { + _lastColumnAutoWidth = double.NaN; + TableDataGrid.Columns.Clear(); + TableDataGrid.ItemsSource = null; + RecordCountHeader.Visibility = Visibility.Collapsed; + TableDataGrid.Visibility = Visibility.Collapsed; + NoSelectionText.Visibility = Visibility.Visible; + } + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml index e7f6db165278..463dc9d08ab0 100644 --- a/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml +++ b/src/modules/peek/Peek.FilePreviewer/FilePreview.xaml @@ -101,6 +101,13 @@ Source="{x:Bind ArchivePreviewer.Tree, Mode=OneWay}" Visibility="{x:Bind IsPreviewVisible(ArchivePreviewer, Previewer.State), Mode=OneWay}" /> + + Previewer as IArchivePreviewer; + public ISQLitePreviewer? SQLitePreviewer => Previewer as ISQLitePreviewer; + public IShellPreviewHandlerPreviewer? ShellPreviewHandlerPreviewer => Previewer as IShellPreviewHandlerPreviewer; public IDrivePreviewer? DrivePreviewer => Previewer as IDrivePreviewer; @@ -198,6 +201,7 @@ private async Task OnItemPropertyChanged() AudioPreview.Visibility = Visibility.Collapsed; BrowserPreview.Visibility = Visibility.Collapsed; ArchivePreview.Visibility = Visibility.Collapsed; + SQLitePreview.Visibility = Visibility.Collapsed; DrivePreview.Visibility = Visibility.Collapsed; UnsupportedFilePreview.Visibility = Visibility.Collapsed; @@ -265,6 +269,7 @@ partial void OnPreviewerChanging(IPreviewer? value) ImagePreview.Source = null; ArchivePreview.Source = null; BrowserPreview.Source = null; + SQLitePreview.Tables = null; DrivePreview.Source = null; ShellPreviewHandlerPreviewer?.Clear(); diff --git a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj index 6d58478be3ee..0201a573d431 100644 --- a/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj +++ b/src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj @@ -19,6 +19,7 @@ + @@ -30,6 +31,8 @@ + + @@ -92,6 +95,9 @@ MSBuild:Compile + + MSBuild:Compile + MSBuild:Compile diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISQLitePreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISQLitePreviewer.cs new file mode 100644 index 000000000000..1061a231b70c --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/Interfaces/ISQLitePreviewer.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.ObjectModel; + +using Peek.FilePreviewer.Previewers.SQLitePreviewer.Models; + +namespace Peek.FilePreviewer.Previewers.Interfaces +{ + public interface ISQLitePreviewer : IPreviewer, IPreviewTarget, IDisposable + { + ObservableCollection Tables { get; } + + string? TableCountText { get; } + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs index cce79a6235e5..08c4178e7ec6 100644 --- a/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/PreviewerFactory.cs @@ -11,6 +11,7 @@ using Peek.FilePreviewer.Previewers.Drive; using Peek.FilePreviewer.Previewers.MediaPreviewer; using Peek.UI.Telemetry.Events; +using SQLiteNS = Peek.FilePreviewer.Previewers.SQLitePreviewer; namespace Peek.FilePreviewer.Previewers { @@ -41,6 +42,10 @@ public IPreviewer Create(IFileSystemItem item) { return new WebBrowserPreviewer(item, _previewSettings); } + else if (SQLiteNS.SQLitePreviewer.IsItemSupported(item)) + { + return new SQLiteNS.SQLitePreviewer(item); + } else if (ArchivePreviewer.IsItemSupported(item)) { return new ArchivePreviewer(item); diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteColumnInfo.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteColumnInfo.cs new file mode 100644 index 000000000000..dd5651ec9962 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteColumnInfo.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text; + +namespace Peek.FilePreviewer.Previewers.SQLitePreviewer.Models +{ + public class SQLiteColumnInfo + { + public string Name { get; set; } = string.Empty; + + public string Type { get; set; } = string.Empty; + + public bool IsPrimaryKey { get; set; } + + public bool IsNotNull { get; set; } + + public string DisplayText + { + get + { + var sb = new StringBuilder(Name); + if (!string.IsNullOrEmpty(Type)) + { + sb.Append(' '); + sb.Append(Type); + } + + if (IsPrimaryKey) + { + sb.Append(" (PK)"); + } + else if (IsNotNull) + { + sb.Append(" NOT NULL"); + } + + return sb.ToString(); + } + } + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteTableInfo.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteTableInfo.cs new file mode 100644 index 000000000000..87e44293345f --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/Models/SQLiteTableInfo.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Peek.FilePreviewer.Previewers.SQLitePreviewer.Models +{ + public class SQLiteTableInfo + { + public string Name { get; set; } = string.Empty; + + public List Columns { get; set; } = new(); + + public long RowCount { get; set; } + + public List> Rows { get; set; } = new(); + + public override string ToString() => Name; + } +} diff --git a/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/SQLitePreviewer.cs b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/SQLitePreviewer.cs new file mode 100644 index 000000000000..560c79896599 --- /dev/null +++ b/src/modules/peek/Peek.FilePreviewer/Previewers/SQLitePreviewer/SQLitePreviewer.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.Data.Sqlite; +using Microsoft.UI.Dispatching; +using Peek.Common.Extensions; +using Peek.Common.Helpers; +using Peek.Common.Models; +using Peek.FilePreviewer.Models; +using Peek.FilePreviewer.Previewers.Interfaces; +using Peek.FilePreviewer.Previewers.SQLitePreviewer.Models; +using Windows.Foundation; + +namespace Peek.FilePreviewer.Previewers.SQLitePreviewer +{ + public partial class SQLitePreviewer : ObservableObject, ISQLitePreviewer + { + [ObservableProperty] + private PreviewState _state; + + [ObservableProperty] + private string? _tableCountText; + + public ObservableCollection Tables { get; } = new(); + + private IFileSystemItem Item { get; } + + private DispatcherQueue Dispatcher { get; } + + private static readonly HashSet _supportedFileTypes = new(StringComparer.OrdinalIgnoreCase) + { + ".db", ".sqlite", ".sqlite3", + }; + + public SQLitePreviewer(IFileSystemItem file) + { + Item = file; + Dispatcher = DispatcherQueue.GetForCurrentThread(); + } + + public static bool IsItemSupported(IFileSystemItem item) + { + return _supportedFileTypes.Contains(item.Extension); + } + + public Task GetPreviewSizeAsync(CancellationToken cancellationToken) + { + var size = new Size(800, 500); + return Task.FromResult(new PreviewSize { MonitorSize = size, UseEffectivePixels = true }); + } + + public async Task LoadPreviewAsync(CancellationToken cancellationToken) + { + State = PreviewState.Loading; + + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = Item.Path, + Mode = SqliteOpenMode.ReadOnly, + }.ToString(); + + using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken); + + var tableNames = new List(); + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name;"; + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tableNames.Add(reader.GetString(0)); + } + } + + foreach (var tableName in tableNames) + { + cancellationToken.ThrowIfCancellationRequested(); + + var tableInfo = new SQLiteTableInfo { Name = tableName }; + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"PRAGMA table_info([{tableName}]);"; + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tableInfo.Columns.Add(new SQLiteColumnInfo + { + Name = reader.GetString(1), + Type = reader.IsDBNull(2) ? string.Empty : reader.GetString(2), + IsNotNull = reader.GetInt32(3) == 1, + IsPrimaryKey = reader.GetInt32(5) > 0, + }); + } + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"SELECT COUNT(*) FROM [{tableName}];"; + tableInfo.RowCount = (long)(await cmd.ExecuteScalarAsync(cancellationToken) ?? 0L); + } + + using (var cmd = connection.CreateCommand()) + { + cmd.CommandText = $"SELECT * FROM [{tableName}] LIMIT 200;"; + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + var row = new Dictionary(reader.FieldCount, StringComparer.Ordinal); + for (int i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i)?.ToString(); + } + + tableInfo.Rows.Add(row); + } + } + + await Dispatcher.RunOnUiThread(() => Tables.Add(tableInfo)); + } + + TableCountText = string.Format( + CultureInfo.CurrentCulture, + ResourceLoaderInstance.ResourceLoader.GetString("SQLite_Table_Count"), + tableNames.Count); + + State = PreviewState.Loaded; + } + + public async Task CopyAsync() + { + await Dispatcher.RunOnUiThread(async () => + { + var storageItem = await Item.GetStorageItemAsync(); + ClipboardHelper.SaveToClipboard(storageItem); + }); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + } +} diff --git a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw index f3dbc0f54d3b..4c1891c342ce 100644 --- a/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw +++ b/src/modules/peek/Peek.UI/Strings/en-us/Resources.resw @@ -390,4 +390,16 @@ Search in Microsoft Store + + {0} tables + {0} is the number of tables in the SQLite database + + + Records: {0} + {0} is the number of rows in the selected SQLite table + + + Select a table to view its data + Placeholder shown in the SQLite previewer when no table is selected + \ No newline at end of file