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