Expiriments with AvaloniaEdit and tracking document changes
This commit is contained in:
parent
29a141a971
commit
6325409d25
53 changed files with 643 additions and 627 deletions
51
.ai-guidelines.md
Normal file
51
.ai-guidelines.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# pgLabII AI Assistant Guidelines
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
This is a .NET 8/C# 13 Avalonia cross-platform application for document management.
|
||||||
|
|
||||||
|
### Architecture Overview
|
||||||
|
- **Main Project**: pgLabII (Avalonia UI)
|
||||||
|
- **Platform Projects**: pgLabII.Desktop
|
||||||
|
- **Utility Project**: pgLabII.PgUtils
|
||||||
|
- **Core Components**: DocumentSession, DocumentSessionFactory, LocalDb
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### C# Guidelines
|
||||||
|
- Use C# 13 features and modern .NET patterns
|
||||||
|
- Prefer primary constructors for dependency injection
|
||||||
|
- Use `var` for obvious types, explicit types for clarity
|
||||||
|
- Implement proper async/await patterns for I/O operations
|
||||||
|
- Use nullable reference types consistently
|
||||||
|
- Prefer "target-typed new"
|
||||||
|
- Prefer "list initializer" over new
|
||||||
|
|
||||||
|
### Avalonia-Specific
|
||||||
|
- Follow MVVM pattern strictly
|
||||||
|
- ViewModels should inherit from appropriate base classes
|
||||||
|
- Use ReactiveUI patterns where applicable
|
||||||
|
- Make use of annotations to generate bindable properties
|
||||||
|
- Implement proper data binding
|
||||||
|
- Consider cross-platform UI constraints
|
||||||
|
|
||||||
|
### Project Patterns
|
||||||
|
- Services should use dependency injection
|
||||||
|
- Use the DocumentSession pattern for data operations
|
||||||
|
- Integrate with LocalDb for persistence
|
||||||
|
- Implement proper error handling and logging
|
||||||
|
- Consider performance for document operations
|
||||||
|
- Use FluentResults.Result for expected errors
|
||||||
|
|
||||||
|
### Architecture Rules
|
||||||
|
- Keep platform-specific code in respective platform projects
|
||||||
|
- Shared business logic in main pgLabII project
|
||||||
|
- Utilities in pgLabII.PgUtils
|
||||||
|
- Follow clean architecture principles
|
||||||
|
|
||||||
|
## Code Review Focus Areas
|
||||||
|
1. Memory management for document operations
|
||||||
|
2. Cross-platform compatibility
|
||||||
|
3. Proper async patterns
|
||||||
|
4. Error handling and user feedback
|
||||||
|
5. Performance considerations for large documents
|
||||||
|
6. UI responsiveness
|
||||||
|
|
@ -6,16 +6,19 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Avalonia packages -->
|
<!-- Avalonia packages -->
|
||||||
<!-- Important: keep version in sync! -->
|
<!-- Important: keep version in sync! -->
|
||||||
<PackageVersion Include="Avalonia" Version="11.3.3" />
|
<PackageVersion Include="Avalonia" Version="11.3.4" />
|
||||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="11.3.0" />
|
||||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.4" />
|
||||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.4" />
|
||||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.4" />
|
||||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.4" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="11.3.4" />
|
||||||
<PackageVersion Include="Avalonia.iOS" Version="11.2.1" />
|
<PackageVersion Include="Avalonia.iOS" Version="11.2.1" />
|
||||||
<PackageVersion Include="Avalonia.Browser" Version="11.2.1" />
|
<PackageVersion Include="Avalonia.Browser" Version="11.2.1" />
|
||||||
<PackageVersion Include="Avalonia.Android" Version="11.2.1" />
|
<PackageVersion Include="Avalonia.Android" Version="11.2.1" />
|
||||||
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.3" />
|
<PackageVersion Include="Avalonia.ReactiveUI" Version="11.3.4" />
|
||||||
|
<PackageVersion Include="AvaloniaEdit.TextMate" Version="11.3.0" />
|
||||||
|
<PackageVersion Include="FluentResults" Version="4.0.0" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|
@ -23,19 +26,16 @@
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.8" />
|
||||||
<PackageVersion Include="Npgsql" Version="9.0.3" />
|
<PackageVersion Include="Npgsql" Version="9.0.3" />
|
||||||
|
<PackageVersion Include="OneOf" Version="3.0.271" />
|
||||||
<PackageVersion Include="Pure.DI" Version="2.1.38" />
|
<PackageVersion Include="Pure.DI" Version="2.1.38" />
|
||||||
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="2.3.1" />
|
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="2.3.1" />
|
||||||
<PackageVersion Include="ReactiveUI.SourceGenerators.Analyzers.CodeFixes" Version="2.3.1" />
|
<PackageVersion Include="ReactiveUI.SourceGenerators.Analyzers.CodeFixes" Version="2.3.1" />
|
||||||
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.1" />
|
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.0.1.1" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageVersion>
|
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3">
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageVersion>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
12
ROADMAP.md
Normal file
12
ROADMAP.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# pgLabII development roadmap
|
||||||
|
|
||||||
|
pgLabII is a developer tool for postgresql databases. Its goal is to allow for querying the database and also editing row data.
|
||||||
|
It also is going to give some user friendly views of the database structure.
|
||||||
|
|
||||||
|
## UI overview
|
||||||
|
|
||||||
|
There is a window for managing the configuration data of connections to actual database instances.
|
||||||
|
When a connection is opened a window specific for that database instance is openen. Within this window
|
||||||
|
the user can open many tabs to query the database and inspects it's tables, indexes etc.
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
|
|
@ -1,23 +0,0 @@
|
||||||
using Android.App;
|
|
||||||
using Android.Content.PM;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Android;
|
|
||||||
using Avalonia.ReactiveUI;
|
|
||||||
|
|
||||||
namespace pgLabII.Android;
|
|
||||||
|
|
||||||
[Activity(
|
|
||||||
Label = "pgLabII.Android",
|
|
||||||
Theme = "@style/MyTheme.NoActionBar",
|
|
||||||
Icon = "@drawable/icon",
|
|
||||||
MainLauncher = true,
|
|
||||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
|
||||||
public class MainActivity : AvaloniaMainActivity<App>
|
|
||||||
{
|
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
|
||||||
{
|
|
||||||
return base.CustomizeAppBuilder(builder)
|
|
||||||
.WithInterFont()
|
|
||||||
.UseReactiveUI();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:installLocation="auto">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<application android:label="pgLabII" android:icon="@drawable/Icon" />
|
|
||||||
</manifest>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
Images, layout descriptions, binary blobs and string dictionaries can be included
|
|
||||||
in your application as resource files. Various Android APIs are designed to
|
|
||||||
operate on the resource IDs instead of dealing with images, strings or binary blobs
|
|
||||||
directly.
|
|
||||||
|
|
||||||
For example, a sample Android app that contains a user interface layout (main.axml),
|
|
||||||
an internationalization string table (strings.xml) and some icons (drawable-XXX/icon.png)
|
|
||||||
would keep its resources in the "Resources" directory of the application:
|
|
||||||
|
|
||||||
Resources/
|
|
||||||
drawable/
|
|
||||||
icon.png
|
|
||||||
|
|
||||||
layout/
|
|
||||||
main.axml
|
|
||||||
|
|
||||||
values/
|
|
||||||
strings.xml
|
|
||||||
|
|
||||||
In order to get the build system to recognize Android resources, set the build action to
|
|
||||||
"AndroidResource". The native Android APIs do not operate directly with filenames, but
|
|
||||||
instead operate on resource IDs. When you compile an Android application that uses resources,
|
|
||||||
the build system will package the resources for distribution and generate a class called "R"
|
|
||||||
(this is an Android convention) that contains the tokens for each one of the resources
|
|
||||||
included. For example, for the above Resources layout, this is what the R class would expose:
|
|
||||||
|
|
||||||
public class R {
|
|
||||||
public class drawable {
|
|
||||||
public const int icon = 0x123;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class layout {
|
|
||||||
public const int main = 0x456;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class strings {
|
|
||||||
public const int first_string = 0xabc;
|
|
||||||
public const int second_string = 0xbcd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
You would then use R.drawable.icon to reference the drawable/icon.png file, or R.layout.main
|
|
||||||
to reference the layout/main.axml file, or R.strings.first_string to reference the first
|
|
||||||
string in the dictionary file values/strings.xml.
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<animated-vector
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt">
|
|
||||||
<aapt:attr name="android:drawable">
|
|
||||||
<vector
|
|
||||||
android:name="vector"
|
|
||||||
android:width="128dp"
|
|
||||||
android:height="128dp"
|
|
||||||
android:viewportWidth="128"
|
|
||||||
android:viewportHeight="128">
|
|
||||||
<group
|
|
||||||
android:name="wrapper"
|
|
||||||
android:translateX="21"
|
|
||||||
android:translateY="21">
|
|
||||||
<group android:name="group">
|
|
||||||
<path
|
|
||||||
android:name="path"
|
|
||||||
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
|
|
||||||
android:strokeWidth="1"/>
|
|
||||||
<path
|
|
||||||
android:name="path_1"
|
|
||||||
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:name="path_2"
|
|
||||||
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
|
|
||||||
android:strokeWidth="1"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
</aapt:attr>
|
|
||||||
<target android:name="path">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:duration="1000"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#161c2d"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
<target android:name="path_1">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:duration="1000"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#f9f9fb"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
<target android:name="path_2">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:duration="1000"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#f9f9fb"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
</animated-vector>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
<animated-vector
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt">
|
|
||||||
<aapt:attr name="android:drawable">
|
|
||||||
<vector
|
|
||||||
android:name="vector"
|
|
||||||
android:width="128dp"
|
|
||||||
android:height="128dp"
|
|
||||||
android:viewportWidth="128"
|
|
||||||
android:viewportHeight="128">
|
|
||||||
<group
|
|
||||||
android:name="wrapper"
|
|
||||||
android:translateX="21"
|
|
||||||
android:translateY="21">
|
|
||||||
<group android:name="group">
|
|
||||||
<path
|
|
||||||
android:name="path"
|
|
||||||
android:pathData="M 74.853 85.823 L 75.368 85.823 C 80.735 85.823 85.144 81.803 85.761 76.602 L 85.836 41.76 C 85.225 18.593 66.254 0 42.939 0 C 19.24 0 0.028 19.212 0.028 42.912 C 0.028 66.357 18.831 85.418 42.18 85.823 L 74.853 85.823 Z"
|
|
||||||
android:fillColor="#00ffffff"
|
|
||||||
android:strokeWidth="1"/>
|
|
||||||
<path
|
|
||||||
android:name="path_1"
|
|
||||||
android:pathData="M 43.059 14.614 C 29.551 14.614 18.256 24.082 15.445 36.743 C 18.136 37.498 20.109 39.968 20.109 42.899 C 20.109 45.831 18.136 48.301 15.445 49.055 C 18.256 61.716 29.551 71.184 43.059 71.184 C 47.975 71.184 52.599 69.93 56.628 67.723 L 56.628 70.993 L 71.344 70.993 L 71.344 44.072 C 71.357 43.714 71.344 43.26 71.344 42.899 C 71.344 27.278 58.68 14.614 43.059 14.614 Z M 29.51 42.899 C 29.51 35.416 35.576 29.35 43.059 29.35 C 50.541 29.35 56.607 35.416 56.607 42.899 C 56.607 50.382 50.541 56.448 43.059 56.448 C 35.576 56.448 29.51 50.382 29.51 42.899 Z"
|
|
||||||
android:fillColor="#00ffffff"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:fillType="evenOdd"/>
|
|
||||||
<path
|
|
||||||
android:name="path_2"
|
|
||||||
android:pathData="M 18.105 42.88 C 18.105 45.38 16.078 47.407 13.579 47.407 C 11.079 47.407 9.052 45.38 9.052 42.88 C 9.052 40.381 11.079 38.354 13.579 38.354 C 16.078 38.354 18.105 40.381 18.105 42.88 Z"
|
|
||||||
android:fillColor="#00ffffff"
|
|
||||||
android:strokeWidth="1"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
</aapt:attr>
|
|
||||||
<target android:name="path_2">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:startOffset="100"
|
|
||||||
android:duration="900"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#161c2d"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
<target android:name="path">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:duration="500"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#f9f9fb"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
<target android:name="path_1">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="fillColor"
|
|
||||||
android:startOffset="100"
|
|
||||||
android:duration="900"
|
|
||||||
android:valueFrom="#00ffffff"
|
|
||||||
android:valueTo="#161c2d"
|
|
||||||
android:valueType="colorType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
</animated-vector>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
|
|
||||||
<item>
|
|
||||||
<color android:color="@color/splash_background"/>
|
|
||||||
</item>
|
|
||||||
|
|
||||||
<item android:drawable="@drawable/icon"
|
|
||||||
android:width="120dp"
|
|
||||||
android:height="120dp"
|
|
||||||
android:gravity="center" />
|
|
||||||
|
|
||||||
</layer-list>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="splash_background">#212121</color>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<style name="MyTheme">
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.NoActionBar">
|
|
||||||
<item name="android:windowActionBar">false</item>
|
|
||||||
<item name="android:windowBackground">@null</item>
|
|
||||||
<item name="android:windowNoTitle">true</item>
|
|
||||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/avalonia_anim</item>
|
|
||||||
<item name="android:windowSplashScreenAnimationDuration">1000</item>
|
|
||||||
<item name="postSplashScreenTheme">@style/MyTheme.Main</item>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
<style name="MyTheme.Main"
|
|
||||||
parent ="MyTheme.NoActionBar">
|
|
||||||
<item name="android:windowIsTranslucent">true</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="splash_background">#FFFFFF</color>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<resources>
|
|
||||||
|
|
||||||
<style name="MyTheme">
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style name="MyTheme.NoActionBar" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
|
|
||||||
<item name="android:windowActionBar">false</item>
|
|
||||||
<item name="android:windowBackground">@drawable/splash_screen</item>
|
|
||||||
<item name="android:windowNoTitle">true</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net8.0-android</TargetFramework>
|
|
||||||
<SupportedOSPlatformVersion>21</SupportedOSPlatformVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ApplicationId>com.CompanyName.pgLabII</ApplicationId>
|
|
||||||
<ApplicationVersion>1</ApplicationVersion>
|
|
||||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
|
||||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
|
||||||
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<AndroidResource Include="Icon.png">
|
|
||||||
<Link>Resources\drawable\Icon.png</Link>
|
|
||||||
</AndroidResource>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Avalonia.Android" />
|
|
||||||
<PackageReference Include="Xamarin.AndroidX.Core.SplashScreen" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\pgLabII\pgLabII.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<!--If you are willing to use Windows/MacOS native APIs you will need to create 3 projects.
|
<!--If you are willing to use Windows/MacOS native APIs you will need to create 3 projects.
|
||||||
One for Windows with net8.0-windows TFM, one for MacOS with net8.0-macos and one with net8.0 TFM for Linux.-->
|
One for Windows with net8.0-windows TFM, one for MacOS with net8.0-macos and one with net8.0 TFM for Linux.-->
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
<Platforms>AnyCPU;x64</Platforms>
|
<Platforms>AnyCPU;x64</Platforms>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ public class PqConnectionStringParserTests
|
||||||
tokenizer
|
tokenizer
|
||||||
.AddString(kw)
|
.AddString(kw)
|
||||||
.AddEquals()
|
.AddEquals()
|
||||||
.AddString(val);
|
.AddString(val)
|
||||||
|
.AddEof();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using pgLabII.PgUtils.ConnectionStrings;
|
using pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
using pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
||||||
|
|
||||||
|
|
@ -11,18 +12,18 @@ public class PqConnectionStringTokenizerTests
|
||||||
public void GetKeyword_Success(string input, string expected)
|
public void GetKeyword_Success(string input, string expected)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
|
var result = subject.GetKeyword();
|
||||||
Assert.Equal(expected, subject.GetKeyword());
|
ResultAssert.Success(result, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("=")]
|
[InlineData("=")]
|
||||||
[InlineData("")]
|
[InlineData("")]
|
||||||
[InlineData(" ")]
|
[InlineData(" ")]
|
||||||
public void GetKeyword_Throws(string input)
|
public void GetKeyword_Errors(string input)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
Assert.Throws<PqConnectionStringParserException>(() => subject.GetKeyword());
|
ResultAssert.Failed(subject.GetKeyword());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -36,7 +37,7 @@ public class PqConnectionStringTokenizerTests
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
|
|
||||||
Assert.Equal(expected, subject.Eof);
|
Assert.Equal(expected, subject.IsEof);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -46,7 +47,7 @@ public class PqConnectionStringTokenizerTests
|
||||||
public void ConsumeEquals_Success(string input)
|
public void ConsumeEquals_Success(string input)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
subject.ConsumeEquals();
|
ResultAssert.Success(subject.ConsumeEquals());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -57,7 +58,8 @@ public class PqConnectionStringTokenizerTests
|
||||||
public void ConsumeEquals_Throws(string input)
|
public void ConsumeEquals_Throws(string input)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
Assert.Throws<PqConnectionStringParserException>(() => subject.ConsumeEquals());
|
var result = subject.ConsumeEquals();
|
||||||
|
ResultAssert.Failed(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -69,7 +71,8 @@ public class PqConnectionStringTokenizerTests
|
||||||
public void GetValue_Success(string input, string expected)
|
public void GetValue_Success(string input, string expected)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
Assert.Equal(expected, subject.GetValue());
|
var result = subject.GetValue();
|
||||||
|
ResultAssert.Success(result, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -80,6 +83,7 @@ public class PqConnectionStringTokenizerTests
|
||||||
public void GetValue_Throws(string input)
|
public void GetValue_Throws(string input)
|
||||||
{
|
{
|
||||||
PqConnectionStringTokenizer subject = new(input);
|
PqConnectionStringTokenizer subject = new(input);
|
||||||
Assert.Throws<PqConnectionStringParserException>(() => subject.GetValue());
|
var result = subject.GetValue();
|
||||||
|
ResultAssert.Failed(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
pgLabII.PgUtils.Tests/ConnectionStrings/Util/ResultAssert.cs
Normal file
38
pgLabII.PgUtils.Tests/ConnectionStrings/Util/ResultAssert.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
||||||
|
|
||||||
|
public static class ResultAssert
|
||||||
|
{
|
||||||
|
public static void Success(Result result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Success<T>(Result<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Success<T>(Result<T> result, T expected)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal(expected, result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Success<T>(Result<T> result, Action<T> assert)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
assert(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Failed(Result result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Failed<T>(Result<T> result)
|
||||||
|
{
|
||||||
|
Assert.True(result.IsFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,84 +1,93 @@
|
||||||
using pgLabII.PgUtils.ConnectionStrings;
|
using FluentResults;
|
||||||
|
using pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
using OneOf;
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
namespace pgLabII.PgUtils.Tests.ConnectionStrings;
|
||||||
|
|
||||||
|
using Elem = OneOf<PqToken, string, IError>;
|
||||||
|
|
||||||
internal class UnitTestTokenizer : IPqConnectionStringTokenizer
|
internal class UnitTestTokenizer : IPqConnectionStringTokenizer
|
||||||
{
|
{
|
||||||
private readonly struct Elem(PqToken result, object? output)
|
private readonly List<Elem> _tokens = new();
|
||||||
{
|
|
||||||
public readonly PqToken Result = result;
|
|
||||||
public readonly object? Output = output;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly List<Elem> _tokens = [];
|
|
||||||
private int _position = 0;
|
private int _position = 0;
|
||||||
|
|
||||||
|
public UnitTestTokenizer AddString(string output)
|
||||||
public UnitTestTokenizer AddString(string? output)
|
|
||||||
{
|
{
|
||||||
_tokens.Add(new(PqToken.String, output));
|
_tokens.Add(output);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UnitTestTokenizer AddException(Exception? output)
|
public UnitTestTokenizer AddError(Error error)
|
||||||
{
|
{
|
||||||
_tokens.Add(new(PqToken.Exception, output));
|
_tokens.Add(error);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UnitTestTokenizer AddEquals()
|
public UnitTestTokenizer AddEquals()
|
||||||
{
|
{
|
||||||
_tokens.Add(new(PqToken.Equals, null));
|
_tokens.Add(PqToken.Equals);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void AddEof()
|
||||||
|
{
|
||||||
|
_tokens.Add(PqToken.Eof);
|
||||||
|
}
|
||||||
|
|
||||||
// note we do no whitespace at end tests here
|
// note we do no whitespace at end tests here
|
||||||
public bool Eof => _position >= _tokens.Count;
|
public bool IsEof
|
||||||
|
|
||||||
public string GetKeyword()
|
|
||||||
{
|
{
|
||||||
EnsureNotEof();
|
get
|
||||||
var elem = Consume();
|
{
|
||||||
if (elem.Result == PqToken.String)
|
EnsureNotEol();
|
||||||
return (string)elem.Output!;
|
return _tokens[_position].IsT0 && _tokens[_position].AsT0 == PqToken.Eof;
|
||||||
|
}
|
||||||
throw new Exception("Unexpected call to GetKeyword");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Result<string> GetKeyword()
|
||||||
public void ConsumeEquals()
|
|
||||||
{
|
{
|
||||||
EnsureNotEof();
|
EnsureNotEol();
|
||||||
var elem = Consume();
|
var elem = Consume();
|
||||||
if (elem.Result == PqToken.Equals)
|
return elem.Match<Result<string>>(
|
||||||
return;
|
token => throw new Exception("Unexpected call to GetKeyword"),
|
||||||
|
str => str,
|
||||||
throw new Exception("Unexpected call to ConsumeEquals");
|
error => Result.Fail(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetValue()
|
public Result ConsumeEquals()
|
||||||
{
|
{
|
||||||
EnsureNotEof();
|
EnsureNotEol();
|
||||||
var elem = Consume();
|
var elem = Consume();
|
||||||
if (elem.Result == PqToken.String)
|
|
||||||
return (string)elem.Output!;
|
|
||||||
|
|
||||||
throw new Exception("Unexpected call to GetValue");
|
return elem.Match<Result>(
|
||||||
|
token =>
|
||||||
|
{
|
||||||
|
if (token != PqToken.Equals)
|
||||||
|
throw new Exception("Unexpected call to GetKeyword");
|
||||||
|
return Result.Ok();
|
||||||
|
},
|
||||||
|
str => throw new Exception("Unexpected call to ConsumeEquals"),
|
||||||
|
error => Result.Fail(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result<string> GetValue()
|
||||||
|
{
|
||||||
|
EnsureNotEol();
|
||||||
|
var elem = Consume();
|
||||||
|
return elem.Match<Result<string>>(
|
||||||
|
token => throw new Exception("Unexpected call to GetValue"),
|
||||||
|
str => str,
|
||||||
|
error => Result.Fail(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Elem Consume()
|
private Elem Consume()
|
||||||
{
|
{
|
||||||
var elem = _tokens[_position++];
|
return _tokens[_position++];
|
||||||
if (elem.Result == PqToken.Exception)
|
|
||||||
{
|
|
||||||
throw (Exception)elem.Output!;
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureNotEof()
|
private void EnsureNotEol()
|
||||||
{
|
{
|
||||||
if (Eof)
|
if (_position >= _tokens.Count)
|
||||||
throw new Exception("unexpected eof in test, wrong parser call?");
|
throw new Exception("unexpected end of list in test, wrong parser call?");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,40 @@
|
||||||
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util;
|
||||||
|
|
||||||
public class UnitTestTokenizerTests
|
public class UnitTestTokenizerTests
|
||||||
{
|
{
|
||||||
private readonly UnitTestTokenizer _sut = new();
|
private readonly UnitTestTokenizer _sut = new();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Eof_True()
|
public void IsEof_Throws()
|
||||||
{
|
{
|
||||||
Assert.True(_sut.Eof);
|
Assert.Throws<Exception>(() =>
|
||||||
|
{
|
||||||
|
bool _ = _sut.IsEof;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsEof_True()
|
||||||
|
{
|
||||||
|
_sut.AddEof();
|
||||||
|
Assert.True(_sut.IsEof);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Eof_False()
|
public void Eof_False()
|
||||||
{
|
{
|
||||||
_sut.AddString("a");
|
_sut.AddString("a");
|
||||||
Assert.False(_sut.Eof);
|
Assert.False(_sut.IsEof);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetKeyword_Success()
|
public void GetKeyword_Success()
|
||||||
{
|
{
|
||||||
_sut.AddString("a");
|
_sut.AddString("a");
|
||||||
Assert.Equal("a", _sut.GetKeyword());
|
var result = _sut.GetKeyword();
|
||||||
|
ResultAssert.Success(result, "a");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -34,15 +47,17 @@ public class UnitTestTokenizerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetKeyword_SimulatesException()
|
public void GetKeyword_SimulatesException()
|
||||||
{
|
{
|
||||||
_sut.AddException(new ArgumentNullException());
|
_sut.AddError(new("test"));
|
||||||
Assert.Throws<ArgumentNullException>(() => _sut.GetKeyword());
|
var result = _sut.GetKeyword();
|
||||||
|
ResultAssert.Failed(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetValue_Success()
|
public void GetValue_Success()
|
||||||
{
|
{
|
||||||
_sut.AddString("a");
|
_sut.AddString("a");
|
||||||
Assert.Equal("a", _sut.GetValue());
|
var result = _sut.GetValue();
|
||||||
|
ResultAssert.Success(result, "a");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
@ -55,15 +70,17 @@ public class UnitTestTokenizerTests
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetValue_SimulatesException()
|
public void GetValue_SimulatesException()
|
||||||
{
|
{
|
||||||
_sut.AddException(new ArgumentNullException());
|
_sut.AddError(new("test"));
|
||||||
Assert.Throws<ArgumentNullException>(() => _sut.GetValue());
|
var result = _sut.GetValue();
|
||||||
|
ResultAssert.Failed(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ConsumeEquals_Success()
|
public void ConsumeEquals_Success()
|
||||||
{
|
{
|
||||||
_sut.AddEquals();
|
_sut.AddEquals();
|
||||||
_sut.ConsumeEquals();
|
var result = _sut.ConsumeEquals();
|
||||||
|
ResultAssert.Success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="OneOf" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=acls/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=connectionstrings_005Cutil/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
using FluentResults;
|
||||||
|
|
||||||
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
public interface IPqConnectionStringTokenizer
|
public interface IPqConnectionStringTokenizer
|
||||||
{
|
{
|
||||||
bool Eof { get; }
|
bool IsEof { get; }
|
||||||
string GetKeyword();
|
Result<string> GetKeyword();
|
||||||
void ConsumeEquals();
|
Result ConsumeEquals();
|
||||||
string GetValue();
|
Result<string> GetValue();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings.Pq;
|
|
||||||
|
|
||||||
enum Keyword
|
|
||||||
{
|
|
||||||
Host,
|
|
||||||
HostAddr,
|
|
||||||
Port,
|
|
||||||
DatabaseName,
|
|
||||||
UserName,
|
|
||||||
Password,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using FluentResults;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parser for converting a libpq style connection string into a dictionary of key value pairs
|
||||||
|
/// </summary>
|
||||||
public ref struct PqConnectionStringParser
|
public ref struct PqConnectionStringParser
|
||||||
{
|
{
|
||||||
// Note possible keywords
|
// Note possible keywords
|
||||||
|
|
@ -51,64 +55,38 @@ public ref struct PqConnectionStringParser
|
||||||
).Parse();
|
).Parse();
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly IPqConnectionStringTokenizer tokenizer;
|
private readonly IPqConnectionStringTokenizer _tokenizer;
|
||||||
private readonly Dictionary<string, string> result = new();
|
private readonly Dictionary<string, string> _result = new();
|
||||||
|
|
||||||
public PqConnectionStringParser(IPqConnectionStringTokenizer tokenizer)
|
public PqConnectionStringParser(IPqConnectionStringTokenizer tokenizer)
|
||||||
{
|
{
|
||||||
this.tokenizer = tokenizer;
|
this._tokenizer = tokenizer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IDictionary<string, string> Parse()
|
public IDictionary<string, string> Parse()
|
||||||
{
|
{
|
||||||
result.Clear();
|
_result.Clear();
|
||||||
|
|
||||||
while (!tokenizer.Eof)
|
while (!_tokenizer.IsEof)
|
||||||
ParsePair();
|
ParsePair();
|
||||||
|
|
||||||
return result;
|
return _result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ParsePair()
|
private Result ParsePair()
|
||||||
{
|
{
|
||||||
string kw = tokenizer.GetKeyword();
|
var kwResult = _tokenizer.GetKeyword();
|
||||||
tokenizer.ConsumeEquals();
|
if (kwResult.IsFailed)
|
||||||
string v = tokenizer.GetValue();
|
return kwResult.ToResult();
|
||||||
result.Add(kw, v);
|
var result = _tokenizer.ConsumeEquals();
|
||||||
//switch (kw)
|
if (result.IsFailed)
|
||||||
//{
|
return result;
|
||||||
// case "host":
|
var valResult = _tokenizer.GetValue();
|
||||||
// case "hostaddr":
|
if (valResult.IsFailed)
|
||||||
// result.Host = v.ToString();
|
return valResult.ToResult();
|
||||||
// break;
|
|
||||||
// case "port":
|
_result.Add(kwResult.Value, valResult.Value);
|
||||||
// result.Port = int.Parse(v);
|
return Result.Ok();
|
||||||
// break;
|
|
||||||
// case "dbname":
|
|
||||||
// result.Database = v.ToString();
|
|
||||||
// break;
|
|
||||||
// case "user":
|
|
||||||
// result.Username = v.ToString();
|
|
||||||
// break;
|
|
||||||
// case "password":
|
|
||||||
// result.Password = v.ToString();
|
|
||||||
// break;
|
|
||||||
// case "connect_timeout":
|
|
||||||
// result.Timeout = int.Parse(v);
|
|
||||||
// break;
|
|
||||||
// case "application_name":
|
|
||||||
// result.ApplicationName = v.ToString();
|
|
||||||
// break;
|
|
||||||
// case "options":
|
|
||||||
// result.Options = v.ToString();
|
|
||||||
// break;
|
|
||||||
// case "sslmode":
|
|
||||||
// result.SslMode = ToSslMode(v);
|
|
||||||
// break;
|
|
||||||
// default:
|
|
||||||
// // Todo what do we do with values we do not support/recognize?
|
|
||||||
// break;
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SslMode ToSslMode(ReadOnlySpan<char> v)
|
private SslMode ToSslMode(ReadOnlySpan<char> v)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
|
||||||
|
|
||||||
public class PqConnectionStringParserException : Exception
|
|
||||||
{
|
|
||||||
public PqConnectionStringParserException()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public PqConnectionStringParserException(string? message) : base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public PqConnectionStringParserException(string? message, Exception? innerException) : base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using FluentResults;
|
||||||
using static System.Net.Mime.MediaTypeNames;
|
using static System.Net.Mime.MediaTypeNames;
|
||||||
|
|
||||||
namespace pgLabII.PgUtils.ConnectionStrings;
|
namespace pgLabII.PgUtils.ConnectionStrings;
|
||||||
|
|
@ -8,7 +9,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
private readonly string input;
|
private readonly string input;
|
||||||
private int position = 0;
|
private int position = 0;
|
||||||
|
|
||||||
public bool Eof
|
public bool IsEof
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
|
@ -23,37 +24,38 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
position = 0;
|
position = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetKeyword()
|
public Result<string> GetKeyword()
|
||||||
{
|
{
|
||||||
if (Eof)
|
if (IsEof)
|
||||||
throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}");
|
return Result.Fail($"Unexpected end of file was expecting a keyword at position {position}");
|
||||||
|
|
||||||
return GetString(forKeyword: true);
|
return GetString(forKeyword: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ConsumeEquals()
|
public Result ConsumeEquals()
|
||||||
{
|
{
|
||||||
ConsumeWhitespace();
|
ConsumeWhitespace();
|
||||||
if (position < input.Length && input[position] == '=')
|
if (position < input.Length && input[position] == '=')
|
||||||
{
|
{
|
||||||
position++;
|
position++;
|
||||||
|
return Result.Ok();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
throw new PqConnectionStringParserException($"Was expecting '=' after keyword at position {position}");
|
return Result.Fail($"Was expecting '=' after keyword at position {position}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetValue()
|
public Result<string> GetValue()
|
||||||
{
|
{
|
||||||
if (Eof)
|
if (IsEof)
|
||||||
throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}");
|
return Result.Fail($"Unexpected end of file was expecting a keyword at position {position}");
|
||||||
|
|
||||||
return GetString(forKeyword: false);
|
return GetString(forKeyword: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetString(bool forKeyword)
|
private Result<string> GetString(bool forKeyword)
|
||||||
{
|
{
|
||||||
if (forKeyword && input[position] == '=')
|
if (forKeyword && input[position] == '=')
|
||||||
throw new PqConnectionStringParserException($"Unexpected '=' was expecting keyword at position {position}");
|
return Result.Fail($"Unexpected '=' was expecting keyword at position {position}");
|
||||||
|
|
||||||
if (input[position] == '\'')
|
if (input[position] == '\'')
|
||||||
return ParseQuotedText();
|
return ParseQuotedText();
|
||||||
|
|
@ -75,7 +77,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
return input.Substring(start, position - start);
|
return input.Substring(start, position - start);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ParseQuotedText()
|
private Result<string> ParseQuotedText()
|
||||||
{
|
{
|
||||||
bool escape = false;
|
bool escape = false;
|
||||||
StringBuilder sb = new();
|
StringBuilder sb = new();
|
||||||
|
|
@ -93,7 +95,7 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
escape = false;
|
escape = false;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new PqConnectionStringParserException($"Invalid escape sequence at position {position}");
|
return Result.Fail($"Invalid escape sequence at position {position}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -113,6 +115,6 @@ public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new PqConnectionStringParserException($"Missing end quote on value starting at {start}");
|
return Result.Fail($"Missing end quote on value starting at {start}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="Npgsql" />
|
<PackageReference Include="Npgsql" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
using Foundation;
|
|
||||||
using UIKit;
|
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.iOS;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.ReactiveUI;
|
|
||||||
|
|
||||||
namespace pgLabII.iOS;
|
|
||||||
|
|
||||||
// The UIApplicationDelegate for the application. This class is responsible for launching the
|
|
||||||
// User Interface of the application, as well as listening (and optionally responding) to
|
|
||||||
// application events from iOS.
|
|
||||||
[Register("AppDelegate")]
|
|
||||||
#pragma warning disable CA1711 // Identifiers should not have incorrect suffix
|
|
||||||
public partial class AppDelegate : AvaloniaAppDelegate<App>
|
|
||||||
#pragma warning restore CA1711 // Identifiers should not have incorrect suffix
|
|
||||||
{
|
|
||||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
|
||||||
{
|
|
||||||
return base.CustomizeAppBuilder(builder)
|
|
||||||
.WithInterFont()
|
|
||||||
.UseReactiveUI();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict/>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>pgLabII</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>companyName.pgLabII</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>LSRequiresIPhoneOS</key>
|
|
||||||
<true/>
|
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>13.0</string>
|
|
||||||
<key>UIDeviceFamily</key>
|
|
||||||
<array>
|
|
||||||
<integer>1</integer>
|
|
||||||
<integer>2</integer>
|
|
||||||
</array>
|
|
||||||
<key>UILaunchStoryboardName</key>
|
|
||||||
<string>LaunchScreen</string>
|
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
|
||||||
<array>
|
|
||||||
<string>armv7</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
||||||
<array>
|
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
using UIKit;
|
|
||||||
|
|
||||||
namespace pgLabII.iOS;
|
|
||||||
|
|
||||||
public class Application
|
|
||||||
{
|
|
||||||
// This is the main entry point of the application.
|
|
||||||
static void Main(string[] args)
|
|
||||||
{
|
|
||||||
// if you want to use a different Application Delegate class from "AppDelegate"
|
|
||||||
// you can specify it here.
|
|
||||||
UIApplication.Main(args, null, typeof(AppDelegate));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="6214" systemVersion="14A314h" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES">
|
|
||||||
<dependencies>
|
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="6207" />
|
|
||||||
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1" />
|
|
||||||
</dependencies>
|
|
||||||
<objects>
|
|
||||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" />
|
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder" />
|
|
||||||
<view contentMode="scaleToFill" id="iN0-l3-epB">
|
|
||||||
<rect key="frame" x="0.0" y="0.0" width="480" height="480" />
|
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES" />
|
|
||||||
<subviews>
|
|
||||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" Copyright (c) 2022 " textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines"
|
|
||||||
minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="8ie-xW-0ye">
|
|
||||||
<rect key="frame" x="20" y="439" width="441" height="21" />
|
|
||||||
<fontDescription key="fontDescription" type="system" pointSize="17" />
|
|
||||||
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
|
|
||||||
<nil key="highlightedColor" />
|
|
||||||
</label>
|
|
||||||
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="pgLabII" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines"
|
|
||||||
minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="kId-c2-rCX">
|
|
||||||
<rect key="frame" x="20" y="140" width="441" height="43" />
|
|
||||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="36" />
|
|
||||||
<color key="textColor" cocoaTouchSystemColor="darkTextColor" />
|
|
||||||
<nil key="highlightedColor" />
|
|
||||||
</label>
|
|
||||||
</subviews>
|
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite" />
|
|
||||||
<constraints>
|
|
||||||
<constraint firstItem="kId-c2-rCX" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="bottom" multiplier="1/3" constant="1" id="5cJ-9S-tgC" />
|
|
||||||
<constraint firstAttribute="centerX" secondItem="kId-c2-rCX" secondAttribute="centerX" id="Koa-jz-hwk" />
|
|
||||||
<constraint firstAttribute="bottom" secondItem="8ie-xW-0ye" secondAttribute="bottom" constant="20" id="Kzo-t9-V3l" />
|
|
||||||
<constraint firstItem="8ie-xW-0ye" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="MfP-vx-nX0" />
|
|
||||||
<constraint firstAttribute="centerX" secondItem="8ie-xW-0ye" secondAttribute="centerX" id="ZEH-qu-HZ9" />
|
|
||||||
<constraint firstItem="kId-c2-rCX" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="fvb-Df-36g" />
|
|
||||||
</constraints>
|
|
||||||
<nil key="simulatedStatusBarMetrics" />
|
|
||||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics" />
|
|
||||||
<point key="canvasLocation" x="548" y="455" />
|
|
||||||
</view>
|
|
||||||
</objects>
|
|
||||||
</document>
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net8.0-ios</TargetFramework>
|
|
||||||
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Avalonia.iOS" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\pgLabII\pgLabII.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Project>
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
|
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
|
||||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
</Application>
|
</Application>
|
||||||
13
pgLabII/Contracts/IEditHistoryManager.cs
Normal file
13
pgLabII/Contracts/IEditHistoryManager.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using pgLabII.Model;
|
||||||
|
using pgLabII.Views.Controls;
|
||||||
|
|
||||||
|
namespace pgLabII;
|
||||||
|
|
||||||
|
public interface IEditHistoryManager
|
||||||
|
{
|
||||||
|
void AddEdit(int offset, string insertedText, string removedText);
|
||||||
|
void FlushBuffer();
|
||||||
|
void SaveToDatabase();
|
||||||
|
IReadOnlyList<EditHistoryEntry> GetHistory();
|
||||||
|
}
|
||||||
12
pgLabII/EditHistoryManager/EditBuffer.cs
Normal file
12
pgLabII/EditHistoryManager/EditBuffer.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace pgLabII;
|
||||||
|
|
||||||
|
public class EditBuffer
|
||||||
|
{
|
||||||
|
public int CurrentOffset { get; set; }
|
||||||
|
public StringBuilder InsertedText { get; set; } = new();
|
||||||
|
public StringBuilder RemovedText { get; set; } = new();
|
||||||
|
public DateTime LastEdit { get; set; }
|
||||||
|
}
|
||||||
163
pgLabII/EditHistoryManager/EditHistoryManager.cs
Normal file
163
pgLabII/EditHistoryManager/EditHistoryManager.cs
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Timers;
|
||||||
|
using pgLabII.Infra;
|
||||||
|
using pgLabII.Model;
|
||||||
|
|
||||||
|
namespace pgLabII;
|
||||||
|
|
||||||
|
public class EditHistoryManager : IEditHistoryManager
|
||||||
|
{
|
||||||
|
private readonly LocalDb _db;
|
||||||
|
private readonly Document _document;
|
||||||
|
private readonly List<EditHistoryEntry> _pendingEdits = new();
|
||||||
|
private EditBuffer? _currentBuffer;
|
||||||
|
private const int BufferTimeoutMs = 500;
|
||||||
|
private readonly Timer _idleFlushTimer;
|
||||||
|
private readonly object _sync = new object();
|
||||||
|
|
||||||
|
public EditHistoryManager(LocalDb db, Document document)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_document = document;
|
||||||
|
|
||||||
|
_idleFlushTimer = new Timer(BufferTimeoutMs)
|
||||||
|
{
|
||||||
|
AutoReset = false,
|
||||||
|
Enabled = false
|
||||||
|
};
|
||||||
|
_idleFlushTimer.Elapsed += OnIdleFlushTimerElapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_idleFlushTimer.Elapsed -= OnIdleFlushTimerElapsed;
|
||||||
|
_idleFlushTimer.Dispose();
|
||||||
|
// Final flush on dispose to avoid losing last edits
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
FlushBuffer();
|
||||||
|
if (_pendingEdits.Count > 0)
|
||||||
|
{
|
||||||
|
SaveToDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddEdit(int offset, string insertedText, string removedText)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
bool isSimpleEdit = (insertedText.Length <= 1 && removedText.Length <= 1);
|
||||||
|
|
||||||
|
if (TryCombineWithBuffer(offset, insertedText, removedText, now, isSimpleEdit))
|
||||||
|
{
|
||||||
|
RestartIdleTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushBuffer();
|
||||||
|
CreateNewBuffer(offset, insertedText, removedText, now);
|
||||||
|
RestartIdleTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FlushBuffer()
|
||||||
|
{
|
||||||
|
if (_currentBuffer == null) return;
|
||||||
|
|
||||||
|
var edit = new EditHistoryEntry
|
||||||
|
{
|
||||||
|
DocumentId = _document.Id,
|
||||||
|
Offset = _currentBuffer.CurrentOffset,
|
||||||
|
InsertedText = _currentBuffer.InsertedText.ToString(),
|
||||||
|
RemovedText = _currentBuffer.RemovedText.ToString(),
|
||||||
|
Timestamp = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
_pendingEdits.Add(edit);
|
||||||
|
_currentBuffer = null;
|
||||||
|
|
||||||
|
if (_pendingEdits.Count >= 10)
|
||||||
|
{
|
||||||
|
SaveToDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveToDatabase()
|
||||||
|
{
|
||||||
|
_db.EditHistory.AddRange(_pendingEdits);
|
||||||
|
_db.SaveChanges();
|
||||||
|
_pendingEdits.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<EditHistoryEntry> GetHistory() => _pendingEdits.AsReadOnly();
|
||||||
|
|
||||||
|
private bool TryCombineWithBuffer(int offset, string insertedText, string removedText, DateTime now, bool isSimpleEdit)
|
||||||
|
{
|
||||||
|
// Try to combine with current buffer if it's a simple edit
|
||||||
|
if (_currentBuffer != null &&
|
||||||
|
isSimpleEdit &&
|
||||||
|
(now - _currentBuffer.LastEdit).TotalMilliseconds < BufferTimeoutMs)
|
||||||
|
{
|
||||||
|
// For consecutive inserts
|
||||||
|
if (insertedText.Length == 1 &&
|
||||||
|
offset == _currentBuffer.CurrentOffset + _currentBuffer.InsertedText.Length)
|
||||||
|
{
|
||||||
|
_currentBuffer.InsertedText.Append(insertedText);
|
||||||
|
_currentBuffer.LastEdit = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For consecutive deletes or backspaces
|
||||||
|
if (removedText.Length == 1 &&
|
||||||
|
(offset == _currentBuffer.CurrentOffset - 1 || // Backspace
|
||||||
|
offset == _currentBuffer.CurrentOffset)) // Delete
|
||||||
|
{
|
||||||
|
if (offset < _currentBuffer.CurrentOffset)
|
||||||
|
{
|
||||||
|
_currentBuffer.RemovedText.Insert(0, removedText);
|
||||||
|
_currentBuffer.CurrentOffset = offset;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_currentBuffer.RemovedText.Append(removedText);
|
||||||
|
}
|
||||||
|
_currentBuffer.LastEdit = now;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateNewBuffer(int offset, string insertedText, string removedText, DateTime now)
|
||||||
|
{
|
||||||
|
_currentBuffer = new EditBuffer
|
||||||
|
{
|
||||||
|
CurrentOffset = offset,
|
||||||
|
LastEdit = now
|
||||||
|
};
|
||||||
|
_currentBuffer.InsertedText.Append(insertedText);
|
||||||
|
_currentBuffer.RemovedText.Append(removedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestartIdleTimer()
|
||||||
|
{
|
||||||
|
_idleFlushTimer.Stop();
|
||||||
|
_idleFlushTimer.Interval = BufferTimeoutMs;
|
||||||
|
_idleFlushTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnIdleFlushTimerElapsed(object? sender, ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
// On inactivity, flush any buffered edit and persist remaining pending edits.
|
||||||
|
FlushBuffer();
|
||||||
|
if (_pendingEdits.Count > 0)
|
||||||
|
{
|
||||||
|
SaveToDatabase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
11
pgLabII/EditHistoryManager/EditOperation.cs
Normal file
11
pgLabII/EditHistoryManager/EditOperation.cs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace pgLabII;
|
||||||
|
|
||||||
|
public class EditOperation
|
||||||
|
{
|
||||||
|
public required int Offset { get; set; }
|
||||||
|
public required string InsertedText { get; set; }
|
||||||
|
public required string RemovedText { get; set; }
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,12 @@ using pgLabII.Model;
|
||||||
|
|
||||||
namespace pgLabII.Infra;
|
namespace pgLabII.Infra;
|
||||||
|
|
||||||
internal class LocalDb : DbContext
|
public class LocalDb : DbContext
|
||||||
{
|
{
|
||||||
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
|
public DbSet<ServerConfiguration> ServerConfigurations => Set<ServerConfiguration>();
|
||||||
|
public DbSet<Document> Documents => Set<Document>();
|
||||||
|
public DbSet<EditHistoryEntry> EditHistory => Set<EditHistoryEntry>();
|
||||||
|
|
||||||
public string DbPath { get; }
|
public string DbPath { get; }
|
||||||
|
|
||||||
public LocalDb()
|
public LocalDb()
|
||||||
|
|
@ -26,9 +28,10 @@ internal class LocalDb : DbContext
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
|
new ServerConfigurationEntityConfiguration().Configure(modelBuilder.Entity<ServerConfiguration>());
|
||||||
|
new ServerUserEntityConfiguration().Configure(modelBuilder.Entity<ServerUser>());
|
||||||
|
new DocumentEntityConfiguration().Configure(modelBuilder.Entity<Document>());
|
||||||
|
new EditHistoryEntityConfiguration().Configure(modelBuilder.Entity<EditHistoryEntry>());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||||
|
|
@ -56,3 +59,26 @@ public class ServerUserEntityConfiguration : IEntityTypeConfiguration<ServerUser
|
||||||
b.HasKey(e => e.Id);
|
b.HasKey(e => e.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class DocumentEntityConfiguration : IEntityTypeConfiguration<Document>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<Document> b)
|
||||||
|
{
|
||||||
|
b.HasKey(e => e.Id);
|
||||||
|
b.Property(e => e.OriginalFilename).IsRequired();
|
||||||
|
b.Property(e => e.BaseCopyFilename).IsRequired();
|
||||||
|
b.HasMany(e => e.EditHistory)
|
||||||
|
.WithOne(e => e.Document)
|
||||||
|
.HasForeignKey(e => e.DocumentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditHistoryEntityConfiguration : IEntityTypeConfiguration<EditHistoryEntry>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<EditHistoryEntry> b)
|
||||||
|
{
|
||||||
|
b.HasKey(e => e.Id);
|
||||||
|
b.Property(e => e.Timestamp).IsRequired();
|
||||||
|
b.HasIndex(e => new { e.DocumentId, e.Timestamp});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
15
pgLabII/Model/Document.cs
Normal file
15
pgLabII/Model/Document.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace pgLabII.Model;
|
||||||
|
|
||||||
|
public class Document
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public required string OriginalFilename { get; set; }
|
||||||
|
public required string BaseCopyFilename { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime LastModified { get; set; }
|
||||||
|
|
||||||
|
public List<EditHistoryEntry> EditHistory { get; set; } = [];
|
||||||
|
}
|
||||||
14
pgLabII/Model/EditHistoryEntry.cs
Normal file
14
pgLabII/Model/EditHistoryEntry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace pgLabII.Model;
|
||||||
|
|
||||||
|
public class EditHistoryEntry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int DocumentId { get; set; }
|
||||||
|
public Document Document { get; set; } = null!;
|
||||||
|
public DateTime Timestamp { get; set; }
|
||||||
|
public int Offset { get; set; }
|
||||||
|
public string InsertedText { get; set; } = string.Empty;
|
||||||
|
public string RemovedText { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using pgLabII.Infra;
|
using pgLabII.Infra;
|
||||||
using pgLabII.ViewModels;
|
using pgLabII.ViewModels;
|
||||||
using pgLabII.Views;
|
using pgLabII.Views;
|
||||||
|
using pgLabII.Views.Controls;
|
||||||
|
|
||||||
namespace pgLabII;
|
namespace pgLabII;
|
||||||
|
|
||||||
|
|
@ -13,6 +14,8 @@ internal static class ServiceCollectionExtensions
|
||||||
collection.AddTransient<MainViewModel>();
|
collection.AddTransient<MainViewModel>();
|
||||||
collection.AddTransient<ServerListViewModel>();
|
collection.AddTransient<ServerListViewModel>();
|
||||||
collection.AddScoped<LocalDb>();
|
collection.AddScoped<LocalDb>();
|
||||||
|
collection.AddTransient<CodeEditorView>();
|
||||||
|
collection.AddTransient<IEditHistoryManager, EditHistoryManager>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
6
pgLabII/Services/DocumentSession.cs
Normal file
6
pgLabII/Services/DocumentSession.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace pgLabII.Services;
|
||||||
|
|
||||||
|
public class DocumentSession
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
18
pgLabII/Services/DocumentSessionFactory.cs
Normal file
18
pgLabII/Services/DocumentSessionFactory.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System;
|
||||||
|
using pgLabII.Infra;
|
||||||
|
|
||||||
|
namespace pgLabII.Services;
|
||||||
|
|
||||||
|
public class DocumentSessionFactory(
|
||||||
|
LocalDb localDb)
|
||||||
|
{
|
||||||
|
public DocumentSession CreateNew()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public DocumentSession Open(string path)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
pgLabII/ViewModels/CodeEditorViewModel.cs
Normal file
10
pgLabII/ViewModels/CodeEditorViewModel.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
using AvaloniaEdit.Document;
|
||||||
|
using ReactiveUI.SourceGenerators;
|
||||||
|
|
||||||
|
namespace pgLabII.ViewModels;
|
||||||
|
|
||||||
|
public partial class CodeEditorViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
[Reactive] private TextDocument _document = new();
|
||||||
|
|
||||||
|
}
|
||||||
18
pgLabII/Views/Controls/CodeEditorView.axaml
Normal file
18
pgLabII/Views/Controls/CodeEditorView.axaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
|
xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
|
x:Class="pgLabII.Views.Controls.CodeEditorView">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<StackPanel Orientation="Horizontal" DockPanel.Dock="Top">
|
||||||
|
<Button Name="SaveButton" Content="Save" Click="OnSaveClicked"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Editor -->
|
||||||
|
<avaloniaEdit:TextEditor Name="Editor"
|
||||||
|
ShowLineNumbers="True"
|
||||||
|
/>
|
||||||
|
</DockPanel>
|
||||||
|
</UserControl>
|
||||||
50
pgLabII/Views/Controls/CodeEditorView.axaml.cs
Normal file
50
pgLabII/Views/Controls/CodeEditorView.axaml.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
using System.IO;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using AvaloniaEdit.Document;
|
||||||
|
using AvaloniaEdit.TextMate;
|
||||||
|
using TextMateSharp.Grammars;
|
||||||
|
|
||||||
|
namespace pgLabII.Views.Controls;
|
||||||
|
|
||||||
|
public partial class CodeEditorView : UserControl
|
||||||
|
{
|
||||||
|
private TextMate.Installation? _textMate;
|
||||||
|
private readonly IEditHistoryManager _editHistoryManager = new EditHistoryManager(
|
||||||
|
new(),
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
OriginalFilename = "",
|
||||||
|
BaseCopyFilename = "",
|
||||||
|
});
|
||||||
|
|
||||||
|
public CodeEditorView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnAttachedToVisualTree(e);
|
||||||
|
|
||||||
|
var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
|
||||||
|
_textMate = Editor.InstallTextMate(registryOptions);
|
||||||
|
_textMate.SetGrammar(registryOptions.GetScopeByLanguageId(registryOptions.GetLanguageByExtension(".sql").Id));
|
||||||
|
|
||||||
|
Editor.Document.Changed += DocumentChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void OnSaveClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
// Example save logic
|
||||||
|
File.WriteAllText("final.sql", Editor.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private void DocumentChanged(object? sender, DocumentChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_editHistoryManager.AddEdit(e.Offset, e.InsertedText.Text, e.RemovedText.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ public partial class ServerListView : UserControl
|
||||||
public ServerListView()
|
public ServerListView()
|
||||||
{
|
{
|
||||||
//DataContext = new ServerListViewModel();
|
//DataContext = new ServerListViewModel();
|
||||||
|
|
||||||
|
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
|
xmlns:viewModels="clr-namespace:pgLabII.ViewModels"
|
||||||
|
xmlns:controls="clr-namespace:pgLabII.Views.Controls"
|
||||||
|
xmlns:avaloniaEdit="https://github.com/avaloniaui/avaloniaedit"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="pgLabII.Views.SingleDatabaseWindow"
|
x:Class="pgLabII.Views.SingleDatabaseWindow"
|
||||||
Title="SingleDatabaseWindow"
|
Title="SingleDatabaseWindow"
|
||||||
|
|
@ -45,11 +47,12 @@
|
||||||
|
|
||||||
<TabControl.ContentTemplate>
|
<TabControl.ContentTemplate>
|
||||||
<DataTemplate DataType="viewModels:QueryToolViewModel">
|
<DataTemplate DataType="viewModels:QueryToolViewModel">
|
||||||
<StackPanel Orientation="Vertical">
|
<DockPanel LastChildFill="True">
|
||||||
<TextBox Text="{Binding Query, Mode=TwoWay}" />
|
<TextBox Text="{Binding Query, Mode=TwoWay}" DockPanel.Dock="Top"/>
|
||||||
<Button Command="{Binding EditCommand}">TEST</Button>
|
<Button Command="{Binding EditCommand}" DockPanel.Dock="Top">TEST</Button>
|
||||||
</StackPanel>
|
|
||||||
|
<controls:CodeEditorView />
|
||||||
|
</DockPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</TabControl.ContentTemplate>
|
</TabControl.ContentTemplate>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" />
|
<PackageReference Include="Avalonia" />
|
||||||
|
<PackageReference Include="Avalonia.AvaloniaEdit" />
|
||||||
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
<PackageReference Include="Avalonia.Controls.DataGrid" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" />
|
<PackageReference Include="Avalonia.Themes.Fluent" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" />
|
<PackageReference Include="Avalonia.Fonts.Inter" />
|
||||||
|
|
@ -22,6 +23,8 @@
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" />
|
<PackageReference Include="Avalonia.ReactiveUI" />
|
||||||
|
<PackageReference Include="AvaloniaEdit.TextMate" />
|
||||||
|
<PackageReference Include="FluentResults" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=contracts/@EntryIndexedValue">True</s:Boolean>
|
||||||
|
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=views_005Ccontrols/@EntryIndexedValue">False</s:Boolean></wpf:ResourceDictionary>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue