Базовая работа на Linux
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
bin/
|
||||
obj/
|
||||
.idea/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
logs/
|
||||
_mockup-page*.png
|
||||
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal 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
83
.vscode/tasks.json
vendored
Normal 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
91
README.md
Normal 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-проекта.
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Sms.Environment.Linux;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
172
src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs
Normal file
172
src/Sms.Environment.Linux/LinuxEnvironmentVariableStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
93
src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs
Normal file
93
src/Sms.Environment.Linux/SystemdEnvironmentFileParser.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Sms.Environment.Windows;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Sms.Environment;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
14
src/Sms.Environment/EnvironmentVariableStoreException.cs
Normal file
14
src/Sms.Environment/EnvironmentVariableStoreException.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
20
src/Sms.Environment/IEnvironmentVariableStore.cs
Normal file
20
src/Sms.Environment/IEnvironmentVariableStore.cs
Normal 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);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +0,0 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Sms.TaskTwo.Avalonia;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
117
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
Normal file
117
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml
Normal 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>
|
||||
101
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml.cs
Normal file
101
src/Sms.TaskTwo.Avalonia/Views/MainWindow.axaml.cs
Normal 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();
|
||||
}
|
||||
16
src/Sms.TaskTwo.Avalonia/appsettings.json
Normal file
16
src/Sms.TaskTwo.Avalonia/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
src/Sms.TaskTwo.Core/AppResources.cs
Normal file
6
src/Sms.TaskTwo.Core/AppResources.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Sms.TaskTwo.Core;
|
||||
|
||||
public static class AppResources
|
||||
{
|
||||
public const string WindowTitle = "Тестовое WPF-приложение для SmartMealService";
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Sms.TaskTwo.Core;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
8
src/Sms.TaskTwo.Core/Configuration/LoggingOptions.cs
Normal file
8
src/Sms.TaskTwo.Core/Configuration/LoggingOptions.cs
Normal 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
57
src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs
Normal file
57
src/Sms.TaskTwo.Core/Logging/ConsoleLog.cs
Normal 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();
|
||||
}
|
||||
16
src/Sms.TaskTwo.Core/Models/EnvironmentVariableRow.cs
Normal file
16
src/Sms.TaskTwo.Core/Models/EnvironmentVariableRow.cs
Normal 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; }
|
||||
}
|
||||
286
src/Sms.TaskTwo.Core/Services/EnvironmentVariablesService.cs
Normal file
286
src/Sms.TaskTwo.Core/Services/EnvironmentVariablesService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Sms.TaskTwo.ViewModels;
|
||||
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
113
src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
Normal file
113
src/Sms.TaskTwo.ViewModels/EnvironmentVariableRowViewModel.cs
Normal 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;
|
||||
}
|
||||
135
src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
Normal file
135
src/Sms.TaskTwo.ViewModels/MainWindowViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user