Базовая работа на Linux

This commit is contained in:
2026-06-04 19:24:08 +03:00
parent 72a5392a73
commit e07fc408eb
37 changed files with 1632 additions and 80 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
bin/
obj/
.idea/
.vs/
*.user
*.suo
logs/
_mockup-page*.png

21
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Avalonia (Debug)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-avalonia",
"program": "${workspaceFolder}/src/Sms.TaskTwo.Avalonia/bin/Debug/net8.0/Sms.TaskTwo.Avalonia.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Sms.TaskTwo.Avalonia/bin/Debug/net8.0",
"console": "integratedTerminal",
"stopAtEntry": false
},
{
"name": "Attach",
"type": "coreclr",
"request": "attach"
}
]
}

83
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,83 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "restore",
"command": "dotnet",
"type": "process",
"args": [
"restore",
"${workspaceFolder}/Sms.TaskTwo.slnx"
],
"problemMatcher": "$msCompile"
},
{
"label": "build-solution",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/Sms.TaskTwo.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"group": "build",
"problemMatcher": "$msCompile",
"dependsOn": "restore"
},
{
"label": "build-avalonia",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": "$msCompile"
},
{
"label": "run-avalonia",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj",
"--no-build"
],
"group": "none",
"problemMatcher": "$msCompile",
"dependsOn": "build-avalonia"
},
{
"label": "run-avalonia (watch)",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj"
],
"group": "none",
"isBackground": true,
"problemMatcher": "$msCompile"
},
{
"label": "clean",
"command": "dotnet",
"type": "process",
"args": [
"clean",
"${workspaceFolder}/Sms.TaskTwo.slnx"
],
"problemMatcher": "$msCompile"
}
]
}

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# SMS Task Two — редактор переменных среды
Десктопное приложение на **Avalonia** (.NET 8) для чтения и изменения пользовательских переменных среды. Ядро, ViewModels и модуль окружения вынесены в переносимые проекты для последующего порта на **WPF**.
## Solution
Файл решения: [`Sms.TaskTwo.slnx`](Sms.TaskTwo.slnx) (формат XML solution).
## Структура проектов
| Проект | Назначение |
|--------|------------|
| `Sms.Environment` | Интерфейс `IEnvironmentVariableStore` |
| `Sms.Environment.Windows` | User env через реестр `HKCU\Environment` |
| `Sms.Environment.Linux` | `~/.config/environment.d/99-sms-task-two.conf` |
| `Sms.TaskTwo.Core` | Конфигурация, сервис, логирование |
| `Sms.TaskTwo.ViewModels` | MVVM (`CommunityToolkit.Mvvm`) |
| `Sms.TaskTwo.Avalonia` | UI-хост |
## Сборка и запуск
```bash
dotnet restore Sms.TaskTwo.slnx
dotnet build Sms.TaskTwo.slnx
dotnet run --project src/Sms.TaskTwo.Avalonia/Sms.TaskTwo.Avalonia.csproj
```
## Конфигурация
[`src/Sms.TaskTwo.Avalonia/appsettings.json`](src/Sms.TaskTwo.Avalonia/appsettings.json):
- `EnvironmentVariables:Names` — массив имён переменных (обязательно по ТЗ).
- `EnvironmentVariables:CommentsVariableName` — служебная переменная с JSON-комментариями (`имя → текст`).
- `EnvironmentVariables:Defaults` — значения по умолчанию, если переменная ещё не существует в ОС.
- `Logging:LogDirectory` — каталог логов (по умолчанию `logs`).
## Комментарии
Комментарии к полям хранятся в одной переменной среды (например `SMS_TASK_TWO_COMMENTS`) в формате JSON:
```json
{"SMS_MEAL_SERVER_URL":"URL сервера","SMS_MEAL_API_KEY":"ключ API"}
```
Переменная записывается тем же механизмом, что и остальные, и доступна другим процессам после применения окружения ОС.
## Логирование
Класс `Sms.TaskTwo.Core.Logging.ConsoleLog` — дублирует записи в консоль и файл.
По умолчанию для GUI: `logs/test-sms-wpf-app-yyyyMMdd.log`. При вызове `ConsoleLog.Open()` без имени — `test-sms-console-app-yyyyMMdd_HHmmss.log`.
Пример строки:
```
2026-06-04 18:30:01 [INFO] Changed value: SMS_MEAL_SERVER_URL=https://example.com (previous: <none>)
```
## UI
- **Добавить** — создаёт пользовательскую переменную (имя + значение); список имён хранится в env-переменной `SMS_TASK_TWO_CUSTOM_VARS` (JSON-массив). Пользовательские строки подсвечиваются жёлтым, идут после переменных из конфига. **Удалить** у пользовательской переменной убирает её из списка и из user store.
- При старте переменные из `appsettings.json`, отсутствующие в пользовательском хранилище, **создаются автоматически** (со значением из `Defaults` или пустой строкой).
- Строки из конфига **всегда вверху** таблицы (в порядке из `Names`), остальные — ниже по алфавиту.
- Изменение одной строки обновляет только её ViewModel (без пересборки всей таблицы); при переключении «все переменные» строки добавляются/удаляются/переставляются инкрементально.
- **Отображать все переменные** — показывает все переменные процесса; значения можно записать в пользовательское хранилище (реестр / `environment.d`).
- Строки из `appsettings.json` подсвечиваются голубым (`#E3F2FD`).
- Переменные в пользовательском хранилище помечаются **USER** и зелёной полосой; кнопка **Удалить** снимает их из HKCU / `environment.d`.
- Совпадение обоих признаков — фон `#C8E6C9`.
## Платформы
### Windows
- Чтение/запись: `EnvironmentVariableTarget.User` (реестр `HKEY_CURRENT_USER\Environment`).
- После записи отправляется `WM_SETTINGCHANGE`, чтобы обновить env в уже запущенных GUI-приложениях.
### Linux
- Запись в `~/.config/environment.d/99-sms-task-two.conf` (формат systemd `KEY=value`, значения с пробелами в кавычках).
- Чтение: merge всех `*.conf` в `environment.d`, затем fallback на env текущего процесса.
- Для новых login-сессий может потребоваться перелогин или `systemctl --user import-environment` — ограничение systemd, не ошибка приложения.
## Предположения (ТЗ)
1. Колонка «Поле» — только имена из `appsettings.json`, без добавления новых строк вручную.
2. Значения по умолчанию показываются в UI, в ОС записываются при первом изменении пользователем.
3. Pixel-perfect вёрстка не требуется; элементы стилизованы по макету (заголовок, DataGrid, кнопки «−» / «×»).
## Порт на WPF
Создать `Sms.TaskTwo.Wpf`, подключить `Sms.TaskTwo.Core`, `Sms.TaskTwo.ViewModels`, зарегистрировать `IEnvironmentVariableStore` так же, как в `App.axaml.cs` Avalonia-проекта.

View File

@@ -1,6 +0,0 @@
namespace Sms.Environment.Linux;
public class Class1
{
}

View File

@@ -0,0 +1,172 @@
using Sms.Environment;
namespace Sms.Environment.Linux;
public sealed class LinuxEnvironmentVariableStore : IEnvironmentVariableStore
{
private const string ManagedFileName = "99-sms-task-two.conf";
private readonly string _managedFilePath;
private readonly string _environmentDirectory;
public LinuxEnvironmentVariableStore()
: this(
Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
".config",
"environment.d"),
ManagedFileName)
{
}
internal LinuxEnvironmentVariableStore(string environmentDirectory, string managedFileName)
{
_environmentDirectory = environmentDirectory;
_managedFilePath = Path.Combine(environmentDirectory, managedFileName);
}
public string? Get(string name)
{
var fromFiles = LoadMergedFromDirectory();
if (fromFiles.TryGetValue(name, out var fileValue))
{
return fileValue;
}
return System.Environment.GetEnvironmentVariable(name);
}
public void Set(string name, string value)
{
try
{
Directory.CreateDirectory(_environmentDirectory);
var managed = LoadManagedFile();
managed[name] = value;
WriteManagedFileAtomic(managed);
System.Environment.SetEnvironmentVariable(name, value);
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
throw new EnvironmentVariableStoreException(
$"Failed to write environment variable '{name}' to '{_managedFilePath}'.",
ex);
}
}
public bool Exists(string name) => Get(name) is not null;
public IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names)
{
var merged = LoadMergedFromDirectory();
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var name in names)
{
if (merged.TryGetValue(name, out var value))
{
result[name] = value;
}
else
{
var processValue = System.Environment.GetEnvironmentVariable(name);
if (processValue is not null)
{
result[name] = processValue;
}
}
}
return result;
}
public IReadOnlyDictionary<string, string> GetProcessEnvironment() =>
ToDictionary(System.Environment.GetEnvironmentVariables());
public IReadOnlyDictionary<string, string> GetUserPersistedEnvironment() =>
LoadMergedFromDirectory();
public bool IsPersistedInUserStore(string name) =>
LoadMergedFromDirectory().ContainsKey(name);
public void RemoveFromUserStore(string name)
{
try
{
if (!Directory.Exists(_environmentDirectory))
{
return;
}
foreach (var file in Directory.EnumerateFiles(_environmentDirectory, "*.conf"))
{
var variables = SystemdEnvironmentFileParser.Parse(File.ReadAllText(file));
if (!variables.Remove(name))
{
continue;
}
WriteConfFileAtomic(file, variables);
}
System.Environment.SetEnvironmentVariable(name, null);
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
throw new EnvironmentVariableStoreException(
$"Failed to remove environment variable '{name}' from '{_environmentDirectory}'.",
ex);
}
}
private Dictionary<string, string> LoadManagedFile()
{
if (!File.Exists(_managedFilePath))
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
return SystemdEnvironmentFileParser.Parse(File.ReadAllText(_managedFilePath));
}
private Dictionary<string, string> LoadMergedFromDirectory()
{
var merged = new Dictionary<string, string>(StringComparer.Ordinal);
if (!Directory.Exists(_environmentDirectory))
{
return merged;
}
foreach (var file in Directory.EnumerateFiles(_environmentDirectory, "*.conf").Order(StringComparer.Ordinal))
{
var parsed = SystemdEnvironmentFileParser.Parse(File.ReadAllText(file));
foreach (var pair in parsed)
{
merged[pair.Key] = pair.Value;
}
}
return merged;
}
private void WriteManagedFileAtomic(Dictionary<string, string> variables) =>
WriteConfFileAtomic(_managedFilePath, variables);
private static void WriteConfFileAtomic(string path, Dictionary<string, string> variables)
{
var content = SystemdEnvironmentFileParser.Serialize(variables);
var tempPath = path + ".tmp";
File.WriteAllText(tempPath, content);
File.Move(tempPath, path, overwrite: true);
}
private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string key in source.Keys)
{
result[key] = source[key]?.ToString() ?? string.Empty;
}
return result;
}
}

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,93 @@
using System.Text;
namespace Sms.Environment.Linux;
internal static class SystemdEnvironmentFileParser
{
public static Dictionary<string, string> Parse(string content)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(content))
{
return result;
}
foreach (var rawLine in content.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
var line = rawLine.Trim();
if (line.Length == 0 || line.StartsWith('#'))
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = Unquote(line[(separatorIndex + 1)..].Trim());
result[key] = value;
}
return result;
}
public static string Serialize(IReadOnlyDictionary<string, string> variables)
{
var builder = new StringBuilder();
foreach (var pair in variables.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
builder.Append(pair.Key);
builder.Append('=');
builder.Append(QuoteIfNeeded(pair.Value));
builder.AppendLine();
}
return builder.ToString();
}
private static string QuoteIfNeeded(string value)
{
if (value.Length == 0)
{
return "\"\"";
}
if (value.Any(static c => char.IsWhiteSpace(c) || c is '"' or '\\' or '$'))
{
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
}
return value;
}
private static string Unquote(string value)
{
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
{
var inner = value[1..^1];
return Unescape(inner);
}
return value;
}
private static string Unescape(string value)
{
var builder = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
if (value[i] == '\\' && i + 1 < value.Length)
{
builder.Append(value[++i]);
continue;
}
builder.Append(value[i]);
}
return builder.ToString();
}
}

View File

@@ -1,6 +0,0 @@
namespace Sms.Environment.Windows;
public class Class1
{
}

View File

@@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,97 @@
using System.Runtime.InteropServices;
using Sms.Environment;
namespace Sms.Environment.Windows;
public sealed class WindowsEnvironmentVariableStore : IEnvironmentVariableStore
{
private const int HWND_BROADCAST = 0xffff;
private const int WM_SETTINGCHANGE = 0x001A;
public string? Get(string name) =>
System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User)
?? System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process);
public void Set(string name, string value)
{
System.Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.User);
System.Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
BroadcastEnvironmentChange();
}
public bool Exists(string name) => Get(name) is not null;
public IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var name in names)
{
var value = Get(name);
if (value is not null)
{
result[name] = value;
}
}
return result;
}
public IReadOnlyDictionary<string, string> GetProcessEnvironment() =>
ToDictionary(System.Environment.GetEnvironmentVariables(EnvironmentVariableTarget.Process));
public IReadOnlyDictionary<string, string> GetUserPersistedEnvironment() =>
ToDictionary(System.Environment.GetEnvironmentVariables(EnvironmentVariableTarget.User));
public bool IsPersistedInUserStore(string name) =>
System.Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.User) is not null;
public void RemoveFromUserStore(string name)
{
System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.User);
System.Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process);
BroadcastEnvironmentChange();
}
private static Dictionary<string, string> ToDictionary(System.Collections.IDictionary source)
{
var result = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (string key in source.Keys)
{
result[key] = source[key]?.ToString() ?? string.Empty;
}
return result;
}
private static void BroadcastEnvironmentChange()
{
try
{
_ = NativeMethods.SendMessageTimeout(
HWND_BROADCAST,
WM_SETTINGCHANGE,
IntPtr.Zero,
"Environment",
0,
1000,
out _);
}
catch
{
// Non-critical: new processes still see updated registry values.
}
}
private static class NativeMethods
{
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
public static extern IntPtr SendMessageTimeout(
int hWnd,
int msg,
IntPtr wParam,
string lParam,
int fuFlags,
int uTimeout,
out IntPtr lpdwResult);
}
}

View File

@@ -1,6 +0,0 @@
namespace Sms.Environment;
public class Class1
{
}

View File

@@ -0,0 +1,14 @@
namespace Sms.Environment;
public sealed class EnvironmentVariableStoreException : Exception
{
public EnvironmentVariableStoreException(string message)
: base(message)
{
}
public EnvironmentVariableStoreException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,20 @@
namespace Sms.Environment;
public interface IEnvironmentVariableStore
{
string? Get(string name);
void Set(string name, string value);
bool Exists(string name);
IReadOnlyDictionary<string, string> GetAll(IEnumerable<string> names);
IReadOnlyDictionary<string, string> GetProcessEnvironment();
IReadOnlyDictionary<string, string> GetUserPersistedEnvironment();
bool IsPersistedInUserStore(string name);
void RemoveFromUserStore(string name);
}

View File

@@ -1,9 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,10 +1,41 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Sms.TaskTwo.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
RequestedThemeVariant="Light">
<Application.Styles>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<FluentTheme />
<Style Selector="Button.titleButton">
<Setter Property="Width" Value="40" />
<Setter Property="Height" Value="44" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="FontSize" Value="18" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button.titleButton:pointerover">
<Setter Property="Background" Value="#D8D8D8" />
</Style>
<Style Selector="Button.titleButton.close:pointerover">
<Setter Property="Background" Value="#E81123" />
<Setter Property="Foreground" Value="White" />
</Style>
<Style Selector="DataGridRow.appSettings">
<Setter Property="Background" Value="#E3F2FD" />
</Style>
<Style Selector="DataGridRow.custom">
<Setter Property="Background" Value="#FFF8E1" />
</Style>
<Style Selector="DataGridRow.userStore">
<Setter Property="BorderBrush" Value="#2E7D32" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
</Style>
<Style Selector="DataGridRow.appSettings.userStore">
<Setter Property="Background" Value="#C8E6C9" />
</Style>
<Style Selector="DataGrid">
<Setter Property="RowHeight" Value="32" />
</Style>
</Application.Styles>
</Application>

View File

@@ -1,11 +1,22 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Sms.Environment;
using Sms.Environment.Linux;
using Sms.Environment.Windows;
using Sms.TaskTwo.Avalonia.Views;
using Sms.TaskTwo.Core.DependencyInjection;
using Sms.TaskTwo.Core.Logging;
using Sms.TaskTwo.ViewModels;
namespace Sms.TaskTwo.Avalonia;
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -13,11 +24,44 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
Services = ConfigureServices();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
var mainViewModel = Services.GetRequiredService<MainWindowViewModel>();
desktop.MainWindow = new MainWindow(mainViewModel);
desktop.ShutdownRequested += OnShutdownRequested;
}
base.OnFrameworkInitializationCompleted();
}
}
private static void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e)
{
if (Services.GetService<ConsoleLog>() is IDisposable log)
{
log.Dispose();
}
}
private static IServiceProvider ConfigureServices()
{
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton<IEnvironmentVariableStore>(CreateEnvironmentStore);
services.AddTaskTwoCore(configuration);
services.AddSingleton<MainWindowViewModel>();
return services.BuildServiceProvider();
}
private static IEnvironmentVariableStore CreateEnvironmentStore(IServiceProvider _) =>
OperatingSystem.IsWindows()
? new WindowsEnvironmentVariableStore()
: new LinuxEnvironmentVariableStore();
}

View File

@@ -1,9 +0,0 @@
<Window 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"
x:Class="Sms.TaskTwo.Avalonia.MainWindow"
Title="Sms.TaskTwo.Avalonia">
Welcome to Avalonia!
</Window>

View File

@@ -1,11 +0,0 @@
using Avalonia.Controls;
namespace Sms.TaskTwo.Avalonia;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -1,20 +1,15 @@
using Avalonia;
using System;
namespace Sms.TaskTwo.Avalonia;
class Program
internal static class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();

View File

@@ -6,6 +6,7 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
@@ -13,10 +14,25 @@
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.8" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.8">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.Environment.Linux\Sms.Environment.Linux.csproj" />
<ProjectReference Include="..\Sms.Environment.Windows\Sms.Environment.Windows.csproj" />
<ProjectReference Include="..\Sms.TaskTwo.Core\Sms.TaskTwo.Core.csproj" />
<ProjectReference Include="..\Sms.TaskTwo.ViewModels\Sms.TaskTwo.ViewModels.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,117 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Sms.TaskTwo.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="Sms.TaskTwo.Avalonia.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="{x:Static core:AppResources.WindowTitle}"
xmlns:core="using:Sms.TaskTwo.Core"
Width="960"
Height="600"
MinWidth="760"
MinHeight="480"
Background="#F5F5F5"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
SystemDecorations="None">
<Border CornerRadius="12"
Background="White"
BorderBrush="#C8C8C8"
BorderThickness="1"
Margin="8">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid Grid.Row="0"
ColumnDefinitions="*,Auto,Auto"
Background="#ECECEC"
Height="44">
<TextBlock Grid.Column="0"
Text="{x:Static core:AppResources.WindowTitle}"
VerticalAlignment="Center"
Margin="16,0,8,0"
FontSize="14" />
<Button Grid.Column="1"
Classes="titleButton"
Content=""
Click="OnMinimizeClick" />
<Button Grid.Column="2"
Classes="titleButton close"
Content="×"
Click="OnCloseClick" />
</Grid>
<CheckBox Grid.Row="1"
Margin="12,8,12,0"
Content="Отображать все переменные"
IsChecked="{Binding ShowAllVariables}" />
<Grid Grid.Row="2"
Margin="12,8,12,0"
ColumnDefinitions="Auto,2*,3*,Auto"
ColumnSpacing="8">
<TextBlock Grid.Column="0"
Text="Новая:"
VerticalAlignment="Center" />
<TextBox Grid.Column="1"
Watermark="Имя переменной"
Text="{Binding NewVariableName}" />
<TextBox Grid.Column="2"
Watermark="Значение"
Text="{Binding NewVariableValue}" />
<Button Grid.Column="3"
Content="Добавить"
Command="{Binding AddVariableCommand}"
MinWidth="100" />
</Grid>
<TextBlock Grid.Row="3"
Margin="12,4,12,0"
Foreground="#C62828"
Text="{Binding AddVariableError}"
IsVisible="{Binding AddVariableError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<DataGrid x:Name="VariablesGrid"
Grid.Row="4"
Margin="12,4,12,12"
ItemsSource="{Binding Rows}"
AutoGenerateColumns="False"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
CanUserSortColumns="False"
GridLinesVisibility="All"
HeadersVisibility="Column"
BorderThickness="1"
BorderBrush="#B0B0B0"
LoadingRow="OnLoadingRow">
<DataGrid.Columns>
<DataGridTextColumn Header=""
Binding="{Binding UserStoreBadge}"
IsReadOnly="True"
Width="56" />
<DataGridTextColumn Header="Поле"
Binding="{Binding Field}"
IsReadOnly="True"
Width="2*" />
<DataGridTextColumn Header="Значение"
Binding="{Binding Value, Mode=TwoWay}"
Width="3*" />
<DataGridTextColumn Header="Комментарий"
Binding="{Binding Comment, Mode=TwoWay}"
Width="3*" />
<DataGridTemplateColumn Header=""
Width="80">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="vm:EnvironmentVariableRowViewModel">
<Button Content="Удалить"
Command="{Binding DeleteFromUserStoreCommand}"
Padding="6,2"
FontSize="11" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,101 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Sms.TaskTwo.ViewModels;
namespace Sms.TaskTwo.Avalonia.Views;
public partial class MainWindow : Window
{
private readonly Dictionary<EnvironmentVariableRowViewModel, DataGridRow> _gridRows = new();
public MainWindow()
{
InitializeComponent();
}
public MainWindow(object dataContext) : this()
{
DataContext = dataContext;
}
private void OnLoadingRow(object? sender, DataGridRowEventArgs e)
{
if (e.Row.DataContext is not EnvironmentVariableRowViewModel row)
{
return;
}
if (_gridRows.TryGetValue(row, out var previousRow) && previousRow != e.Row)
{
previousRow.DataContextChanged -= OnRowDataContextChanged;
}
_gridRows[row] = e.Row;
ApplyRowClasses(e.Row, row);
row.RowAppearanceChanged -= OnRowAppearanceChanged;
row.RowAppearanceChanged += OnRowAppearanceChanged;
e.Row.DataContextChanged -= OnRowDataContextChanged;
e.Row.DataContextChanged += OnRowDataContextChanged;
}
private void OnRowDataContextChanged(object? sender, EventArgs e)
{
if (sender is not DataGridRow gridRow)
{
return;
}
var row = _gridRows.FirstOrDefault(pair => pair.Value == gridRow).Key;
if (row is null)
{
return;
}
row.RowAppearanceChanged -= OnRowAppearanceChanged;
_gridRows.Remove(row);
gridRow.DataContextChanged -= OnRowDataContextChanged;
}
private void OnRowAppearanceChanged(object? sender, EventArgs e)
{
if (sender is not EnvironmentVariableRowViewModel row)
{
return;
}
if (_gridRows.TryGetValue(row, out var gridRow))
{
ApplyRowClasses(gridRow, row);
}
}
private static void ApplyRowClasses(DataGridRow gridRow, EnvironmentVariableRowViewModel row)
{
gridRow.Classes.Remove("appSettings");
gridRow.Classes.Remove("custom");
gridRow.Classes.Remove("userStore");
if (row.IsFromAppSettings)
{
gridRow.Classes.Add("appSettings");
}
if (row.IsCustom)
{
gridRow.Classes.Add("custom");
}
if (row.IsPersistedInUserStore)
{
gridRow.Classes.Add("userStore");
}
}
private void OnMinimizeClick(object? sender, RoutedEventArgs e) =>
WindowState = WindowState.Minimized;
private void OnCloseClick(object? sender, RoutedEventArgs e) =>
Close();
}

View File

@@ -0,0 +1,16 @@
{
"EnvironmentVariables": {
"Names": [
"SMS_MEAL_SERVER_URL",
"SMS_MEAL_API_KEY"
],
"CommentsVariableName": "SMS_TASK_TWO_COMMENTS",
"CustomVariablesVariableName": "SMS_TASK_TWO_CUSTOM_VARS",
"Defaults": {
"SMS_MEAL_SERVER_URL": "https://localhost"
}
},
"Logging": {
"LogDirectory": "logs"
}
}

View File

@@ -0,0 +1,6 @@
namespace Sms.TaskTwo.Core;
public static class AppResources
{
public const string WindowTitle = "Тестовое WPF-приложение для SmartMealService";
}

View File

@@ -1,6 +0,0 @@
namespace Sms.TaskTwo.Core;
public class Class1
{
}

View File

@@ -0,0 +1,14 @@
namespace Sms.TaskTwo.Core.Configuration;
public sealed class EnvironmentVariablesOptions
{
public const string SectionName = "EnvironmentVariables";
public string[] Names { get; init; } = [];
public string CommentsVariableName { get; init; } = "SMS_TASK_TWO_COMMENTS";
public string CustomVariablesVariableName { get; init; } = "SMS_TASK_TWO_CUSTOM_VARS";
public Dictionary<string, string> Defaults { get; init; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,8 @@
namespace Sms.TaskTwo.Core.Configuration;
public sealed class LoggingOptions
{
public const string SectionName = "Logging";
public string LogDirectory { get; init; } = "logs";
}

View File

@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Sms.TaskTwo.Core.Configuration;
using Sms.TaskTwo.Core.Logging;
using Sms.TaskTwo.Core.Services;
namespace Sms.TaskTwo.Core.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddTaskTwoCore(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<EnvironmentVariablesOptions>(
configuration.GetSection(EnvironmentVariablesOptions.SectionName));
services.Configure<LoggingOptions>(
configuration.GetSection(LoggingOptions.SectionName));
services.AddSingleton(CreateConsoleLog);
services.AddSingleton<EnvironmentVariablesService>();
return services;
}
private static ConsoleLog CreateConsoleLog(IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<IOptions<LoggingOptions>>().Value;
Directory.CreateDirectory(options.LogDirectory);
var fileName = Path.Combine(
options.LogDirectory,
$"test-sms-wpf-app-{DateTime.Now:yyyyMMdd}.log");
return ConsoleLog.Open(fileName);
}
}

View File

@@ -0,0 +1,57 @@
namespace Sms.TaskTwo.Core.Logging;
public sealed class ConsoleLog : IDisposable
{
private readonly StreamWriter _file;
private ConsoleLog(string filePath, DateTime startedAt)
{
FilePath = filePath;
StartedAt = startedAt;
_file = new StreamWriter(filePath, append: true) { AutoFlush = true };
_file.WriteLine($"Запуск: {startedAt:yyyy-MM-dd HH:mm:ss}");
}
public string FilePath { get; }
public DateTime StartedAt { get; }
public static ConsoleLog Open(string? fileName = null)
{
var startedAt = TruncateToSeconds(DateTime.Now);
fileName ??= $"test-sms-console-app-{startedAt:yyyyMMdd_HHmmss}.log";
return new ConsoleLog(Path.GetFullPath(fileName), startedAt);
}
private static DateTime TruncateToSeconds(DateTime value) =>
new(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind);
public void Write(string? text)
{
Console.Write(text);
_file.Write(text);
}
public void WriteLine(string? text = null)
{
if (text is null)
{
Console.WriteLine();
_file.WriteLine();
return;
}
Console.WriteLine(text);
_file.WriteLine(text);
}
public string? ReadLine(string prompt)
{
Write(prompt);
var line = Console.ReadLine();
_file.WriteLine(line ?? "");
return line;
}
public void Dispose() => _file.Dispose();
}

View File

@@ -0,0 +1,16 @@
namespace Sms.TaskTwo.Core.Models;
public sealed class EnvironmentVariableRow
{
public required string Field { get; init; }
public required string Value { get; init; }
public required string Comment { get; init; }
public bool IsFromAppSettings { get; init; }
public bool IsCustom { get; init; }
public bool IsPersistedInUserStore { get; init; }
}

View File

@@ -0,0 +1,286 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Sms.Environment;
using Sms.TaskTwo.Core.Configuration;
using Sms.TaskTwo.Core.Logging;
using Sms.TaskTwo.Core.Models;
namespace Sms.TaskTwo.Core.Services;
public sealed class EnvironmentVariablesService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false,
};
private static readonly Regex ValidNamePattern = new(
@"^[A-Za-z_][A-Za-z0-9_]*$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private readonly IEnvironmentVariableStore _store;
private readonly EnvironmentVariablesOptions _options;
private readonly ConsoleLog _log;
private readonly HashSet<string> _configuredNames;
private List<string> _customNames = [];
private Dictionary<string, string> _comments = new(StringComparer.Ordinal);
public EnvironmentVariablesService(
IEnvironmentVariableStore store,
IOptions<EnvironmentVariablesOptions> options,
ConsoleLog log)
{
_store = store;
_options = options.Value;
_log = log;
_configuredNames = new HashSet<string>(_options.Names, StringComparer.Ordinal);
}
public void EnsureConfiguredVariablesExist()
{
ReloadMetadata();
foreach (var name in _options.Names)
{
if (_store.IsPersistedInUserStore(name))
{
continue;
}
var value = ResolveValue(name);
_store.Set(name, value);
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created configured variable: {name}={value}");
}
}
public IReadOnlyList<EnvironmentVariableRow> Load(bool showAllVariables)
{
ReloadMetadata();
var rows = new List<EnvironmentVariableRow>();
var knownNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var name in _options.Names)
{
rows.Add(CreateRow(name, ResolveValue(name)));
knownNames.Add(name);
}
foreach (var name in _customNames)
{
if (knownNames.Add(name))
{
rows.Add(CreateRow(name, ResolveValue(name)));
}
}
if (!showAllVariables)
{
return rows;
}
var process = _store.GetProcessEnvironment();
foreach (var pair in process.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
if (ShouldHideVariable(pair.Key) || knownNames.Contains(pair.Key))
{
continue;
}
rows.Add(CreateRow(pair.Key, pair.Value));
}
return rows;
}
public bool TryAddCustomVariable(string name, string value, out string? errorMessage)
{
ReloadMetadata();
name = name.Trim();
if (string.IsNullOrWhiteSpace(name))
{
errorMessage = "Укажите имя переменной.";
return false;
}
if (!ValidNamePattern.IsMatch(name))
{
errorMessage = "Имя может содержать только латинские буквы, цифры и '_', и не может начинаться с цифры.";
return false;
}
if (ShouldHideVariable(name))
{
errorMessage = "Это имя зарезервировано приложением.";
return false;
}
if (_configuredNames.Contains(name) || _customNames.Contains(name))
{
errorMessage = "Переменная с таким именем уже есть в списке.";
return false;
}
_customNames.Add(name);
PersistCustomNames();
_store.Set(name, value);
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Created custom variable: {name}={value}");
errorMessage = null;
return true;
}
public EnvironmentVariableRow GetRowSnapshot(string name)
{
ReloadMetadata();
return CreateRow(name, ResolveValue(name));
}
public string GetDisplayValue(string name) => ResolveValue(name);
public void SaveValue(string name, string value)
{
var previous = _store.Get(name);
_store.Set(name, value);
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed value: {name}={value} (previous: {previous ?? "<none>"})");
}
public void SaveComment(string name, string comment)
{
var previous = _comments.GetValueOrDefault(name, string.Empty);
_comments[name] = comment;
PersistComments();
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Changed comment: {name}={comment} (previous: {(string.IsNullOrEmpty(previous) ? "<none>" : previous)})");
}
public void RemoveVariable(string name)
{
ReloadMetadata();
if (IsCustom(name))
{
_customNames.Remove(name);
PersistCustomNames();
_comments.Remove(name);
PersistComments();
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Removed custom variable from list: {name}");
}
DeleteFromUserStore(name);
}
public void DeleteFromUserStore(string name)
{
if (!_store.IsPersistedInUserStore(name))
{
return;
}
var previous = _store.Get(name);
_store.RemoveFromUserStore(name);
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [INFO] Removed from user store: {name} (previous: {previous ?? "<none>"})");
}
public bool IsCustom(string name) => _customNames.Contains(name, StringComparer.Ordinal);
private void ReloadMetadata()
{
_comments = LoadComments();
_customNames = LoadCustomNames();
}
private EnvironmentVariableRow CreateRow(string name, string value)
{
_comments.TryGetValue(name, out var comment);
return new EnvironmentVariableRow
{
Field = name,
Value = value,
Comment = comment ?? string.Empty,
IsFromAppSettings = _configuredNames.Contains(name),
IsCustom = IsCustom(name),
IsPersistedInUserStore = _store.IsPersistedInUserStore(name),
};
}
private bool ShouldHideVariable(string name) =>
string.Equals(name, _options.CommentsVariableName, StringComparison.Ordinal)
|| string.Equals(name, _options.CustomVariablesVariableName, StringComparison.Ordinal);
private string ResolveValue(string name)
{
var existing = _store.Get(name);
if (existing is not null)
{
return existing;
}
if (_options.Defaults.TryGetValue(name, out var defaultValue))
{
return defaultValue;
}
return string.Empty;
}
private List<string> LoadCustomNames()
{
var raw = _store.Get(_options.CustomVariablesVariableName);
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(raw, JsonOptions) ?? [];
}
catch (JsonException ex)
{
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] Failed to parse custom variables {_options.CustomVariablesVariableName}: {ex.Message}");
return [];
}
}
private void PersistCustomNames()
{
var json = JsonSerializer.Serialize(_customNames, JsonOptions);
_store.Set(_options.CustomVariablesVariableName, json);
}
private Dictionary<string, string> LoadComments()
{
var raw = _store.Get(_options.CommentsVariableName);
if (string.IsNullOrWhiteSpace(raw))
{
return new Dictionary<string, string>(StringComparer.Ordinal);
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, string>>(raw, JsonOptions)
?? new Dictionary<string, string>(StringComparer.Ordinal);
}
catch (JsonException ex)
{
_log.WriteLine(
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [WARN] Failed to parse comments variable {_options.CommentsVariableName}: {ex.Message}");
return new Dictionary<string, string>(StringComparer.Ordinal);
}
}
private void PersistComments()
{
var json = JsonSerializer.Serialize(_comments, JsonOptions);
_store.Set(_options.CommentsVariableName, json);
}
}

View File

@@ -1,9 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.Environment\Sms.Environment.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
namespace Sms.TaskTwo.ViewModels;
public class Class1
{
}

View File

@@ -0,0 +1,113 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Sms.TaskTwo.Core.Models;
using Sms.TaskTwo.Core.Services;
namespace Sms.TaskTwo.ViewModels;
public sealed partial class EnvironmentVariableRowViewModel : ObservableObject
{
private readonly EnvironmentVariablesService _service;
private bool _isLoading;
public EnvironmentVariableRowViewModel(EnvironmentVariableRow row, EnvironmentVariablesService service)
{
Field = row.Field;
IsFromAppSettings = row.IsFromAppSettings;
IsCustom = row.IsCustom;
_service = service;
ApplySnapshot(row, suppressSave: true);
}
public string Field { get; }
public bool IsFromAppSettings { get; }
public bool IsCustom { get; }
[ObservableProperty]
private bool _isPersistedInUserStore;
public string UserStoreBadge => IsPersistedInUserStore ? "USER" : string.Empty;
[ObservableProperty]
private string _value = string.Empty;
[ObservableProperty]
private string _comment = string.Empty;
partial void OnIsPersistedInUserStoreChanged(bool value)
{
OnPropertyChanged(nameof(UserStoreBadge));
DeleteFromUserStoreCommand.NotifyCanExecuteChanged();
RowAppearanceChanged?.Invoke(this, EventArgs.Empty);
}
public event EventHandler? RowAppearanceChanged;
public void ApplySnapshot(EnvironmentVariableRow row, bool suppressSave = false)
{
if (suppressSave)
{
BeginLoad();
}
Value = row.Value;
Comment = row.Comment;
IsPersistedInUserStore = row.IsPersistedInUserStore;
if (suppressSave)
{
EndLoad();
}
}
public void BeginLoad() => _isLoading = true;
public void EndLoad() => _isLoading = false;
partial void OnValueChanged(string value)
{
if (_isLoading)
{
return;
}
_service.SaveValue(Field, value);
if (!IsPersistedInUserStore)
{
IsPersistedInUserStore = true;
}
}
partial void OnCommentChanged(string value)
{
if (_isLoading)
{
return;
}
_service.SaveComment(Field, value);
}
[RelayCommand(CanExecute = nameof(CanDeleteFromUserStore))]
private void DeleteFromUserStore()
{
if (IsCustom)
{
_service.RemoveVariable(Field);
Removed?.Invoke(this, EventArgs.Empty);
return;
}
_service.DeleteFromUserStore(Field);
BeginLoad();
IsPersistedInUserStore = false;
Value = _service.GetDisplayValue(Field);
EndLoad();
}
public event EventHandler? Removed;
private bool CanDeleteFromUserStore() => IsPersistedInUserStore || IsCustom;
}

View File

@@ -0,0 +1,135 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Sms.TaskTwo.Core.Services;
namespace Sms.TaskTwo.ViewModels;
public sealed partial class MainWindowViewModel : ObservableObject
{
private readonly EnvironmentVariablesService _service;
public MainWindowViewModel(EnvironmentVariablesService service)
{
_service = service;
Rows = new ObservableCollection<EnvironmentVariableRowViewModel>();
_service.EnsureConfiguredVariablesExist();
SyncRowsFromService();
}
public ObservableCollection<EnvironmentVariableRowViewModel> Rows { get; }
[ObservableProperty]
private bool _showAllVariables;
[ObservableProperty]
private string _newVariableName = string.Empty;
[ObservableProperty]
private string _newVariableValue = string.Empty;
[ObservableProperty]
private string? _addVariableError;
partial void OnShowAllVariablesChanged(bool value) => SyncRowsFromService();
partial void OnNewVariableNameChanged(string value) => AddVariableCommand.NotifyCanExecuteChanged();
[RelayCommand(CanExecute = nameof(CanAddVariable))]
private void AddVariable()
{
if (!_service.TryAddCustomVariable(NewVariableName, NewVariableValue, out var error))
{
AddVariableError = error;
return;
}
AddVariableError = null;
var name = NewVariableName.Trim();
NewVariableName = string.Empty;
NewVariableValue = string.Empty;
var row = _service.GetRowSnapshot(name);
var viewModel = new EnvironmentVariableRowViewModel(row, _service);
viewModel.Removed += OnRowRemoved;
Rows.Insert(FindInsertIndexForCustomVariable(), viewModel);
}
private void OnRowRemoved(object? sender, EventArgs e)
{
if (sender is EnvironmentVariableRowViewModel row)
{
row.Removed -= OnRowRemoved;
Rows.Remove(row);
}
}
private int FindInsertIndexForCustomVariable()
{
for (var index = Rows.Count - 1; index >= 0; index--)
{
if (Rows[index].IsFromAppSettings || Rows[index].IsCustom)
{
return index + 1;
}
}
return Rows.Count;
}
private bool CanAddVariable() => !string.IsNullOrWhiteSpace(NewVariableName);
private void SyncRowsFromService()
{
var data = _service.Load(ShowAllVariables);
var existingByField = Rows.ToDictionary(static r => r.Field, StringComparer.Ordinal);
for (var index = Rows.Count - 1; index >= 0; index--)
{
var row = Rows[index];
if (data.All(item => item.Field != row.Field))
{
row.Removed -= OnRowRemoved;
Rows.RemoveAt(index);
}
}
for (var targetIndex = 0; targetIndex < data.Count; targetIndex++)
{
var row = data[targetIndex];
EnvironmentVariableRowViewModel viewModel;
if (existingByField.TryGetValue(row.Field, out var existing))
{
viewModel = existing;
viewModel.ApplySnapshot(row, suppressSave: true);
}
else
{
viewModel = new EnvironmentVariableRowViewModel(row, _service);
viewModel.Removed += OnRowRemoved;
existingByField[row.Field] = viewModel;
}
var currentIndex = Rows.IndexOf(viewModel);
if (currentIndex < 0)
{
Rows.Insert(targetIndex, viewModel);
}
else if (currentIndex != targetIndex)
{
MoveItem(Rows, currentIndex, targetIndex);
}
}
}
private static void MoveItem(
ObservableCollection<EnvironmentVariableRowViewModel> collection,
int oldIndex,
int newIndex)
{
var item = collection[oldIndex];
collection.RemoveAt(oldIndex);
collection.Insert(newIndex, item);
}
}

View File

@@ -1,9 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Sms.TaskTwo.Core\Sms.TaskTwo.Core.csproj" />
</ItemGroup>
</Project>