diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..7bed8a5d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,65 @@ +# ============================================================================= +# Сборка обеих целей проекта на каждый push в main и на каждый pull request. +# +# Целевой фреймворк выбирается в csproj по операционной системе сборки: +# на Windows это net10.0-windows (WPF), на Linux net10.0 (Avalonia). +# Поэтому одной машиной обе цели не проверить: сборка на Windows не +# компилирует ни одного файла *.Avalonia.cs, а сборка на Linux не +# компилирует WPF-часть. Отсюда две задачи, по одной на систему. +# +# Каждая задача вызывает тот же скрипт, которым проект собирается вручную +# (build.ps1 и build.sh), чтобы CI и локальная сборка не разъезжались: +# в Release-конфигурации заданы RuntimeIdentifier и single-file, и оба +# скрипта их гасят для обычной сборки. +# ============================================================================= +name: Сборка + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Сборке хватает чтения: ничего не публикует и не пишет в репозиторий. +permissions: + contents: read + +# Новый коммит в ветку отменяет незаконченный прогон по ней же, +# иначе на активной ветке копится очередь параллельных сборок. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + name: Windows (WPF, net10.0-windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Установка .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Сборка Release + shell: pwsh + working-directory: Configuration Management + run: ./build.ps1 -Configuration Release + + linux: + name: Linux (Avalonia, net10.0) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Установка .NET SDK + uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + + - name: Сборка Release + working-directory: Configuration Management + run: ./build.sh Release diff --git a/.gitignore b/.gitignore index e4c14473..3fcbea71 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ obj/ *.suo *.userosscache *.sln.docstates +dist/ +*_wpftmp.csproj ## Build results [Dd]ebug/ @@ -35,4 +37,11 @@ packages/ ## OS Thumbs.db Desktop.ini -.DS_Store \ No newline at end of file +.DS_Store + +# Артефакты упаковки Linux: собираются скриптами из package/linux, +# в репозитории нужны только сами скрипты. +package/linux/AppDir/ +package/linux/out/ +package/linux/deb/staging/ +package/linux/deb/out/ diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa11de2..eb11fc63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,567 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версионирование — на [Semantic Versioning](https://semver.org/lang/ru/). +## [0.3.4.15] — 2026-08-25 + +### Добавлено + +- **Размер кеша выбранной базы отображается в правой панели главного окна (обе платформы)**. В блок сведений о подключении добавлена строка **«Размер кеша»**: WPF — [`MainWindow.xaml`](Configuration Management/MainWindow.xaml), Linux/Avalonia — [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). Значение вычисляется асинхронно в фоновом потоке при выборе базы (метод `RefreshCacheSizeAsync` в [`Infobase.cs`](Configuration Management/Models/Infobase.cs), вызов из `SelectedInfobase` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs)) и показывается в человекочитаемом виде с локализованными единицами (Б/КБ/МБ/ГБ/ТБ или B/KB/MB/GB/TB); пока размер не определён, выводится «…». Добавлен ключ локализации `Main.CacheSize` в `ru.json` и `en.json`. + +### Версия + +- **Версия обновлена до 0.3.4.15** (`InformationalVersion` = 0.3.4.15 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.14] — 2026-08-25 + +### Добавлено + +- **Колонка «Действия» стала перемещаемой в списке баз (Windows/WPF)**. Раньше колонка «Действия» была закреплена сразу после «Режим запуска» и отсутствовала в списке порядка колонок вкладки **«Отображение» → «Колонки»** окна настроек — изменить её местоположение было нельзя. Теперь она участвует в порядке колонок наравне с остальными: добавлена в `DefaultColumnOrder` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), учитывается в `BuildColumnLayout` ([`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs)), а её локализованное имя — в `ColumnOrderLabel` ([`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs)). Новый порядок применяется к заголовку, строкам баз и строкам групп. + +### Изменено + +- **Заголовок колонки «Действия» прижат к левому краю (Windows/WPF)**. Заголовок колонки «Действия» в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) был отцентрован (`HorizontalAlignment="Center"`), в отличие от остальных колонок — теперь он выровнен по левому краю, как остальные заголовки. +- **Текст подсказки «Порядок колонок» уточнён**. `Settings.Columns.OrderHint` в `ru.json`/`en.json` больше не утверждает, что колонка «Действия» закреплена: теперь указано, что фиксирована только колонка «Название». + +### Версия + +- **Версия обновлена до 0.3.4.14** (`InformationalVersion` = 0.3.4.14 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.13] — 2026-08-25 + +### Исправлено + +- **Устранено предупреждение компилятора CS0618 (obsolete) в проверке доступности клиент-серверных баз (обе платформы)**. Метод `IsBaseAvailable` вызывал помеченный `[Obsolete]` `IOneCComConnector.Connect`, который под CoreCLR обрывает процесс нативным fast-fail (0xC0000409). В [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) (Windows/WPF) доступность теперь проверяется через безопасный путь [`ReadConfigurationInfo`](Configuration Management/Services/IOneCComConnector.cs) (процесс-агент `ComReadHost`); в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs) (Linux/Avalonia) клиент-серверные базы по-прежнему считаются недоступными (COM отсутствует), а устаревший вызов убран. +- **Исправлена сборка решения [`Configuration Management.slnx`](Configuration Management.slnx)**. Из решения удалена битая ссылка на несуществующий проект `Configuration Management.Tests/Configuration Management.Tests.csproj`, из-за которой `dotnet build` падал с ошибкой MSB3202. + +### Версия + +- **Версия обновлена до 0.3.4.13** (`InformationalVersion` = 0.3.4.13 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.11] — 2026-08-25 + +### Исправлено + +- **Команды групп («Изменить группу» / «Удалить группу») размещены на уровне колонки «Действия» (Windows/WPF)**. Строка группы перестроена в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) по той же схеме ведущих колонок, что и строка базы/заголовок: имя/иконка/счётчик группы занимают область названия, а команды `EditGroupCommand`/`DeleteGroupCommand` — колонку «Действия» (та же горизонтальная позиция, что у кнопок строк баз). Строки групп участвуют в динамическом порядке колонок (`OnGroupRowGrid_Loaded`, `ApplyColumnOrder` в [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs)), поэтому при смене порядка колонок команды группы остаются на уровне кнопок баз. +- **Исправлено изменение размера колонок списка баз после динамической перестройки (Windows/WPF)**. Причина регрессии: при перестановке колонок элементы перемещались по `Tag`, который был занят другими целями (сортировка заголовка «Последний запуск» — `LastLaunchDate`, двойной клик по версии — `PlatformVersion`), из-за чего разделители/подписи/ячейки не следовали за своими колонками и перетаскивание ломалось. Введено attached-свойство `ColumnKey` ([`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs)) для хранения ключа колонки отдельно от `Tag`; `ReorderGridColumns` теперь корректно перемещает разделители, подписи и ячейки данных синхронно с перестановкой колонок. Ресайз работает и в порядке по умолчанию, и при пользовательском порядке колонок. +- **Добавлено изменение размера колонки «Действия» (Windows/WPF)**. Добавлено поле ширины `ActionsColumnWidth` в [`AppSettings.cs`](Configuration Management/Models/AppSettings.cs) и [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) (включено в `UpdateColumnWidths`/`SaveColumnWidths`), ширина колонки в заголовке, строке базы и строке группы привязана к нему вместо жёсткого значения, а в заголовке добавлен разделитель `ActionsSplitter` (обработан в `GetSplitterTargetColumn`). Ручное перетаскивание меняет ширину синхронно во всех строках и сохраняется. +- **Данные в списке баз выровнены с заголовком колонок (Windows/WPF)**. Строка базы и заголовок теперь имеют одинаковый набор ведущих колонок (кнопки групп + компенсатор + избранное + закрепление + название): в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) в сетку строки базы добавлена нулевая offset-колонка, а `AlignHeaderToData` переписан в [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs) на измерение фактического положения первой колонки данных строки относительно заголовка. Колонки данных строк точно совпадают по горизонтали с заголовками. + +### Версия + +- **Версия обновлена до 0.3.4.11** (`InformationalVersion` = 0.3.4.11 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.10] — 2026-08-25 + +### Добавлено + +- **Редактор порядка колонок списка баз в настройках (Windows/WPF)**. В **Настройки → Отображение → Колонки** добавлен редактор порядка колонок с кнопками «Вверх»/«Вниз» по выбранной строке: пользователь задаёт последовательность колонок версии платформы, режима запуска, сервер/базы, последнего запуска, размера и конфигурации слева направо; колонки «Название» и «Действия» закреплены. Реализовано в [`SettingsWindow.xaml`](Configuration Management/SettingsWindow.xaml) и [`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs). +- **Динамический порядок колонок заголовка и строк базы (Windows/WPF)**. Построение заголовка и шаблона строки базы переведено с фиксированного XAML на динамическое с учётом выбранного порядка: колонки переставляются в [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs) (`ReorderGridColumns`, `ApplyColumnOrder`, `OnInfobaseRowGrid_Loaded`) по ключам из `ColumnOrderKeys`; колонка «Действия» встаёт сразу после «Режим запуска», а «Конфигурация» остаётся в конце. Сохранённый порядок хранится в поле `ColumnOrder` настроек ([`AppSettings.cs`](Configuration Management/Models/AppSettings.cs)) и применяется через [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) (`ColumnOrderKeys`, `DefaultColumnOrder`). Функциональность приведена в соответствие с Linux/Avalonia. + +### Локализация + +- Использованы существующие ключи `Settings.Columns.OrderTitle`, `Settings.Columns.OrderHint`, `Settings.Columns.OrderUp`, `Settings.Columns.OrderDown` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json) (добавлены ранее в 0.3.4.7 для Avalonia). + +### Версия + +- **Версия обновлена до 0.3.4.10** (`InformationalVersion` = 0.3.4.10 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.9] — 2026-08-25 + +### Изменено + +- **Колонка «Действия» списка баз встаёт сразу после колонки «Режим запуска» (Windows/WPF)**, а не у правого края. Колонки данных переставлены в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml): фиксированная колонка «Действия» (170) размещена между колонками «Режим запуска» и «Сервер/база» и в заголовке, и в шаблоне строки базы; соответствующим образом сдвинуты заголовки, значения, разделители колонок и область тегов (теги больше не заходят под колонку «Действия»). Поведение приведено в соответствие с Linux/Avalonia (`ActionsOffsetInColumns` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). +- **Колонка «Конфигурация» перенесена в самый конец списка колонок (Windows/WPF)** — после размера и остальных колонок данных, как уже сделано в Linux/Avalonia (порядок по умолчанию `DefaultColumnOrder` в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)). В Windows/WPF порядок колонок задан фиксированно в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). + +### Версия + +- **Версия обновлена до 0.3.4.9** (`InformationalVersion` = 0.3.4.9 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.8] — 2026-08-25 + +### Изменено + +- **Команды групп («Изменить группу» / «Удалить группу») выровнены по левому краю строки группы в Windows/WPF**; имя группы, иконка и счётчик следуют правее. Раньше команды групп располагались в правой колонке «Действия» и были выровнены по правому краю. Теперь колонки строки группы в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) переставлены: фиксированная колонка «Действия» (170) с кнопками `EditGroupCommand`/`DeleteGroupCommand` встала слева с выравниванием `HorizontalAlignment="Left"`, а имя/иконка/счётчик — в растягивающуюся колонку правее — как уже реализовано в Linux/Avalonia в `BuildGroupRow()` ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). + +### Версия + +- **Версия обновлена до 0.3.4.8** (`InformationalVersion` = 0.3.4.8 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.7] — 2026-08-25 + +### Изменено + +- **Настройка порядка колонок списка баз (Linux/Avalonia)**. В **Настройки → Отображение → Колонки** добавлен редактор порядка колонок (кнопки «Вверх»/«Вниз» по выбранной строке): пользователь задаёт последовательность колонок версии платформы, режима запуска, сервер/базы, последнего запуска, размера и конфигурации слева направо; колонки «Название» и «Действия» закреплены. Реализовано в [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs) и [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs) (поле `ColumnOrder` в настройках). +- **Колонка «Конфигурация» перенесена в самый конец списка баз** — порядок колонок по умолчанию. +- **Колонка «Действия» встаёт сразу после колонки «Режим запуска»** (а не у правого края). Если колонка «Режим запуска» скрыта или стоит последней — действия остаются в самом конце. Заголовок, строки баз и колонки значений теперь строятся с учётом позиции «Действий» в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs) (`RefreshColumnHeader`, `BuildInfobaseRow`, `ActionsOffsetInColumns`). +- **Команды групп («Изменить группу» / «Удалить группу») выровнены по левому краю** строки группы; имя и счётчик группы следуют правее. + +### Локализация + +- Добавлены ключи `Settings.Columns.OrderTitle`, `Settings.Columns.OrderHint`, `Settings.Columns.OrderUp`, `Settings.Columns.OrderDown` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +### Версия + +- **Версия обновлена до 0.3.4.7** (`InformationalVersion` = 0.3.4.7 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.6] — 2026-08-25 + +> **Авторство изменений этого выпуска: [ksv47](https://github.com/ksv47)** — слияние ветки `win-debug` (PR #69). + +### Исправлено + +- **Приложение молча завершалось на старте с кодом 0xC0000409 (Windows/WPF)**. Причина — прямой COM-вызов (`comcntr.dll`) из процесса приложения: под CoreCLR (.NET 5+) `Connect` обрывает процесс нативным fast-fail **0xC0000409** без управляемого исключения, поэтому приложение «исчезало», не показывая ни окна, ни ошибки, а Windows могла предложить отладчик. Теперь COM-коннектор 1С вынесен в **отдельный вспомогательный процесс-агент**, и чтение сведений о конфигурации 1С выполняется там, а не в процессе приложения. Прямой метод [`IOneCComConnector.Connect`](Configuration Management/Services/IOneCComConnector.cs) помечен `[Obsolete]` и из приложения больше не вызывается. + - **Процесс-агент** [`ComReadHost.cs`](Configuration Management/Services/ComReadHost.cs) (~1300 строк): обслуживает запросы чтения сведений по протоколу `stdin`/`stdout`, сам закрывается при закрытии своего `stdin` и явно снимается через `ComReadHost.Shutdown()` при выходе приложения ([`App.xaml.cs`](Configuration Management/App.xaml.cs)). + - **Ручная точка входа** [`Program.Main`](Configuration Management/Program.cs) (Windows/WPF) вместо автоматически генерируемой из `App.xaml`: режим COM-агента перехватывается **до** создания `App` и загрузки словарей ресурсов (MaterialDesign, тема, иконки) — агенту они не нужны, а сбой загрузки XAML у агента не уходил бы в `stderr`. В [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj) задан `StartupObject=Configuration_Management.Program`, а `App.xaml` подключён как `Page`, а не `ApplicationDefinition`. + - **Подавление WER** (Windows Error Reporting), чтобы система не прерывала работу диалогом о сбое. +- **Зависание/потеря диагноза при гибели агента**: при повреждённом окончательном кадре протокола и при таймауте (ранний коннектор уже назвал причину, следующий завис на недоступном сервере) придержанный диагноз молча терялся. Диагноз сохранён на обеих ветках, отказ по-прежнему учитывается. +- **Ложный успех регистрации COM-коннектора** ([`OneCComConnectorRegistrar.cs`](Configuration Management/Services/OneCComConnectorRegistrar.cs)): результат команды теперь определяется её собственным результатом, а не тем, что в системе и раньше был виден какой-то коннектор — иначе при уже установленной платформе 8.5 регистрация выбранной 8.3 могла провалиться, а пользователю сообщили бы об успехе. + +### Добавлено + +- **Юнит-тесты на протокол COM-агента и сборку строки подключения** — новый тестовый проект [`Configuration Management.Tests`](Configuration Management.Tests/Configuration Management.Tests.csproj) (xUnit, 53 теста): разбор кадров протокола (успех, ошибка, промежуточный кадр, чужой seq, повреждённая нагрузка, неизвестный токен, подделанный BADREQ), взаимная обратимость разрядов и токенов, ранжирование диагнозов, сборка строки подключения с экранированием и признаком пароля, маскировка учётных данных и само решение о показе пароля. Проверяемые чистые функции открыты тестам через `InternalsVisibleTo` (публичными не становятся); целевая платформа тестов следует за основным проектом, сами тесты закрыты `#if WINDOWS`, поэтому на Linux решение не ломается. Проект добавлен в [`Configuration Management.slnx`](Configuration Management.slnx). + +### Изменено + +- **Пароль не передаётся в командной строке** — только по протоколу `stdin` процесса-агента. **Маскировка учётных данных** и **решение о показе текста ошибки** больше не строятся на догадке агента: решение принимает родитель/сборщик строки подключения. Если у базы задан пароль, текст ошибки 1С скрывается (он может содержать пароль), показывается только код (`Com.DbErrorHiddenFormat` / `Com.DbErrorCodeOnlyFormat`). +- **Экранирование строки подключения** при сборке; значение поля не может закрыть себя и дописать чужой параметр; признак `Transport` по протоколу не приезжает. +- **Честный таймаут** чтения сведений; **накопление диагнозов**; разряд `BadRequest` получил собственный текст (отличается от молчания агента). +- **Поддержка платформы 8.5** в переборе `ProgID` ([`KnownProgIds`](Configuration Management/Services/OneCComConnector.cs) — теперь V85/V83/V82/V81): 8.5 регистрирует собственный ProgID/CLSID. **Диагностика разрядности** сравнивает один и тот же ProgID в обеих ветвях реестра по всему списку — это различает случай «64-битная 8.5 + 32-битная 8.3», который раньше врал. +- **Сброс обоих вердиктов о недоступности COM** (кэш реестра и сессионная защёлка агента) через `OneCComConnector.ResetComVerdicts()`: теперь это делается по явной команде пользователя и после успешной регистрации коннектора, а не только кэша реестра ([`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs)). +- **Локализация**: добавлены ключи `Com.DisabledForSession`, `Com.AgentStartFailed`, `Com.AgentCrashedFormat`, `Com.AgentCrashedUnknownCode`, `Com.UnknownCode`, `Com.DbErrorCodeOnlyFormat`, `Com.DbErrorHiddenFormat`, `Com.AgentNoResult`, `Com.AgentBadRequest`; `Com.NotFound` расширен до V85; переформулированы `Com.ProgIdStatus*` и `Main.ComRegProgId*` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +### Версия + +- **Версия обновлена до 0.3.4.6** (`InformationalVersion` = 0.3.4.6 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.5] — 2026-08-25 + +### Изменено + +- **Компактный режим стал плотнее (обе платформы)**: уменьшены шрифт имени базы 1С, шрифт имени групп, высота оформления групп и расстояние между группами, а также ширина правой панели (подстраивается под длину названий команд и выпадающих меню). **Linux/Avalonia**: метрики компактного режима пересмотрены в [`UiMetrics.Avalonia.cs`](Configuration Management/Controls/UiMetrics.Avalonia.cs) — имя базы `RowNameFont` 13.5 → 12.5; добавлены метрики имени группы `GroupNameFont`, высоты заголовка `GroupHeaderPadV` и расстояния между группами `GroupHeaderMarginV`; ширина правой панели `RightPanelMin` 220 → 200, `RightPanelMax` 280 → 255; шрифт кнопок действий `ActionFontSize` 11.5 → 11, минимальная высота кнопки `ActionButtonMinHeight` 28 → 26, вертикальный отступ `ActionButtonPadV` 4 → 3. Применение шрифта группы и её оформления обновлено в `BuildGroupRow()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs): в обычном режиме имя группы по-прежнему наследует применяемый к интерфейсу шрифт, а в компактном — задаётся явно меньшим размером. **Windows/WPF**: в компактном режиме для заголовка группы добавлены явные триггеры в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) — шрифт имени 15 → 12, счётчика 13 → 11, иконка 18 → 14, внутренний отступ `Padding` 8,6 → 6,1 (меньше пустого места вокруг названия и ниже строка); промежуток между группами убирается в компактном режиме через [`GroupOffsetConverter.cs`](Configuration Management/Converters/GroupOffsetConverter.cs) (вертикальный зазор 1 → 0, в MultiBinding добавлен флаг `CompactMode`); предел ширины компактной правой панели `MaxWidth` уменьшен 260 → 200, чтобы панель максимально сжималась по длине названий команд и выпадающих меню. + +## [0.3.4.4] — 2026-08-25 + +### Добавлено + +- **Отдельная команда «Проверить доступность всех баз 1С» в верхней панели команд вместо автопроверки при запуске (обе платформы)**. При запуске приложения больше не выполняется фоновая проверка доступности всех информационных баз 1С, поэтому старт не замедляется запросами ко всем базам. Проверка доступности всех баз выполняется только по явной команде из верхней панели — заметная кнопка с подписью «Доступность». **Windows/WPF**: убраны вызовы `RefreshConfigurationInfoAsync()` при инициализации и после перезагрузки списка в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs); кнопка `RefreshAllConfigurationInfoCommand` в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) оформлена как заметная вторичная кнопка с подписью. **Linux/Avalonia**: кнопка `RefreshAllConfigurationInfoCommand` добавлена в `BuildTopBar()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs); метод `RefreshAllConfigurationInfo()` в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs) теперь проверяет доступность всех баз и выводит сводку с перечнем недоступных баз. +- **Новая колонка «Действия» в списке информационных баз (обе платформы)**. В таблицу слева добавлена фиксированная колонка «Действия» с пятью кнопками-иконками для каждой строки-базы: «Запуск 1С:Предприятие» (`LaunchEnterpriseCommand`), «Конфигуратор» (`LaunchConfiguratorCommand`), «Изменить настройки» (`EditInfobaseCommand`), «Очистить кеш» (`ClearCacheCommand`) и «Удалить» (`DeleteInfobaseCommand`). **Windows/WPF**: колонка и кнопки добавлены в заголовок и в шаблон строки в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml); команды доработаны так, чтобы принимать параметр-`Infobase` (с откатом на `SelectedInfobase`) в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs). **Linux/Avalonia**: колонка и кнопки добавлены в строку базы (`BuildInfobaseRow`) и в заголовок (`RefreshColumnHeader`) в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **Кнопка-подсказка «?» перенесена в конец верхней панели команд (обе платформы)**. Кнопка помощи `HelpLink` теперь расположена справа, после всех остальных команд. **Windows/WPF**: `HelpLink` перемещён после кнопки «Настройки» в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: `HelpLink` добавлен в конец `BuildTopBar()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **Модель [`Infobase`](Configuration Management/Models/Infobase.cs): добавлено переопределение доступности**. Появилось свойство `IsAvailable`, учитывающее результат команды проверки, и метод `SetCheckedAvailability(bool?)`, который обновляет статусные свойства (`StatusIconKey`, `StatusColorHex`, `StatusDisplay`) — благодаря этому иконка базы в списке обновляется после проверки. При отсутствии заданного результата используется прежний расчёт по параметрам подключения. + +### Изменено + +- **Кнопка «Доступность» получила понятную иконку-сонар и вывод результата в нижнюю панель на 10 секунд (обе платформы)**. Иконка заменена на иконку подводного сонара `IconSonar` (эхолокатор; геометрия добавлена в [`Icons.axaml`](Configuration Management/Themes/Icons.axaml) и [`Icons.xaml`](Configuration Management/Themes/Icons.xaml); привязка кнопок обновлена в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) и [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). После завершения проверки всех баз в **нижней строке состояния** показывается сообщение «Проверка доступности: всего {0}, недоступно {1}», которое автоматически скрывается через 10 секунд. **Linux/Avalonia**: добавлен механизм временного сообщения `ShowTemporaryStatusMessage(...)` + `ClearStatusMessageAfterDelayAsync(...)` в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs). **Windows/WPF**: сообщение выводится через существующий `SyncMessage` с автоскрытием `ScheduleClearSyncMessage()` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs). +- **Команда «Доступность баз 1С» теперь проверяет фактическую доступность и помечает недоступные базы красным крестиком в списке (обе платформы)**. Кнопка «Доступность» в верхней панели команд больше не читает информацию о конфигурации и не показывает окно-сводку — вместо этого она проверяет доступность каждой базы по факту и сразу обновляет её иконку статуса в списке баз: недоступные базы помечаются красным крестиком. Для файловых баз доступность определяется наличием каталога/файла по пути; для клиент-серверных — реальной попыткой подключения через COM-коннектор (на Linux недоступен, поэтому такие базы считаются недоступными); для веб-баз — заполненностью адреса. Проверки выполняются в фоне, чтобы не блокировать интерфейс. Команда `RefreshAllConfigurationInfoCommand` переименована в `CheckAvailabilityCommand`, методы `RefreshAllConfigurationInfo()` заменены на `CheckAvailability()` + `IsBaseAvailable(...)` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) и [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs); привязка кнопок обновлена в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) и [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **Кнопка «Доступность» стала иконко-кнопкой без подписи (обе платформы)**. В верхней панели команд кнопка больше не содержит текст «Доступность» — остаётся только иконка с подсказкой. Подобрана более подходящая иконка пульса (`IconPulse`): добавлена геометрия в [`Icons.axaml`](Configuration Management/Themes/Icons.axaml) и [`Icons.xaml`](Configuration Management/Themes/Icons.xaml). **Windows/WPF**: кнопка переведена со стиля `SecondaryButton` на `IconButton` в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: кнопка переведена с `TopBarSecondaryButton` на `TopBarIconButton` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **Кнопки «показать/скрыть правую панель» и «текущая сессия» поменялись местами (обе платформы)**. В строке состояния главного окна переключатель видимости блока «Текущая сессия» (`ToggleSessionLaunchPanelCommand`) теперь стоит раньше, а переключатель подробностей правой панели (`ToggleRightPanelDetailsCommand`) — правее. **Windows/WPF**: изменён порядок колонок (`Grid.Column`) кнопок в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: переставлены `Grid.SetColumn(...)` кнопок в `BuildStatusBar()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **При скрытии блока «Текущая сессия» скрывается и разделитель между ним и кнопкой «Перейти по ссылке» (Windows/WPF)**. Разделитель-линия над кнопкой «Перейти по ссылке» (`OpenInfobaseByLinkCommand`) в правой панели теперь привязан к видимости блока сессии: когда `ShowSessionLaunchPanel == false`, разделитель тоже скрывается, чтобы между сессией и кнопкой не оставался лишний отступ. Изменено в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). +- **Кнопки «Добавить» и «Очистить кеш» перенесены в верхнюю панель команд (обе платформы)**. Кнопка «Добавить» (`AddInfobaseCommand`) переехала из правой панели действий в верхнюю полосу команд, рядом с кнопками синхронизации/темы/настроек; кнопка «Очистить кеш» (`ClearCacheCommand`) добавлена в ту же верхнюю панель и действует на выбранную базу (`SelectedInfobase`) — если база не выбрана, команда недоступна (`CanExecute=false`). В колонке «Действия» строки базы кнопка «Очистить кеш» передаёт свою базу параметром, поэтому остаётся включённой независимо от глобального выбора. **Windows/WPF**: кнопки добавлены в верхнюю панель команд и удалены из правой панели в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml); у [`ClearCacheCommand`](Configuration Management/ViewModels/MainViewModel.cs) добавлено `CanExecute` по `SelectedInfobase`. **Linux/Avalonia**: кнопки добавлены в `BuildTopBar()` и удалены из `BuildRightPanel()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs); `ClearCacheCommand` получил `CanExecute` по `SelectedInfobase` в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs). +- **Кнопки «Изменить группу» и «Удалить группу» перенесены в колонку «Действия» для строк-групп (обе платформы)**. В правой части строки каждой группы в дереве списка баз теперь показываются две кнопки-иконки: «Изменить группу» (`EditGroupCommand`) и «Удалить группу» (`DeleteGroupCommand`), размещённые в той же фиксированной правой зоне шириной ~170, что и кнопки строк баз. **Windows/WPF**: добавлены команды `EditGroupCommand`/`DeleteGroupCommand` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), а сами кнопки — в шаблон группы (`HierarchicalDataTemplate DataType=GroupNodeViewModel`) в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: команды добавлены в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), а кнопки — в `BuildGroupRow()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). Правая панель от карточек/кнопок групп очищена; команды остаются доступны в контекстном меню. +- **Кнопки действий убраны из правой панели (обе платформы)**. Из правой панели удалены кнопки/карточки «Запуск 1С:Предприятие», «Конфигуратор», «Изменить настройки», «Очистить кеш» и «Удалить»; команды остаются в контекстном меню и теперь доступны в колонке «Действия». **Windows/WPF**: удалены блоки из [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: удалены карточки запуска/конфигуратора/очистки кеша и кнопки «Изменить»/«Удалить» из `BuildRightPanel()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). +- **Интерпретация переноса «Очистить кеш» (зафиксировано).** Несмотря на то, что отдельная задача позже перенесёт «Добавить» и «Очистить кеш» в верхнюю панель команд, в этой задаче в новую колонку «Действия» добавлены **все пять** кнопок (включая «Очистить кеш»), а с правой панели убраны: запуск Предприятия, Конфигуратор, «Изменить настройки», «Очистить кеш» и «Удалить». +- **Кнопки «Избранное» и «Закрепить» убраны из правой панели действий (обе платформы)**. Кнопки `ToggleFavoriteCommand` / `TogglePinCommand` больше не дублируются в правой панели — они остаются только в строке названия базы в списке баз и в контекстном меню. **Windows/WPF**: удалены обе кнопки из [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: удалены две карточки `CompactActionButton(...)` из [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). Команды остаются доступными. +- **Кнопка показа/скрытия блока «Текущая сессия» в строке состояния (обе платформы)**. Рядом с переключателем правой панели добавлена аналогичная кнопка команды `ToggleSessionLaunchPanelCommand`, управляющая видимостью блока «Текущая сессия». Добавлены команда и свойство-переключатель `ShowSessionLaunchPanel` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) и [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs); кнопка добавлена в строку состояния [`MainWindow.xaml`](Configuration Management/MainWindow.xaml) и [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). Состояние сохраняется в настройках как прежде. +- **Локализация**: добавлен ключ `Main.AvailabilityStatus` («Проверка доступности: всего {0}, недоступно {1}») в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json); добавлены ключи `Main.CheckAvailabilityTooltip`, `Main.AvailabilityTitle`, `Main.AvailabilityDone`, `Main.AvailabilityUnavailableList` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json); добавлены ключи `Main.EditGroupTooltip` и `Main.DeleteGroupTooltip` («Изменить группу» / «Удалить группу») в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json); добавлен ключ заголовка колонки `Column.Actions` («Действия» / «Actions») в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). Tooltip-ключи кнопок переиспользуются существующие. + +### Исправлено + +- **Кнопки «Запуск 1С:Предприятие» и «Конфигуратор» возвращены на правую панель (обе платформы)**. Ранее они были ошибочно удалены из правой панели при переносе действий в новую колонку «Действия» списка баз. Кнопки восстановлены на правой панели и при этом сохранены в колонке «Действия» (оттуда их не удаляем). **Windows/WPF**: split-кнопки запуска — «Запуск 1С:Предприятие» (`LaunchEnterpriseCommand`, кнопка-меню `EnterpriseMenuButton`, пункты `LaunchEnterpriseWithParamsCommand`/`LaunchEnterpriseWithAuthCommand`/`LaunchEnterpriseAsAdminCommand`) и «Конфигуратор» (`LaunchConfiguratorCommand`, кнопка-меню `ConfiguratorMenuButton`, пункты `LaunchConfiguratorWithParamsCommand`/`LaunchConfiguratorAsAdminCommand`) — возвращены в блок «Действия» правой панели над кнопкой «Родной стартер» в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml). **Linux/Avalonia**: primary-кнопка запуска Предприятия (меню `LaunchEnterpriseWithParamsCommand`/`LaunchEnterpriseWithAuthCommand`) и secondary-кнопка Конфигуратора (меню `LaunchConfiguratorWithParamsCommand`) возвращены в `BuildRightPanel()` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). + +### Версия + +- **Версия объединена в 0.3.4.4** (разделы 0.3.4.5–0.3.4.12 сведены в единый раздел; `InformationalVersion` = 0.3.4.4, `Version`/`AssemblyVersion`/`FileVersion` = 0.3.4). Бейдж и заголовок в [`README.md`](README.md); версия в `Settings.About.HelpText` в `ru.json` и `en.json`. + +## [0.3.4.3] — 2026-08-25 + +### Исправлено + +- **Надписи в компактном режиме стали нечитаемо мелкими (обе платформы)**. В компактном режиме шрифты сжимались тем же агрессивным коэффициентом, что и геометрия (`Scale = 0.8` в Avalonia и единый `factor = 0.7` в WPF), из-за чего мелкие подписи «проваливались» до 8–10px. Теперь для текста введён отдельный, более мягкий коэффициент 0.9 — геометрия (отступы, иконки, поля) сжимается как раньше, а надписи уменьшаются лишь на 10% и остаются читаемыми. **Linux/Avalonia**: добавлен [`FontScale`](Configuration Management/Controls/UiMetrics.Avalonia.cs) и метод `ScaledFont(...)` в [`UiMetrics.Avalonia.cs`](Configuration Management/Controls/UiMetrics.Avalonia.cs), все шрифтовые вызовы в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs) переведены на `ScaledFont(...)`, увеличены размеры шрифта имени базы и вторичной информации в строке списка. **Windows/WPF**: в [`ThemeManager.cs`](Configuration Management/Themes/ThemeManager.cs) добавлен отдельный коэффициент `fontFactor` для шрифтов (0.9), тогда как отступы и ширины колонок по-прежнему сжимаются на 0.7. +- **Версия обновлена до 0.3.4.3** (`InformationalVersion` = 0.3.4.3 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.2] — 2026-08-25 + +### Исправлено + +- **Устранено предупреждение компилятора CS8604 (nullable) в [`LocalizationManager.cs`](Configuration Management/Localization/LocalizationManager.cs)**. Параметр `SetLanguage` (`code`) фактически мог принимать `null` (метод уже корректно обрабатывал это через `code ?? "null"` в диагностической строке), но `code` передавался в `Dictionary.TryGetValue` с non-nullable ключом, из-за чего сборка `Release` выдавала предупреждение «Возможно, аргумент-ссылка, допускающий значение NULL, для параметра "key"». Параметр объявлен как `string?`, перед поиском языка добавлена явная проверка `code is null`. Сборки `Debug` и `Release` теперь выполняются без ошибок и предупреждений (0 ошибок, 0 предупреждений). +- **Версия обновлена до 0.3.4.2** (`InformationalVersion` = 0.3.4.2 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.4.1] — 2026-08-24 + +### Добавлено + +- **Кнопка «Скопировать техническую информацию» во вкладке «О программе» окна настроек** (обе платформы). Одна кнопка копирует в буфер обмена **обезличенный** технический отчёт, по которому можно понять, в чём проблема: версия приложения, тип интерфейса (WPF/Avalonia), ОС, архитектура, среда выполнения .NET, число логических процессоров, объём физической памяти и памяти процесса, язык/регион. Личные данные (имя пользователя, имя компьютера, домен, сетевые адреса, пути профилей) намеренно исключены. + - **Кросс-платформенный сервис** [`TechnicalInfoService.cs`](Configuration Management/Services/TechnicalInfoService.cs) — собирает отчёт через кроссплатформенные API .NET (`RuntimeInformation`, `Environment`, `GC`); физическая память читается через `GlobalMemoryStatusEx` на Windows и `/proc/meminfo` на Linux. + - **Windows/WPF**: кнопка в [`SettingsWindow.xaml`](Configuration Management/SettingsWindow.xaml), обработчик `OnCopyTechInfo_Click` в [`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs) (`Clipboard.SetText`). + - **Linux/Avalonia**: кнопка в [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs) (`Clipboard.SetTextAsync`), подтверждение через `MessageWindow`. + - **Локализация**: ключи `Settings.About.CopyTechInfo`, `Settings.About.TechInfoCopied/CopyFailed`, `Settings.About.TechInfo.*` добавлены в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). +- **Версия обновлена до 0.3.4.1** (`InformationalVersion` = 0.3.4.1 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.58] — 2026-08-24 + +### Исправлено + +- **Зависание Windows-версии при запуске с пустым списком баз (диалог «Импорт» → «Выход»)** ([`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs)). Предложение загрузить базы из `ibases.v8i` показывалось модальным `MessageBox` прямо из конструктора вьюмодели — до того, как главное окно было показано. Модальный диалог без активного окна-владельца на Windows зависал (в т.ч. при нажатии «Выход»). Запрос теперь откладывается через `Dispatcher` до завершения отрисовки первого кадра, когда окно уже на экране и модальный цикл сообщений работает корректно. +- **Версия обновлена до 0.3.3.58** (`InformationalVersion` = 0.3.3.58 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.57] — 2026-08-24 + +### Исправлено + +- **Зависание при запуске (в т.ч. после действий «Импорт» / «Выход») из-за отложенной инициализации главного окна** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). Изменение из `0.3.3.55` («окно быстрее появляется на старте») переносило загрузку данных на следующий кадр через `Dispatcher.UIThread.Post`. Внутри отложенного колбэка могли открываться модальные диалоги (импорт/восстановление конфига), чей вложенный цикл сообщений не завершался корректно, и приложение зависало. Отложенная инициализация **отменена**: данные снова загружаются синхронно при загрузке окна, как до `0.3.3.55`. Защита от зависания на повреждённых/старых конфиг-файлах (`0.3.3.52`) сохранена. +- **Версия обновлена до 0.3.3.57** (`InformationalVersion` = 0.3.3.57 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.56] — 2026-08-24 + +### Изменено + +- **Исправлено сбрасывание своей цветовой схемы на дефолтные при переключении светлой/тёмной темы** ([`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), [`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs), [`App.xaml.cs`](Configuration Management/App.xaml.cs), [`App.axaml.cs`](Configuration Management/App.axaml.cs)). Ранее при переключении темы активная схема подменялась встроенной (по умолчанию) схемой другой темы, из-за чего кастомизированное оформление «сбрасывалось». Теперь пользовательские схемы для светлой и тёмной базовой темы хранятся **раздельно и независимо** (`LightColorScheme` / `DarkColorScheme` в [`AppSettings.cs`](Configuration Management/Models/AppSettings.cs)): переключение темы применяет схему целевой темы (сохранённую пользовательскую или встроенную), а правки цветов в настройках влияют **только на выбранную тему**, не затрагивая другую. Для старых настроек предусмотрена миграция с одиночного `ActiveColorScheme`. Добавлена временная диагностика `%TEMP%\cm_theme_debug.log` для подтверждения корректности. +- **Версия обновлена до 0.3.3.56** (`InformationalVersion` = 0.3.3.56 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.55] — 2026-08-24 + +### Изменено + +- **Главное окно быстрее появляется на старте** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). Инициализация данных (загрузка списка баз/групп, построение дерева, повторное сканирование платформ и шаблонов после восстановления конфига) теперь откладывается на следующий кадр: окно показывается сразу, а тяжёлая работа выполняется сразу после отрисовки первого кадра. Первый запуск после обновления (когда конфиг самовосстанавливается) становится визуально заметно быстрее. +- **Версия обновлена до 0.3.3.55** (`InformationalVersion` = 0.3.3.55 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.54] — 2026-08-24 + +### Изменено + +- **Вкладка «Резервное копирование» поднята выше и получила значок** ([`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs)). В WPF-версии вкладка теперь вставляется перед «О программе», а не в самый конец (в Avalonia она и так шла выше «О программе»). К названию вкладки добавлен значок слева: `PackIcon BackupRestore` в WPF ([`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs)) и контурная иконка базы через `StreamGeometry` в Avalonia ([`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs)) — как у остальных вкладок окна настроек. +- **Версия обновлена до 0.3.3.54** (`InformationalVersion` = 0.3.3.54 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.53] — 2026-08-24 + +### Добавлено + +- **Резервное копирование профиля приложения в произвольный каталог и его восстановление после переустановки системы** (обе платформы). Новая вкладка **«Резервное копирование»** в окне настроек позволяет указать произвольный каталог и одной кнопкой сохранить туда весь «профиль» приложения: настройки интерфейса (`settings.json`), список информационных баз вместе с пользователями и паролями запуска (`infobases.json`), дерево групп (`groups.json`) и стандартный файл списка баз 1С `ibases.v8i`. После переустановки системы достаточно указать тот же каталог и нажать «Восстановить профиль» либо включить флажок «Восстанавливать профиль при запуске» — тогда при каждом запуске файлы профиля автоматически копируются обратно в каталог данных, и приложение сразу открывается привычно настроенным. + - **Кросс-платформенный сервис** [`ProfileBackupService.cs`](Configuration Management/Services/ProfileBackupService.cs) — копирует/восстанавливает файлы поимённо; источник и цель `ibases.v8i` определяются по настроенному пути или стандартному месту 1С. + - **Linux/Avalonia**: вкладка в [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs), методы в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), восстановление при запуске в [`App.axaml.cs`](Configuration Management/App.axaml.cs). + - **Windows/WPF**: вкладка строится в [`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs) и добавляется в `TabControl` ([`SettingsWindow.xaml`](Configuration Management/SettingsWindow.xaml)), поля и методы в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), восстановление при запуске в [`App.xaml.cs`](Configuration Management/App.xaml.cs). + - **Настройки** [`AppSettings.cs`](Configuration Management/Models/AppSettings.cs) — добавлены `ProfileBackupDirectory` и `ProfileRestoreOnStartup` (добавление обратносовместимо). + - Восстановление при запуске выполняется до загрузки данных главного окна, поэтому базы, группы, настройки и `ibases.v8i` подхватываются без ручных действий. +- **Версия обновлена до 0.3.3.53** (`InformationalVersion` = 0.3.3.53 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.52] — 2026-08-24 + +### Исправлено + +- **Зависание при запуске со старыми или повреждёнными конфигурационными файлами** ([`InfobaseRepository.cs`](Configuration Management/Services/InfobaseRepository.cs), [`AppSettings.cs`](Configuration Management/Models/AppSettings.cs)). В настройки добавлена версия схемы (`settings.json`): если файл настроек повреждён или создан более новой версией приложения, чем текущая, он автоматически откладывается в резервную копию (`*.bak`), а приложение стартует с чистыми настройками вместо зависания. Повреждённые `infobases.json` и `groups.json` при ошибке чтения также переносятся в резервную копию и заменяются пустыми списками. Резервные копии сохраняются рядом с исходными файлами в каталоге данных приложения; текущая версия схемы проставляется при сохранении настроек. +- **Версия обновлена до 0.3.3.52** (`InformationalVersion` = 0.3.3.52 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.51] — 2026-08-24 + +### Изменено + +- **Исправлена команда «Очистить кеш» в контекстном меню дерева баз** ([`MainWindow.xaml`](Configuration Management/MainWindow.xaml), [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs), [`LightTheme.xaml`](Configuration Management/Themes/LightTheme.xaml), [`DarkTheme.xaml`](Configuration Management/Themes/DarkTheme.xaml)). Ранее пункт «Очистить кеш» был просто контейнером подменю, а шаблон пункта меню `ModernMenuItem` не выводил ни стрелку раскрытия, ни само подменю (Popup), поэтому клик по пункту ничего не делал. Теперь пункт устроен по аналогии со split-кнопкой «Очистить кеш» в правой панели: сам заголовок «Очистить кеш» открывает окно очистки кеша (команда `ClearCacheCommand`), а стрелка справа раскрывает подменю с выбором типа кеша («Программный кеш», «Пользовательский кеш», «Программный и пользовательский»). Для этого шаблон `ModernMenuItem` дополнен поддержкой подменю (`Role = SubmenuHeader`: стрелка + `Popup` с оформлением под контекстное меню) в светлой и тёмной темах. +- **Версия обновлена до 0.3.3.51** (`InformationalVersion` = 0.3.3.51 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.50] — 2026-08-24 + +### Изменено + +- **Исправлен сброс цветового оформления на дефолтные при переключении светлой/тёмной темы** ([`AppSettings.cs`](Configuration Management/Models/AppSettings.cs), [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs), [`App.xaml.cs`](Configuration Management/App.xaml.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs), [`App.axaml.cs`](Configuration Management/App.axaml.cs)). Ранее настройки цвета хранились в единственном `ActiveColorScheme`, и переключение темы (кнопка на верхней панели, радиокнопки «Светлая/Тёмная» в настройках) применяло встроенную схему по умолчанию, затирая кастомизированное оформление; при настройке цветов на светлой теме сохранённая тема могла сбрасываться на дефолт. Теперь пользовательские схемы для светлой и тёмной базовой темы хранятся раздельно (`LightColorScheme` / `DarkColorScheme`): переключение темы применяет схему целевой темы (сохранённую пользовательскую или встроенную), ни одна из настроенных схем не теряется; кастомизация светлой темы больше не затрагивает тёмную и наоборот. Для старых настроек предусмотрена миграция с одиночного `ActiveColorScheme`. +- **Версия обновлена до 0.3.3.50** (`InformationalVersion` = 0.3.3.50 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены. + +## [0.3.3.49] — 2026-08-24 + +### Слияние исправлений от ksv47 + +- **Влит PR #61 «Порт на Linux: читаемый справочник ключей в параметрах запуска, кнопка тегов и меню трея, показывающее текущее состояние списка»** (ветка `ksv47/linux-fixes-2`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Читаемый справочник ключей в параметрах запуска** ([`LaunchParametersWindow.Avalonia.cs`](Configuration Management/LaunchParametersWindow.Avalonia.cs)) — размеры приведены к WPF-версии: окно 800 при минимуме 720, колонка ключа 220 с зазором между колонками, обрезка длинных значений многоточием. Длинные ключи (например `/RunModeManagedApplication`) теперь помещаются целиком, а не слипаются с описанием. + - **Кнопка тегов больше не налезает на подпись «Название»** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)) — ширина блока кнопок шапки измеряется по правому краю содержимого (Bounds панели обрезан узкими колонками заголовка), измеренная ширина сохраняется и обновляет минимум области списка; расчёт по константам остаётся только оценкой до первой раскладки. Также: фокусная рамка сегментной кнопки больше не меняет её ширину (подпись не уезжает на четыре пикселя при обходе с клавиатуры), а подписка на размеры шапки — одна вместо двух. + - **Меню трея показывает текущее состояние списка** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — меню собиралось один раз при создании значка и предлагало базу, выбранную на момент запуска, и устаревший список недавних. Штатный `NativeMenu.NeedsUpdate` под KDE не приходит, поэтому меню пересобирается по событию открытия и по изменению самих данных (переименование, импорт, синхронизация, удаление), пересборка ставится в очередь диспетчера и сверяет отпечаток состава (одно действие больше не даёт несколько полных пересборок), а запуск из меню проверяет, что база ещё в списке. + - **Исправлена перегрузка `SetProperty` со списком связанных свойств** ([`ViewModelBase.Avalonia.cs`](Configuration Management/ViewModels/ViewModelBase.Avalonia.cs)) — перегрузка вызывала базовую без имени, `CallerMemberName` подставлял «SetProperty» вместо имени свойства, и подписчики на имя свойства не получали уведомлений. Перегрузка переименована в `SetPropertyWithRelated`, имя свойства передаётся явно (все четыре места использования исправлены, включая пятый вызов `SessionClient`). +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #61`](https://github.com/ksv47/linux-fixes-2)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.49** (`InformationalVersion` = 0.3.3.49 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.48] — 2026-08-24 + +### Слияние исправлений от ksv47 + +- **Влит PR #60 «Linux/Avalonia: обслуживание и опасные операции на вкладке „Базы“, трей и упаковка в deb/AppImage»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Обслуживание и опасные операции на вкладке «Базы»** ([`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs), [`InfobaseMaintenanceService.Linux.cs`](Configuration Management/Services/InfobaseMaintenanceService.Linux.cs), [`LinuxProcess.cs`](Configuration Management/Services/LinuxProcess.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — перенесены блоки удаления отсутствующих файловых баз, завершения процессов 1С и очистки всего списка. Признак процесса 1С стал надёжным (исполняемый файл в каталоге установки платформы либо известное имя, а не подстрока «1cv8» в командной строке); счёт, разбивка по именам и завершение работают по одному снимку `/proc`; перед сигналом сверяется время старта PID (ядро переиспользует номера); завершение уносит дерево потомков, ожидание выхода — один общий цикл; итог называется числом («завершено N, не удалось M»). Удаление отсутствующих баз использует трёхзначную проверку (есть / точно нет / проверить не удалось — недоступность через `Directory.Exists` не считается отсутствием), операции пишут на диск до изменения списка в памяти, снята зависимость от регистра имени `1Cv8.1CD`. + - **Трей** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs)) — настройки «Значок в области уведомлений», «Сворачивать в трей при закрытии» и «По Esc сворачивать в трей» начали работать: значок создаётся всегда, настройка управляет видимостью и применяется сразу; закрытие/Esc уводят в трей по условиям (Esc только при включённом значке, закрытие — только когда его инициировал пользователь, чтобы не прерывать завершение сеанса рабочего стола); модальные окна не встают поверх спрятанного окна; окно-владелец выбирается одинаково во вьюмодели и диалоговом сервисе. + - **Очистка кеша** ([`CacheCleanWindow.Avalonia.cs`](Configuration Management/CacheCleanWindow.Avalonia.cs)) — окно открывается модально с владельцем и получает копию списка баз (пока оно открыто, список нельзя изменить из главного окна); счётчик «Выбрано: N из M» перенесён в шапку списка и окрашен цветом темы; откат при очистке проверяется. + - **Упаковка в deb и AppImage** (`package/linux/*`) — сборка доведена до работающей: `DEBIAN/control` получил завершающую пустую строку, версия пакета берётся из `csproj` (а не расходится с ним), размер установки считается по собранному дереву, `appimagetool` находит desktop-файл и иконку (кладутся и в корень AppDir, и `.DirIcon`). Устранена уязвимость `AppRun` (`$SELF/usr/lib` давала завершающее двоеточие — поиск библиотек в текущем каталоге), зависимости перечислены по нуждам .NET/Avalonia с GTK в `Recommends`, права каталогов фиксированы (`umask 022`), добавлены файл о лицензии, контрольные суммы и поле `Homepage`, имя AppImage содержит версию, `.desktop` получил языковые ключи, скриптам возвращён исполняемый бит. Артефакты упаковки добавлены в `.gitignore`. + - **Прочее** — окно настроек переключает язык целиком (подписка на смену языка включается явно); предупреждение про остановку кластера показывается только когда серверные процессы действительно найдены; итог завершения считается по факту. +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #60`](https://github.com/ksv47/linux-fixes)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.48** (`InformationalVersion` = 0.3.3.48 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). + +## [0.3.3.47] — 2026-08-24 + +### Слияние исправлений от ksv47 + +- **Влит PR #59 «Linux/Avalonia: каталоги шаблонов и операции со списком баз на вкладке „Базы“»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Каталоги шаблонов конфигураций** ([`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — на вкладку «Базы» перенесён блок каталогов шаблонов (как в WPF-версии): список путей с добавлением, изменением, удалением и кнопкой «Из 1С», которая берёт пути из настроек платформы и стандартного каталога `tmplts`. Список каталогов теперь отдаётся сервису поиска шаблонов при запуске и при каждом сохранении настроек; пустые элементы в списке больше не роняют окно. + - **Операции со списком баз** — выгрузка в JSON, загрузка из JSON с заменой текущего списка и разовый импорт из `ibases.v8i`. Метка времени в имени файла выгрузки и её шаблон настраиваются там же, предпросмотр собран теми же ключами локализации, что и в оригинале; диалог сохранения дописывает расширение из предложенного имени. + - **Безопасный импорт из `ibases.v8i`** ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — импорт идёт в копии списков, при удалениях спрашивается подтверждение с числами, рабочие списки заменяются только после согласия; защита от пустого файла. Результат импорта показывается диалогом с добавленными, обновлёнными, пропущенными и созданными группами (как в Windows-версии). Неудачная запись на диск больше не выдаётся за успех; ручная синхронизация с `ibases.v8i` сведена к тому же пути (сохраняет созданные импортом группы и не оставляет выбор на удалённой базе). + - **Прочее** — подсказка про каталоги шаблонов добавлена отдельным ключом локализации (в авторской указан путь Windows, здесь нужен Linux); добавлены ключи `ru.json`/`en.json` и расширен [`AvaloniaDialogService.cs`](Configuration Management/Services/AvaloniaDialogService.cs). +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #59`](https://github.com/ksv47/linux-fixes)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.47** (`InformationalVersion` = 0.3.3.47 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). + +## [0.3.3.46] — 2026-08-24 + +### Слияние исправлений от ksv47 + +- **Влит PR #58 «Linux/Avalonia: выпадающие меню кнопок запуска, вертикальная полоса прокрутки списка, клавиши прокрутки и разовый запуск с параметрами/авторизацией»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Выпадающие меню у кнопок запуска** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)) — кнопки «1С:Предприятие» и «Конфигуратор» превращены в split-кнопки: `BuildLaunchSplitButton` строит основную кнопку со стрелкой и контекстным меню дополнительных вариантов (как в WPF-версии). Для «1С:Предприятие» доступны пункты «Запустить с выбором параметров» (`LaunchEnterpriseWithParamsCommand`) и «Запустить с аутентификацией» (`LaunchEnterpriseWithAuthCommand`); для «Конфигуратора» — «Запустить с выбором параметров» (`LaunchConfiguratorWithParamsCommand`). Пункт «от имени администратора» на Linux не переносится: повышения прав через оболочку нет, а запуск клиента 1С от root оставил бы в домашнем каталоге пользователя файлы, которые ему не принадлежат. Подсказка стрелки — `Main.MoreLaunchOptions`. + - **Запуск с разовыми параметрами и разовой авторизацией** ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — новые команды `LaunchEnterpriseWithParamsCommand`, `LaunchConfiguratorWithParamsCommand` и `LaunchEnterpriseWithAuthCommand`. `LaunchWithParams(kind)` открывает [`LaunchParametersWindow`](Configuration Management/LaunchParametersWindow.Avalonia.cs), правит параметры базы только на один запуск и в `finally` возвращает сохранённое значение + вызывает `SaveSilently()` (успешный запуск сохраняет базы изнутри, иначе подменённые параметры остались бы на диске навсегда). `LaunchWithAuth()` на один раз очищает сохранённые имя/пароль и режим аутентификации (включая отдельную авторизацию Предприятия `EnterpriseAuth` базы), чтобы платформа спросила учётные данные сама; прежние значения возвращаются в `finally` + `SaveSilently()`. + - **Вертикальная полоса прокрутки списка баз больше не уезжает за границу** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)) — полоса вынесена отдельным столбцом справа, вне области горизонтальной прокрутки, поэтому остаётся у правого края даже когда колонки шире окна (собственная полоса дерева раньше рисовалась у края содержимого и уезжала). `AttachVerticalScrollBar()` связывает внешнюю полосу с внутренней прокруткой дерева (`ScrollViewer`), синхронизируя `Offset`/`Extent`/`Viewport`/`SmallChange`/`LargeChange` через `PropertyObserver`; колесо мыши над полосой обрабатывается с шагом 50 (с `Shift` дельта становится горизонтальной); полоса начинается под шапкой колонок. + - **Клавиши `Home`/`End`/`PageUp`/`PageDown` прокручивают список** ([`LeveledTreeView.Avalonia.cs`](Configuration Management/Controls/LeveledTreeView.Avalonia.cs)) — туннельный обработчик `KeyDownEvent` (`OnNavigationKeyDown`): голые клавиши переносят выделение (Home → начало, End → конец, PageUp/PageDown → на страницу по координатам), с `Ctrl` — прокручивают, не трогая выделение (`ScrollBy`). Защита от внутренней прокрутки дерева (она помечает PageUp/PageDown обработанными), гашение события на пустом списке, `VisibleRows()` собирает строки по контейнерам, `PageStep()` считает шаг по координатам, а не по числу строк. + - **Компоновка окна «Параметры запуска»** ([`LaunchParametersWindow.Avalonia.cs`](Configuration Management/LaunchParametersWindow.Avalonia.cs)) — порядок `RowDefinition` исправлен: звёздочка (`Star`) у справочника стоит после `Auto`-строк, поэтому по высоте тянется справочник, а подпись не выдавливает кнопки за край окна. + - **Убраны задвоенные ключи локализации** — из [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json) удалены продублированные ключи `Settings.General.AfterLaunchAction*` (вариант без завершающего двоеточия), оставлен актуальный. +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #58`](https://github.com/ksv47/linux-fixes)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.46** (`InformationalVersion` = 0.3.3.46 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.45] — 2026-08-24 + +### Слияние исправлений от ksv47 + +- **Влит PR #56 «Linux/Avalonia: освобождение подписок, оформление окон сообщений темой, кисти через динамический ресурс и прокрутка после пересборки дерева»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Устранение утечек подписок диалогов на ресурсы темы и локализацию** ([`ModalWindowBase.cs`](Configuration Management/ModalWindowBase.cs)) — переопределён `OnClosed`: окно отписывается от `LocalizationManager.Instance.LanguageChanged` (событие живёт у синглтона локализации, а обработчик — метод экземпляра; без отписки закрытое окно оставалось бы достижимым). Кисти темы отписывать не нужно — их привязку держит сам элемент. Подписки диалогов освобождены также в [`ColorPickerWindow.Avalonia.cs`](Configuration Management/ColorPickerWindow.Avalonia.cs), [`GroupPickerWindow.Avalonia.cs`](Configuration Management/GroupPickerWindow.Avalonia.cs), [`GroupSettingsWindow.Avalonia.cs`](Configuration Management/GroupSettingsWindow.Avalonia.cs) и [`PlatformVersionPickerWindow.Avalonia.cs`](Configuration Management/PlatformVersionPickerWindow.Avalonia.cs). + - **Окна сообщений оформлены темой** ([`AvaloniaDialogService.cs`](Configuration Management/Services/AvaloniaDialogService.cs)) — `MessageWindow` красится кистями темы через `ThemeBrushes.Bind`: фон окна (`ContentBackgroundColorBrush`), текст сообщения (`TextPrimaryColorBrush`), кнопка OK (`AccentColorBrush`/`TextOnAccentColorBrush`) и кнопка отмены (`SecondaryButtonBackgroundColorBrush`/`ButtonTextColorBrush`). Значение `Result` по умолчанию теперь отрицательное для вопроса (закрытие крестиком или Alt+F4 не должно означать согласие перед удалением базы/группы/схемы), а для сообщения — всегда положительное. Кнопки вопроса локализованы как «Да»/«Нет» (`Common.Yes`/`Common.No`) вместо «OK/Отмена». + - **Кисти темы через динамический ресурс вместо ручных подписок** ([`ThemeBrushes.Avalonia.cs`](Configuration Management/Themes/ThemeBrushes.Avalonia.cs)) — `Bind` переведён на `DynamicResourceExtension` (привязку отслеживает сам элемент, освобождать её не нужно и нельзя); добавлен `Observe(target, brushKey, apply)` для кистей в код, зависящих от состояния (наведение/фокус), подписка живёт вместе с элементом через `AttachedToVisualTree`/`DetachedFromVisualTree`, чтобы не удерживать пересобранное содержимое окна сильной ссылкой. Привязка ресурса ищется по цепочке логических родителей до окна. Аналогично доработаны [`IconHelper.Avalonia.cs`](Configuration Management/Converters/Avalonia/IconHelper.Avalonia.cs) и [`UiMetrics.Avalonia.cs`](Configuration Management/Controls/UiMetrics.Avalonia.cs). + - **Прокрутка переживает пересборку дерева** ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)) — введено событие `TreeRebuilding` (поднимается до смены состава списка — окно запоминает прокрутку) и `TreeRebuilt` (после обновления — возвращает выделение строки и позицию прокрутки). События поднимаются в `ApplyFilter()`, потому что список меняет состав и мимо полной пересборки: через поиск, вкладки «Все базы/Избранное/Недавние», отбор по тегам, переключатель тегов и группировки. +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #56`](https://github.com/ksv47/linux-fixes)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.45** (`InformationalVersion` = 0.3.3.45 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). + +## [0.3.3.44] — 2026-08-23 + +### Локализация (Windows/WPF) + +- **Исправлена регрессия: вместо переводов отображались сырые ключи локализации (например «Main.AllBases»)** — [`LocExtension.cs`](Configuration Management/Localization/LocExtension.cs) возвращён к КОНВЕРТЕРНОЙ привязке (`Source = LocalizationManager.Instance.Source`, `Mode = BindingMode.OneWay`, `Converter = LocalizationValueConverter.Instance`, `ConverterParameter = Key`). Ранее (в 0.3.3.43) привязка была заменена на индексаторную (`Path = new PropertyPath($"[{EscapeIndexerKey(Key)}]")`), которая некорректно резолвится в индексатор `LocalizationSource[string]` и возвращала сырой ключ вместо перевода. Убраны ставшие ненужными `using System.Text;`, `using System.Windows;` и метод `EscapeIndexerKey`. +- **Надёжное динамическое обновление через регистрацию выражений привязки** — в [`LocalizationSource.cs`](Configuration Management/Localization/LocalizationSource.cs) добавлен платформонезависимый механизм: `RegisterForUpdate(object target, Action update)` хранит СЛАБУЮ ссылку на объект и делегат обновления (без захвата target), а `NotifyAll()` после поднятия `PropertyChanged("Item[]")`/`PropertyChanged(string.Empty)` вызывает `UpdateTarget()` у всех зарегистрированных выражений (обёртка в try/catch, защита от выброшенных/отвязанных ссылок, периодическая очистка «мёртвых» ссылок по достижении порога). `LocExtension.ProvideValue` регистрирует полученный `BindingExpression`. `LocalizationSource` не зависит от WPF-типа `BindingExpression` напрямую, поэтому сборка Avalonia (Linux) не ломается. +- **Сохранён проход по визуальному дереву** — в [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs) `RefreshAllBindingsOnVisualTree()` в `RebuildAfterLanguageChange()` остался без изменений: он по-прежнему закрывает MultiBinding-подсказки кнопок запуска (`Path="Source"` + конвертер), которые регистрация из п.2 не покрывает. +- Переводы снова корректно отображаются при загрузке и обновляются динамически при смене языка без перезапуска приложения. + +### Документация + +- **Версия обновлена до 0.3.3.44** (`InformationalVersion` = 0.3.3.44 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.43] — 2026-08-23 + +### Локализация (Windows/WPF) + +- **Исправлено: статичные подписи главного окна не переводились сразу при смене языка** — корневая причина устранена в [`LocExtension.cs`](Configuration Management/Localization/LocExtension.cs). Раньше `ProvideValue` создавал привязку с пустым `Path` (`Source` + конвертер по `ConverterParameter=Key`), а привязка WPF без пути НЕ подписывается на `INotifyPropertyChanged` источника, поэтому `LocalizationSource.NotifyAll()` (`PropertyChanged(string.Empty)`) не обновляла такие элементы — они получали значение один раз при создании и оставались на старом языке до перезапуска. + Теперь `ProvideValue` строит ИНДЕКСАТОРНУЮ привязку: `Path = new PropertyPath($"[{EscapeIndexerKey(Key)}]")` (с экранированием `\` и `"` в ключе), `Source = LocalizationManager.Instance.Source`, `Mode = BindingMode.OneWay`. Конвертер больше не используется — источник возвращает перевод через `this[string key]`. Индексаторные привязки WPF корректно реагируют на `PropertyChanged("Item[]")`, которое поднимает `NotifyAll()`, поэтому все `{loc:Loc}` в любом окне (вкладки «Все базы/Избранное/Недавние», кнопки панели, заголовки колонок, пункты меню и т.п.) переводятся мгновенно, без перезапуска. +- **Принудительное обновление привязок по визуальному дереву окна** — в [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs) в `RebuildAfterLanguageChange()` после обновления заголовка/темы/трея теперь вызывается `RefreshAllBindingsOnVisualTree()`, который через `VisualTreeHelper` (с переходом в логическое дерево, где необходимо) обходит все `DependencyObject` окна и для каждой локальной привязки (`GetLocalValueEnumerator()` + `BindingOperations.GetBindingExpressionBase(dp).UpdateTarget()`) принудительно обновляет целевое значение. Это закрывает и те элементы, которые не реагируют на `PropertyChanged("Item[]")` (например MultiBinding-подсказки кнопок запуска с `Path="Source"` + конвертер). Всё обёрнуто в try/catch — сбой одной привязки не прерывает пересборку интерфейса. Существующая подписка/обработчик смены языка сохранены. +- Смена языка работает в обе стороны (ru ↔ en) и на внешние языки; главное окно полностью переводится без перезапуска. + +### Документация + +- **Версия обновлена до 0.3.3.43** (`InformationalVersion` = 0.3.3.43 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.42] — 2026-08-23 + +### Локализация (Windows/WPF) + +- **Полная динамическая смена языка интерфейса без перезапуска для локализованных свойств VM и дерева групп** — [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs) (Windows/#if WINDOWS) теперь подписывается на `LocalizationManager.Instance.LanguageChanged` в конструкторе. Новый обработчик `OnLanguageChanged` (с маршалингом на UI-поток через `Dispatcher`) вызывает `HandleLanguageChanged()`, который: + - вызывает `OnPropertyChanged(string.Empty)` — WPF пересчитывает все привязки к VM, обновляя локализованные свойства (`StatusBarInfo`, `ExportIndicatorTooltip`, `RightPanelToggleTooltip`, `GroupByGroupText`, `SyncMessage` и др.), которые возвращают `LocalizationManager.T(...)` на лету; + - вызывает `RebuildGroupTree()` — дерево пересобирается целиком, поэтому служебные узлы («Все базы», «Без группы», «Избранное») через `GroupNodeViewModel.DisplayName` сразу получают тексты на новом языке. + Работает для любого направления (ru ↔ en и внешние языки). Ранее эти элементы обновлялись только после перезапуска, хотя XAML-привязки `{loc:Loc}` уже обновлялись через `Source.NotifyAll()`. +- **Корректная отписка от события** — добавлен публичный метод `UnsubscribeLanguageChanged()` с флагом `_languageChangedSubscribed` (защищает от дублирования подписки); вызывается из `MainWindow.OnClosing` при полном закрытии окна, чтобы не было утечек. + +### Документация + +- **Версия обновлена до 0.3.3.42** (`InformationalVersion` = 0.3.3.42 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.41] — 2026-08-23 + +### Локализация (Windows/WPF) + +- **Динамическая смена языка интерфейса без перезапуска (Windows/WPF)** — главное окно [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs) теперь подписывается на `LocalizationManager.Instance.LanguageChanged` (по аналогии с Avalonia-версией [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). При смене языка в настройках новый обработчик `OnLanguageChanged`/`RebuildAfterLanguageChange` вручную обновляет элементы, заданные в code-behind: заголовок окна (`Title` с версией — локальное значение перекрывает XAML-привязку `{loc:Loc App.Title}`), подсказку кнопки смены темы ([`UpdateThemeButton()`](Configuration Management/MainWindow.xaml.cs)), а также текст и меню трея ([`RebuildTrayMenu()`](Configuration Management/MainWindow.xaml.cs)). Событие обрабатывается на UI-потоке через диспетчер (`Dispatcher.CheckAccess`/`BeginInvoke`) — работает для любого направления (ru ↔ en и внешние языки). +- **Корректная отписка от события** — в [`OnClosing`](Configuration Management/MainWindow.xaml.cs) (ветка реального закрытия, не сворачивания в трей) выполняется `LocalizationManager.Instance.LanguageChanged -= OnLanguageChanged`, что исключает утечку памяти (окно не удерживается менеджером локализации после закрытия). +- Остальные тексты главного окна (кнопки, колонки, тултипы, empty-state и т.п.) объявлены в XAML через `{loc:Loc ...}` и обновляются автоматически через `LocalizationManager.Source.NotifyAll()`, поэтому дополнительной перестройки не требуют. + +### Документация + +- **Версия обновлена до 0.3.3.41** (`InformationalVersion` = 0.3.3.41 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в `README.md` обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.40] — 2026-08-23 + +### Локализация / сохранение настроек + +- **Исправлено: язык интерфейса терялся при закрытии окна (Windows/WPF)** — метод `SaveSettings()` в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs:3282) при построении нового объекта `AppSettings` не задавал свойство `Language`, из-за чего оно сохранялось пустым (`""`). При закрытии главного окна (`MainWindow.OnClosing` → `_viewModel.SaveSettings()`) файл настроек перезаписывался с пустым языком, и при следующем запуске `LocalizationManager.Initialize(settings.Language)` выбирал язык системы вместо выбранного пользователем. Теперь `SaveSettings()` всегда записывает актуальный код языка через `LocalizationManager.Instance.CurrentLanguage` в создаваемый объект `AppSettings.Language`. + +### Документация + +- **Версия обновлена до 0.3.3.40** (`InformationalVersion` = 0.3.3.40 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в `README.md` обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [0.3.3.39] — 2026-08-23 + +### Локализация / сохранение настроек + +- **Сохранение языка при закрытии окна в трей** — при уходе главного окна в трей (`OnClosing`) вызывается `MainViewModel.PersistSettings()`, который записывает текущий код языка в `AppSettings.Language` и сохраняет настройки на диск. Ранее язык сохранялся только при полном выходе через команду «Выход», поэтому при закрытии окна крестиком и последующем завершении из трея выбранный язык мог не сохраниться. +- **Динамическое обновление меню трея при смене языка** — меню и подсказка (`ToolTipText`) значка в трее пересобираются в `RebuildAfterLanguageChange()` через новый метод `BuildTrayMenu()`. Подписи пунктов («Показать окно», «Синхронизация», «Настройки», «Выход» и др.) и подсказка трея сразу переключаются на выбранный язык без перезапуска приложения. +- **Публичный метод `PersistSettings()`** в `MainViewModel` — единая точка сохранения настроек (включая язык), используется и при закрытии в трей, и при полном выходе. + +### Документация + +- **Версия обновлена до 0.3.3.39** (`InformationalVersion` = 0.3.3.39 в `Configuration Management.csproj`). Бейдж и заголовок в `README.md` обновлены; версия в `Settings.About.HelpText` обновлена в `ru.json` и `en.json`. + +## [Не выпущено] + +### Инструменты / валидация локализации + +- **Добавлен автоматический аудит локализации** [`tools/l10n-audit.ps1`](tools/l10n-audit.ps1) (и отчёт [`tools/l10n-audit-report.txt`](tools/l10n-audit-report.txt)). Скрипт (а) сравнивает наборы ключей `ru.json ↔ en.json` и (б) проверяет, что все ключи, используемые в коде/XAML (`LocalizationManager.T("...")`, `{loc:Loc ...}`, `Loc["..."]`, `{Binding Loc[...]}`), присутствуют в `ru.json`. +- **Результат повторной валидации:** расхождений ключей между [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json) нет (1186 = 1186); все 1117 уникальных ключей, найденных в коде/XAML, присутствуют в `ru.json` (и, следовательно, в `en.json`). Захардкоженных пользовательских строк вне локализации в контекстных меню дерева, трей-меню и статус-баре не обнаружено. Дефектов для исправления не выявлено, поэтому номер версии не изменён. + +## [0.3.3.38] — 2026-08-23 + +### Локализация + +- **Динамическая смена языка при выборе в настройках (Avalonia/Linux)** — в [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs) добавлен обработчик `SelectionChanged` у выпадающего списка языка: язык теперь применяется (и сохраняется в настройках) сразу при выборе пункта, без нажатия «OK». Ранее изменение языка фиксировалось только по кнопке «OK», поэтому интерфейс выглядел «непереведённым» при закрытии диалога иначе. Также добавлено переопределение `OnLanguageChanged`, которое пересобирает содержимое окна настроек целиком, чтобы все подписи вкладок и элементов обновились на выбранный язык немедленно (базовая реализация [`ModalWindowBase`](Configuration Management/ModalWindowBase.cs) обновляла только кнопки OK/Отмена). +- **Сохранение языка при закрытии программы** — в [`ExitApplication()`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs:2676) текущий код языка (`LocalizationManager.Instance.CurrentLanguage`) записывается в `AppSettings.Language` перед сохранением настроек. Это гарантирует, что язык не теряется между запусками даже если он определялся автоматически (по языку системы) и не был явно выбран через `ApplyLanguage`. + +### Документация + +- **Версия обновлена до 0.3.3.38** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.38 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.38; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.37] — 2026-08-23 + +### Локализация + +- **Аудит прочих непереведённых строк интерфейса по всему проекту** — проверены все диалоговые окна ([`AddEditWindow`](Configuration Management/AddEditWindow.Avalonia.cs), [`CacheCleanWindow`](Configuration Management/CacheCleanWindow.Avalonia.cs), [`ConnectionSettingsWindow`](Configuration Management/ConnectionSettingsWindow.Avalonia.cs), [`CreateInfobaseWindow`](Configuration Management/CreateInfobaseWindow.Avalonia.cs), [`DeleteInfobaseWindow`](Configuration Management/DeleteInfobaseWindow.Avalonia.cs), [`GroupEditWindow`](Configuration Management/GroupEditWindow.Avalonia.cs), [`GroupPickerWindow`](Configuration Management/GroupPickerWindow.Avalonia.cs), [`GroupSettingsWindow`](Configuration Management/GroupSettingsWindow.Avalonia.cs), [`LaunchParametersWindow`](Configuration Management/LaunchParametersWindow.Avalonia.cs), [`LinkInputWindow`](Configuration Management/LinkInputWindow.Avalonia.cs), [`NameInputWindow`](Configuration Management/NameInputWindow.Avalonia.cs), [`PlatformVersionPickerWindow`](Configuration Management/PlatformVersionPickerWindow.Avalonia.cs), [`SettingsWindow`](Configuration Management/SettingsWindow.Avalonia.cs), [`TagInputWindow`](Configuration Management/TagInputWindow.Avalonia.cs) и др.), контролы (`Controls/*.cs`), сервисы (`Services/*.cs`) и ViewModel (`ViewModels/*.cs`) обеих реализаций (WPF `.xaml`/`.xaml.cs` и Avalonia `.Avalonia.cs`). Способ поиска — regex по кириллице в строковых литералах C# и по содержимому атрибутов XAML/AXAML вне обёртки `{loc:Loc …}`/`Content=`; комментарии не учитывались. +- **Результат: жёстко зашитых пользовательских строк не обнаружено** — весь пользовательский текст (подписи кнопок, заголовки окон, тексты меток, сообщения диалогов/MessageBox, тултипы, пункты контекстных меню, значения выпадающих списков) уже вынесен в локализацию: WPF — `{loc:Loc Key}`, Avalonia — `LocalizationManager.T("Key")`. Ключи [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json) согласованы, недостающих ключей и английских переводов нет. +- **Оставлены без изменений как технические (не показываются пользователю в UI):** значения режимов запуска/клиента («Автоматический», «Тонкий клиент», «Толстый клиент», «Веб-клиент», «Авто», «Обычный», «Тонкий» и т.п.), используемые как внутренние ключи хранения и сопоставляемые с локализованными подписями через маппинги ([`Infobase.cs`](Configuration Management/Models/Infobase.cs), [`IbasesV8iImporter.cs`](Configuration Management/Services/IbasesV8iImporter.cs), [`OneCLauncher*.cs`](Configuration Management/Services/OneCLauncher.cs)); строки журналов (`_logger.*`, `Debug.WriteLine`); диагностические префиксы в логах фатальных ошибок ([`App.xaml.cs`](Configuration Management/App.xaml.cs)); описание окружения Linux для журнала (`LinuxDesktopEnvironment.Describe()`). Их замена на `LocalizationManager.T(...)` сломала бы логику хранения/сопоставления и не дала бы пользы, т.к. они не отображаются в интерфейсе. + +### Документация + +- **Версия обновлена до 0.3.3.37** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.37 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.37; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.36] — 2026-08-23 + +### Локализация + +- **Аудит подсказок (ToolTips) кнопок и переключателей главного окна** — проверены обе реализации ([`MainWindow.xaml`](Configuration Management/MainWindow.xaml): верхняя панель, заголовки колонок, правая панель; [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs): [`BuildTopBar()`](Configuration Management/MainWindow.Avalonia.cs:183), [`RefreshColumnHeader()`](Configuration Management/MainWindow.Avalonia.cs:1931), [`BuildRightPanel()`](Configuration Management/MainWindow.Avalonia.cs:1144), вспомогательные `TopBarPrimaryButton`/`TopBarSecondaryButton`/`TopBarIconButton`/`PrimaryActionButton`/`SecondaryActionButton`/`HeaderIconButton`). Все подсказки уже вынесены в локализацию: WPF — `{loc:Loc Main.*}` (в т.ч. через `MultiBinding` с ключами `Main.LaunchEnterpriseShort`/`Main.LaunchConfiguratorShort` и хоткеем), Avalonia — `LocalizationManager.T("Main.*Tooltip")` и `ToolTip.SetTip(...)`. Жёстко зашитых русских строк в тултипах не обнаружено. +- **Добавлены недостающие тултипы сегментам «Все / Избранное / Недавние» в Avalonia-версии** — в [`BuildListModeSegments()`](Configuration Management/MainWindow.Avalonia.cs:273) переключатели списка баз не имели подсказок, тогда как в WPF они есть. Теперь им задаются существующие ключи `Main.AllBasesTooltip`/`Main.FavoritesTooltip`/`Main.RecentTooltip`. Ключи `Main.*Tooltip` согласованы между [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json); недостающих ключей и английских переводов нет. + +### Документация + +- **Версия обновлена до 0.3.3.36** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.36 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.36; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.35] — 2026-08-23 + +### Локализация + +- **Аудит текстов кнопок правой панели главного окна** — проверены обе реализации ([`MainWindow.xaml`](Configuration Management/MainWindow.xaml) и [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), метод [`BuildRightPanel()`](Configuration Management/MainWindow.Avalonia.cs:1144)). Все подписи кнопок, заголовки секций, тексты split-кнопок и пункты их контекстных меню уже вынесены в локализацию: WPF использует `{loc:Loc Main.*}` (и `{loc:Loc LinkInput.Title}`, `{loc:Loc Common.Delete}`), Avalonia — `LocalizationManager.T("Main.*")` (вспомогательные `PrimaryActionButton`/`SecondaryActionButton`/`SectionCard` получают текст ключом). Жёстко зашитых русских строк в текстах кнопок правой панели не обнаружено. +- **Исправлен английский перевод** текста переключателя `Main.SessionClientOrdinary` в блоке «Текущая сессия» в [`en.json`](Configuration Management/Localization/Languages/en.json): было «Thin (managed)», стало «Ordinary» (согласовано с `ru.json` — «Обычный»). Ключи `Main.*` правой панели согласованы между `ru.json` и `en.json`; недостающих ключей и переводов нет. + +### Документация + +- **Версия обновлена до 0.3.3.35** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.35 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.35; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.34] — 2026-08-23 + +### Локализация + +- **Аудит названий колонок главного окна** — проверены обе реализации ([`MainWindow.xaml`](Configuration Management/MainWindow.xaml) и [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). Все заголовки колонок уже вынесены в локализацию: WPF использует `{loc:Loc Column.*}`, Avalonia — `LocalizationManager.T("Column.*")` (в методе [`ListColumns()`](Configuration Management/MainWindow.Avalonia.cs)); жёстко зашитых русских строк в названиях колонок не обнаружено. Переводы в [`en.json`](Configuration Management/Localization/Languages/en.json) согласованы с `ru.json`: `Column.Name` = Name, `Column.Version` = Platform version, `Column.Configuration` = Configuration, `Column.LaunchMode` = Launch mode, `Column.ServerBase` = Server/Base, `Column.LastLaunch` = Last launch, `Column.Size` = Size. + +### Документация + +- **Версия обновлена до 0.3.3.34** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.34 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.34; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.33] — 2026-08-23 + +### Исправлено + +- **Смена языка интерфейса в Linux/Avalonia-версии теперь мгновенно обновляет главное окно** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs)). Ранее при выборе другого языка в настройках названия колонок, кнопки правой панели и подсказки кнопок (которые создаются в коде через `LocalizationManager.T(...)`) не перерисовывались — переведённый текст появлялся только после перезапуска приложения, что выглядело как непереведённые элементы интерфейса. Теперь главное окно подписывается на событие [`LocalizationManager.Instance.LanguageChanged`](Configuration Management/Localization/LocalizationManager.cs) и пересобирает своё содержимое: заголовок окна, названия колонок, кнопки правой панели и все подсказки обновляются сразу; компактный режим (`UiMetrics.Compact`) сохраняется, а выделение и прокрутка списка баз восстанавливаются после пересборки. На Windows (WPF) такой проблемы не было — там тексты связаны через `{loc:Loc ...}` и обновляются автоматически через `NotifyAll()`. + +- **Версия обновлена до 0.3.3.33** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.33 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.33; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.32] — 2026-08-23 + +### Добавлено (сборка / упаковка) + +- **Собран единый исполняемый файл для Windows (WPF, self-contained single-file)** через [`build.ps1`](Configuration Management/build.ps1) (`dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true`). Выходной артефакт — один файл `ConfigurationManagement.exe` (~75,5 МБ) в каталоге `Configuration Management/publish/win-x64/`, не требующий установленного .NET Runtime. Нативные библиотеки встроены (`IncludeNativeLibrariesForSelfExtract`), применено сжатие (`EnableCompressionInSingleFile`). Рядом с exe находится только папка `Localization/` для подгрузки внешних `.json`-языков (встроенные `ru`/`en` уже внутри исполняемого файла). + +### Документация + +- **Версия обновлена до 0.3.3.32** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.32 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.32; жёстко прописанная версия в `Settings.About.HelpText` обновлена в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + +## [0.3.3.31] — 2026-08-23 + +### Слияние исправлений от ksv47 + +- **Влит PR #55 «Linux/Avalonia: перетаскивание баз и групп мышью»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. По функциональным направлениям: + - **Перетаскивание баз и групп мышью в Linux/Avalonia-версии** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), [`LeveledTreeView.Avalonia.cs`](Configuration Management/Controls/LeveledTreeView.Avalonia.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)): нажатие ловится туннельным событием `PointerPressed`, узел для переноса фиксируется в нажатии, а не в движении (курсор над дочерней строкой больше не подменяет цель), задан порог сдвига, после которого клик становится перетаскиванием. Сброс допустим только на настоящую группу или корректные служебные узлы (без потери группы базы и без создания циклов в иерархии). Перенос базы выполняется в указанную группу и перед строкой другой базы ([`MoveInfobaseToGroup`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)); перенос подгруппы со всей вложенной иерархией пересчитывает полные пути `Infobase.Group` и свёрнутость узлов без «уезжания» баз в «Без группы» ([`MoveGroupUnder`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)). + - **Действие после запуска базы или конфигуратора в Linux/Avalonia-версии** ([`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs), [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)): как в WPF, окно можно свернуть в трей или увести в трей после успешного запуска; при отсутствии значка трея (например, GNOME Shell без AppIndicator) окно безопасно сворачивается, чтобы не оставаться недоступным. Добавлены ключи локализации `Settings.General.AfterLaunchAction.*` и `Main.AfterLaunchTrayUnavailable` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). + - **Сохранение выбора и подсветки при пересборке дерева**: раскрытие узла теперь связывается с моделью самим `LeveledTreeView` на любом уровне вложенности, а выделение строки восстанавливается после перестройки дерева — по идентификатору группы или ключу узла ([`RestoreTreeSelection`](Configuration Management/MainWindow.Avalonia.cs), событие `TreeRebuilt` в [`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)). + - **Надёжность сохранения**: `SaveSilently()`/`SaveGroupsSilently()` теперь возвращают признак успеха, и экспорт в `ibases.v8i` выполняется только когда удалось записать оба файла — иначе пути баз и дерево групп разъедутся. +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #55`](https://github.com/ksv47/linux-fixes)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.31** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.31 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.31. + +## [0.3.3.30] — 2026-08-23 + +### Удалено (мёртвый код, неиспользуемые файлы и функции) + +Проведена чистка репозитория: удалены файлы, которые не компилировались ни на одной платформе или не использовались нигде в коде/XAML, а также неиспользуемая функция. После удалений проект успешно собирается (`dotnet build`, конфигурация Windows/WPF). + +- **Удалены устаревшие Linux-заглушки, исключённые из сборки и не дававшие кода на Windows (`#if LINUX` + `` в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)):** + - [`Themes/ThemeManager.Linux.cs`](Configuration Management/Themes/ThemeManager.Linux.cs) — старая заглушка Этапа 2; вместо неё используется полный `Themes/ThemeManager.Avalonia.cs`. + - [`Services/LinuxStubs.cs`](Configuration Management/Services/LinuxStubs.cs) — временные DI-заглушки; вместо них — полноценные реализации `OneCLauncher.Linux.cs`, `OneCComConnector.Linux.cs`, `PlatformVersionService.Linux.cs`. + - [`Services/LinuxOneCServiceShims.cs`](Configuration Management/Services/LinuxOneCServiceShims.cs) — старый шим статических сервисов 1С; функциональность перенесена в `*Linux.cs` (напр., `Launcher.CreateDirCreateFailedFormat` в [`OneCLauncher.Linux.cs`](Configuration Management/Services/OneCLauncher.Linux.cs)). +- **Удалён временный артефакт WPF-компилятора** [`Configuration Management_ujfv2o2k_wpftmp.csproj`](Configuration Management/Configuration Management_ujfv2o2k_wpftmp.csproj) — авто-генерируемый `*_wpftmp.csproj` (создаётся заново при каждой сборке). +- **Удалена неподключаемая тема** [`Themes/ModernTheme.xaml`](Configuration Management/Themes/ModernTheme.xaml) — не упоминалась ни в `App.xaml`, ни в [`ThemeManager.cs`](Configuration Management/Themes/ThemeManager.cs) (используются только `LightTheme.xaml`/`DarkTheme.xaml`). +- **Удалён неиспользуемый конвертер `GroupFullPathConverter` (WPF + Avalonia)** — без привязок в XAML и без использований в коде. +- **Удалена неиспользуемая ViewModel** [`ViewModels/MetadataNodeViewModel.cs`](Configuration Management/ViewModels/MetadataNodeViewModel.cs) — UI метаданных отсутствует (`MetadataNode`-модель сохраняется, используется через `Infobase.MetadataRoot`). +- **Удалена неиспользуемая локальная функция `FlushSection`** в [`OneCTemplateService.cs`](Configuration Management/Services/OneCTemplateService.cs) — пустая no-op заглушка, вызывала предупреждение компилятора CS8321. +- **Устранены предупреждения компилятора CS8601** (nullable) в [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs): в `LaunchEnterpriseWithParams` / `LaunchConfiguratorWithParams` промежуточная переменная `saved` для временного сохранения параметров запуска инициализируется через `?? ""`, исключая назначение nullable-значения в не-nullable свойство. +- **Почищен [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)**: убраны «висячие» `` для удалённых файлов и устаревшие комментарии про заглушки; исправлены устаревшие комментарии в [`Services/IOneCLauncher.cs`](Configuration Management/Services/IOneCLauncher.cs) и [`Services/IPlatformVersionService.cs`](Configuration Management/Services/IPlatformVersionService.cs). + +### Документация + +- Обновлён [`README.md`](README.md): версия в бейдже и заголовке, удалены ссылки на удалённые файлы (`ModernTheme.xaml`, `LinuxOneCServiceShims.cs`, `MetadataNodeViewModel.cs`), уточнено количество конвертеров Avalonia (18 → 10). + +### Прочее + +- **Версия обновлена до 0.3.3.30** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.30 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.30. + +## [0.3.3.29] — 2026-08-23 + +### Изменено + +- **Настройка «После запуска базы или конфигуратора»: вариант «Закрыть» теперь полностью завершает работу приложения, а не уводит его в трей** ([`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs)). Ранее выбор «Закрыть» фактически делал то же, что и «Свернуть в трей» — вызывал `HideToTray()`, оставляя приложение работать в фоне. Теперь после успешного запуска информационной базы или конфигуратора 1С при выбранной опции «Закрыть» главное окно закрывается через `_forceClose = true; Close()` (тот же механизм, что у команды «Выход»): настройки сохраняются, автоматическая синхронизация останавливается, значок трея освобождается, приложение полностью завершает работу. Текст опции обновлён в локализации: [`ru.json`](Configuration Management/Localization/Languages/ru.json) — «Закрыть (увести в трей)» → «Закрыть программу», [`en.json`](Configuration Management/Localization/Languages/en.json) — «Close (hide to tray)» → «Close the program». +- **Версия обновлена до 0.3.3.29** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.29 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.29. + +## [0.3.3.28] — 2026-08-23 + +### Исправлено + +- **Добавлены недостающие ключи локализации настройки «После запуска базы или конфигуратора»** в разделе «Поведение приложения» окна настроек ([`SettingsWindow.xaml`](Configuration Management/SettingsWindow.xaml), [`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs)). Ключи `Settings.General.AfterLaunchAction`, `Settings.General.AfterLaunchAction.None`, `Settings.General.AfterLaunchAction.MinimizeToTray` и `Settings.General.AfterLaunchAction.Close` были объявлены ещё в версии 0.3.3.26, но фактически отсутствовали в файлах локализации. Из-за механизма отката [`LocalizationManager.Translate()`](Configuration Management/Localization/LocalizationManager.cs) (текущий язык → английский → русский → сам ключ) в комбобоксе отображался сырой технический ключ (`Settings.General.AfterLaunchAction.MinimizeToTray` и т.п.) вместо понятной подписи «что делать с окном после запуска», что выглядело как нелокализованный англоязычный текст. Ключи добавлены в [`ru.json`](Configuration Management/Localization/Languages/ru.json) («Ничего», «Свернуть в трей», «Закрыть программу») и [`en.json`](Configuration Management/Localization/Languages/en.json) («Do nothing», «Minimize to tray», «Close the program»). +- **Версия обновлена до 0.3.3.28** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.28 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.28. + +## [0.3.3.27] — 2026-08-23 + +### Слияние исправлений от ksv47 + +- **Влит PR #54 «Linux/Avalonia: доведение до сборки, запуска и работы с платформой 1С»** (ветка `ksv47/linux-fixes`). Все изменения этого PR **выполнены автором ksv47**. Это крупный объём работ по доведению Linux/Avalonia-версии приложения до полноценной сборки, запуска и работы с платформой 1С:Предприятие. По функциональным направлениям: + - **Запуск и работа с платформой 1С**: поиск платформы на штатной раскладке установки 1С, корректные пути к данным 1С и каталогам (`binDir`, корень кеша), читаемость тёмной темы, определение окружения рабочего стола (Linux), корректная работа файловых менеджеров. + - **Список баз как таблица с колонками**: сортировка кликом по заголовкам, изменение ширины колонок перетаскиванием разделителя, панель инструментов над списком. + - **Теги и дерево**: панель отбора по тегам, теги в строке базы и переключатель их показа, состояние дерева, сортировка групп и порядок узлов, сохранение свёрнутости только для групп и служебных узлов. + - **Сессия, контекстное меню и действия**: блок текущей сессии и полные сведения о подключении, контекстное меню строки, горячие клавиши и их переназначение, удаление базы и группы, переход по ссылке. + - **Настройки**: вкладки «Отображение», «Платформы», «Базы» и автосинхронизация с `ibases.v8i`, редактор цветовых схем. + - **Качество**: устранены утечки подписок содержимого, двойная пересборка, синхронные диалоги больше не вешают интерфейс, корректный выход на старте. + - Детальные описания конкретных правок Linux/Avalonia-версии уже зафиксированы в записях **0.3.3.12–0.3.3.24** ниже; настоящая запись оформляет само слияние PR #54 и фиксирует авторство. +- **Авторство изменений:** все правки, вошедшие в этот merge, выполнены **ksv47** ([`PR #54`](https://github.com/ksv47)) — см. также раздел «Благодарности» в [`README.md`](README.md). +- **Версия обновлена до 0.3.3.27** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.27 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.27. + +## [0.3.3.26] — 2026-08-23 + +### Добавлено + +- **Глобальная настройка «После запуска базы или конфигуратора»** (обе платформы) — определяет, что делать с окном приложения сразу после успешного запуска информационной базы или конфигуратора 1С: **«Ничего»** (по умолчанию, окно остаётся как было), **«Свернуть в трей»** или **«Закрыть (уйти в трей)»**. Настройка расположена в **Настройки → Настройки** и хранится в [`AppSettings`](Configuration Management/Models/AppSettings.cs) (`AfterLaunchAction`, новые ключи локализации `Settings.General.AfterLaunchAction*` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json)). Реализовано для Windows (WPF) через событие `AfterLaunchRequested` ([`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs), [`SettingsWindow.xaml`](Configuration Management/SettingsWindow.xaml)) и для Linux (Avalonia) ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs)). Уведомление выполняется после успешного запуска из главного окна, по горячей клавише избранного и из меню трея; «Свернуть в трей» сворачивает окно, «Закрыть» уводит его в трей, не завершая приложение. +- **Версия обновлена до 0.3.3.26** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.26 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.26. + +## [0.3.3.25] — 2026-08-22 + +### Изменено + +- **Исправлено сброс цветового оформления на дефолтные при переключении светлой/тёмной темы** — выбранная пользовательская цветовая схема больше не затирается встроенной ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs), [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs), [`SettingsWindow.Avalonia.cs`](Configuration Management/SettingsWindow.Avalonia.cs)). Ранее переключение темы (кнопка на верхней панели и радиокнопки Светлая/Тёмная в настройках) применяло встроенную схему по умолчанию, полностью теряя кастомизированные цвета; в WPF-версии это дополнительно безвозвратно заменяло сохранённую активную схему. Теперь переключается только базовый вариант (светлый/тёмный): если для целевой темы есть сохранённая схема — применяется она, иначе применяются встроенные цвета, а сохранённая схема остаётся нетронутой и восстанавливается при возврате к её базовой теме. +- **Версия обновлена до 0.3.3.25** (`Version`/`AssemblyVersion`/`FileVersion` = 0.3.3, `InformationalVersion` = 0.3.3.25 в [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj)). Бейдж и заголовок в [`README.md`](README.md) обновлены до 0.3.3.25. + ## [0.3.3.24] — 2026-08-22 ### Изменено diff --git a/Configuration Management/AddEditWindow.Avalonia.cs b/Configuration Management/AddEditWindow.Avalonia.cs index efc8e956..4cb0a239 100644 --- a/Configuration Management/AddEditWindow.Avalonia.cs +++ b/Configuration Management/AddEditWindow.Avalonia.cs @@ -1,5 +1,6 @@ #if LINUX using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; diff --git a/Configuration Management/AddEditWindow.xaml.cs b/Configuration Management/AddEditWindow.xaml.cs index 93ee0017..03bf4f4f 100644 --- a/Configuration Management/AddEditWindow.xaml.cs +++ b/Configuration Management/AddEditWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Controls; @@ -36,4 +37,5 @@ private void OnNext_Click(object sender, RoutedEventArgs e) DialogResult = true; } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/App.axaml b/Configuration Management/App.axaml index 93af4893..637c855f 100644 --- a/Configuration Management/App.axaml +++ b/Configuration Management/App.axaml @@ -14,6 +14,7 @@ + diff --git a/Configuration Management/App.axaml.cs b/Configuration Management/App.axaml.cs index 8467eabc..eb314696 100644 --- a/Configuration Management/App.axaml.cs +++ b/Configuration Management/App.axaml.cs @@ -30,11 +30,15 @@ public partial class App : Application private const string LockFileName = "configuration-management.lock"; private const string ActivateFileName = "activate"; - /// Каталог данных приложения (например ~/.config/ConfigurationManagement). + /// + /// Каталог данных приложения (например ~/.config/ConfigurationManagement). + /// Считается тем же механизмом, что и у репозитория настроек: расчёт + /// напрямую через SpecialFolder.ApplicationData на Linux даёт пустую + /// строку, когда каталога из XDG_CONFIG_HOME нет, путь становится + /// относительным, и приложение молча не запускается. + /// private static string DataDirectory => - _dataDir ??= Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), - "ConfigurationManagement"); + _dataDir ??= Services.PlatformPaths.AppDataDirectory; public override void Initialize() { @@ -72,6 +76,29 @@ public override void OnFrameworkInitializationCompleted() try { settings = repository.LoadSettings(); } catch { settings = new AppSettings(); } + // Восстановление профиля из указанного каталога резервной копии + // (например, после переустановки системы): настройки, список баз + // (с пользователями и паролями запуска), группы и ibases.v8i. + // Файлы копируются до загрузки данных главным окном, поэтому приложение + // сразу открывается с привычным состоянием. Настройки перечитываются, + // чтобы последующие этапы запуска использовали восстановленные значения. + if (settings.ProfileRestoreOnStartup + && !string.IsNullOrWhiteSpace(settings.ProfileBackupDirectory) + && ProfileBackupService.HasBackup(settings.ProfileBackupDirectory)) + { + try + { + ProfileBackupService.Restore(settings.ProfileBackupDirectory, settings.IbasesSyncFilePath); + try { settings = repository.LoadSettings(); } + catch { /* оставляем уже прочитанные настройки */ } + } + catch (Exception ex) + { + // Сбой восстановления не должен блокировать запуск. + Console.Error.WriteLine("[profile] Ошибка восстановления профиля: " + ex.Message); + } + } + // Инициализируем локализацию: выбранный или системный язык, а также // загружаем внешние языки (.json) из папок Languages (рядом с приложением // и в каталоге данных). @@ -101,14 +128,25 @@ public override void OnFrameworkInitializationCompleted() { _desktopLifetime = desktop; - // Применяем сохранённую цветовую схему (тему оформления). - if (settings.ActiveColorScheme is { Colors.Count: > 0 }) - ThemeManager.ApplyScheme(settings.ActiveColorScheme); - else - ThemeManager.ApplyTheme( - string.IsNullOrWhiteSpace(settings.Theme) - ? ThemeManager.LightThemeName - : settings.Theme); + // Применяем сохранённую цветовую схему активной базовой темы. Предпочитаем + // раздельную схему для светлой/тёмной темы, иначе — старый одиночный + // ActiveColorScheme (миграция) или встроенные цвета. + var activeTheme = string.IsNullOrWhiteSpace(settings.Theme) + ? (settings.ActiveColorScheme?.IsDark == true ? ThemeManager.DarkThemeName : ThemeManager.LightThemeName) + : settings.Theme; + var isDark = string.Equals(activeTheme, ThemeManager.DarkThemeName, StringComparison.OrdinalIgnoreCase); + Configuration_Management.Models.ColorScheme? activeScheme = isDark + ? settings.DarkColorScheme + : settings.LightColorScheme; + if (activeScheme is not { Colors.Count: > 0 } + && settings.ActiveColorScheme is { Colors.Count: > 0 } + && settings.ActiveColorScheme.IsDark == isDark) + { + activeScheme = settings.ActiveColorScheme; + } + ThemeManager.ApplyScheme(activeScheme ?? (isDark + ? Configuration_Management.Models.ColorScheme.CreateDark() + : Configuration_Management.Models.ColorScheme.CreateLight())); // Компактный режим интерфейса (влияет на метрики отступов/иконок, // должен быть установлен до построения главного окна). @@ -141,6 +179,15 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// + /// Завершает приложение на этапе запуска. У Avalonia Application нет метода Shutdown, + /// а desktop.Shutdown здесь не годится: оба вызова происходят внутри + /// OnFrameworkInitializationCompleted, то есть до входа в цикл сообщений, и гасят + /// Dispatcher раньше времени. Тогда MainLoop падает с InvalidOperationException + /// «Cannot perform requested operation because the Dispatcher shut down». + /// + private static void Shutdown(int exitCode = 0) => Environment.Exit(exitCode); + /// /// Захватывает исключительный файловый lock (один экземпляр на Linux). /// Файл-блокировка в каталоге данных с FileShare.None. diff --git a/Configuration Management/App.xaml.cs b/Configuration Management/App.xaml.cs index 615800cd..e0296d5e 100644 --- a/Configuration Management/App.xaml.cs +++ b/Configuration Management/App.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -21,16 +22,23 @@ public partial class App : Application protected override void OnStartup(StartupEventArgs e) { + // Режим COM-агента перехватывается раньше, в Program.Main: агенту не нужны + // ни WPF, ни ресурсные словари тем. См. ComReadHost. + // Показываем любые необработанные ошибки — иначе окно просто не появляется. DispatcherUnhandledException += (_, args) => { + LogFatal(LocalizationManager.T("App.Fatal.Interface"), args.Exception); ShowFatalError(LocalizationManager.T("App.Fatal.Interface"), args.Exception); args.Handled = true; }; AppDomain.CurrentDomain.UnhandledException += (_, args) => { if (args.ExceptionObject is Exception ex) + { + LogFatal(LocalizationManager.T("App.Fatal.Critical"), ex); ShowFatalError(LocalizationManager.T("App.Fatal.Critical"), ex); + } }; TaskScheduler.UnobservedTaskException += (_, args) => { @@ -54,6 +62,29 @@ protected override void OnStartup(StartupEventArgs e) settings = new AppSettings(); } + // Восстановление профиля из указанного каталога резервной копии + // (например, после переустановки системы): настройки, список баз + // (с пользователями и паролями запуска), группы и ibases.v8i. + // Файлы копируются до загрузки данных главным окном, поэтому приложение + // сразу открывается с привычным состоянием. Настройки перечитываются, + // чтобы последующие этапы запуска использовали восстановленные значения. + if (settings.ProfileRestoreOnStartup + && !string.IsNullOrWhiteSpace(settings.ProfileBackupDirectory) + && ProfileBackupService.HasBackup(settings.ProfileBackupDirectory)) + { + try + { + ProfileBackupService.Restore(settings.ProfileBackupDirectory, settings.IbasesSyncFilePath); + try { settings = repository.LoadSettings(); } + catch { /* оставляем уже прочитанные настройки */ } + } + catch (Exception ex) + { + // Сбой восстановления не должен блокировать запуск. + System.Diagnostics.Debug.WriteLine("[profile] Ошибка восстановления профиля: " + ex.Message); + } + } + // Инициализируем локализацию: выбираем сохранённый язык, иначе язык // системы. Внешние языки (.json) подгружаются из папки Languages. try @@ -82,19 +113,34 @@ protected override void OnStartup(StartupEventArgs e) base.OnStartup(e); - // Применяем сохранённую цветовую схему (тему оформления). - if (settings.ActiveColorScheme is { Colors.Count: > 0 }) + // Применяем сохранённую цветовую схему (тему оформления). Предпочитаем раздельную + // схему для активной базовой темы (Light/Dark), иначе — старый одиночный + // ActiveColorScheme (миграция) или встроенные цвета. + var themeName = string.IsNullOrWhiteSpace(settings.Theme) + ? ThemeManager.LightThemeName + : settings.Theme; + var isDark = string.Equals(themeName, ThemeManager.DarkThemeName, StringComparison.OrdinalIgnoreCase); + Configuration_Management.Models.ColorScheme? scheme = isDark + ? settings.DarkColorScheme + : settings.LightColorScheme; + if (scheme is not { Colors.Count: > 0 } + && settings.ActiveColorScheme is { Colors.Count: > 0 } + && settings.ActiveColorScheme.IsDark == isDark) { - ThemeManager.ApplyScheme(settings.ActiveColorScheme); + scheme = settings.ActiveColorScheme; } - else + ThemeManager.ApplyScheme(scheme ?? (isDark + ? Configuration_Management.Models.ColorScheme.CreateDark() + : Configuration_Management.Models.ColorScheme.CreateLight())); + try { - // Обратная совместимость: только имя темы Light/Dark. - var theme = string.IsNullOrWhiteSpace(settings.Theme) - ? ThemeManager.LightThemeName - : settings.Theme; - ThemeManager.ApplyTheme(theme); + System.IO.File.AppendAllText( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cm_theme_debug.log"), + $"[startup] theme='{themeName}' dark={isDark} applied='{ThemeManager.CurrentScheme.Name}' " + + $"darkSlot='{settings.DarkColorScheme?.Name}' lightSlot='{settings.LightColorScheme?.Name}' " + + $"active='{settings.ActiveColorScheme?.Name}'{System.Environment.NewLine}"); } + catch { /* не критично */ } var mainWindow = AppServices.GetRequiredService(); MainWindow = mainWindow; @@ -122,11 +168,35 @@ protected override void OnStartup(StartupEventArgs e) } catch (Exception ex) { + LogFatal(LocalizationManager.T("App.Fatal.StartupFailed"), ex); ShowFatalError(LocalizationManager.T("App.Fatal.StartupFailed"), ex); Shutdown(1); } } + /// + /// Записывает полный стек-трейс фатальной ошибки в лог-файл (для диагностики). + /// Сам по себе не бросает исключений, даже если логирование недоступно. + /// + private static void LogFatal(string title, Exception ex) + { + try + { + var logger = AppServices.GetRequiredService(); + var sb = new StringBuilder(); + sb.AppendLine(title); + sb.AppendLine("Исключение: " + ex.GetType().FullName); + sb.AppendLine("Сообщение: " + ex.Message); + sb.AppendLine("StackTrace:"); + sb.AppendLine(ex.ToString()); + logger.Error(sb.ToString()); + } + catch + { + // Логирование не должно маскировать исходную ошибку. + } + } + private static void ShowFatalError(string title, Exception ex) { try @@ -160,6 +230,17 @@ private static void ShowFatalError(string title, Exception ex) protected override void OnExit(ExitEventArgs e) { + // Процесс-агент COM закрывается сам, когда закроется его stdin, но при + // нештатном завершении лучше не полагаться на это и убрать его явно. + try + { + ComReadHost.Shutdown(); + } + catch + { + // ignore + } + try { var logger = AppServices.GetRequiredService(); @@ -310,3 +391,4 @@ private static void ActivateMainWindow() private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); } } +#endif diff --git a/Configuration Management/CacheCleanWindow.Avalonia.cs b/Configuration Management/CacheCleanWindow.Avalonia.cs index 52cf5a27..dc84b0e7 100644 --- a/Configuration Management/CacheCleanWindow.Avalonia.cs +++ b/Configuration Management/CacheCleanWindow.Avalonia.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; @@ -73,7 +75,7 @@ public CacheCleanWindow(IEnumerable infobases, OneCCacheKind initialKi _programCacheCheck.Content = BuildCacheTypeContent(LocalizationManager.T("CacheClean.ProgramCache"), _programCacheSizeText); _userCacheCheck.Content = BuildCacheTypeContent(LocalizationManager.T("CacheClean.UserCache"), _userCacheSizeText); _orphanCacheCheck.Content = BuildCacheTypeContent(LocalizationManager.T("CacheClean.OrphanCache"), _orphanCacheSizeText); - _orphanCacheCheck.ToolTip = new ToolTip { Content = LocalizationManager.T("CacheClean.OrphanCacheTooltip") }; + ToolTip.SetTip(_orphanCacheCheck, LocalizationManager.T("CacheClean.OrphanCacheTooltip")); _programCacheCheck.IsChecked = initialKind.HasFlag(OneCCacheKind.Program); _userCacheCheck.IsChecked = initialKind.HasFlag(OneCCacheKind.User); @@ -155,7 +157,7 @@ private void SaveColumnWidths() /// чтобы диалог можно было вызывать из ViewModel (не наследника окна). /// /// True, если пользователь подтвердил очистку. - public bool ShowSync() => ShowDialogSync(); + public bool ShowSync(Window? owner = null) => ShowDialogSync(owner); /// /// Формирует содержимое чекбокса типа кеша: название и поле текущего размера. @@ -268,8 +270,8 @@ private Control BuildRoot() grid.Children.Add(typeLabel); var typePanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; - _programCacheCheck.ToolTip = new ToolTip { Content = "%LOCALAPPDATA%\\1C\\1cv8…" }; - _userCacheCheck.ToolTip = new ToolTip { Content = "%APPDATA%\\1C\\1cv8…" }; + ToolTip.SetTip(_programCacheCheck, "%LOCALAPPDATA%\\1C\\1cv8…"); + ToolTip.SetTip(_userCacheCheck, "%APPDATA%\\1C\\1cv8…"); typePanel.Children.Add(_programCacheCheck); typePanel.Children.Add(_userCacheCheck); Grid.SetRow(typePanel, 3); @@ -296,18 +298,29 @@ private Control BuildRoot() DockPanel.SetDock(_searchBox, Dock.Top); dock.Children.Add(_searchBox); - var toolbar = new StackPanel - { - Orientation = Orientation.Horizontal, - Spacing = 12, - Margin = new Thickness(8, 2, 8, 4) - }; + var toolbar = new Grid { Margin = new Thickness(8, 2, 8, 4) }; + toolbar.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + toolbar.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); + var toolbarButtons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 }; var selectAll = new Button { Content = IconHelper.IconAndText("IconCheck", LocalizationManager.T("CacheClean.SelectAll")), Background = Brushes.Transparent, BorderThickness = new Thickness(0) }; selectAll.Click += (_, _) => { foreach (var check in _baseChecks.Keys) check.IsChecked = true; UpdateCount(); UpdateCleanEnabled(); }; var clearAll = new Button { Content = IconHelper.IconAndText("IconUncheck", LocalizationManager.T("CacheClean.ClearAll")), Background = Brushes.Transparent, BorderThickness = new Thickness(0) }; clearAll.Click += (_, _) => { foreach (var check in _baseChecks.Keys) check.IsChecked = false; UpdateCount(); UpdateCleanEnabled(); }; - toolbar.Children.Add(selectAll); - toolbar.Children.Add(clearAll); + toolbarButtons.Children.Add(selectAll); + toolbarButtons.Children.Add(clearAll); + toolbar.Children.Add(toolbarButtons); + + // Кегль и цвет как у заголовков колонок рядом: счётчик встал с ними + // в один ряд, и умолчания выбивались из строя. + _basesCountText.VerticalAlignment = VerticalAlignment.Center; + _basesCountText.HorizontalAlignment = HorizontalAlignment.Right; + _basesCountText.TextTrimming = TextTrimming.CharacterEllipsis; + _basesCountText.FontSize = 12; + // Цвет из темы, как в оригинале: постоянный серый мимо палитры + // тёмной схемы, где вторичный текст светлее фона. + Themes.ThemeBrushes.Bind(_basesCountText, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + Grid.SetColumn(_basesCountText, 1); + toolbar.Children.Add(_basesCountText); DockPanel.SetDock(toolbar, Dock.Top); dock.Children.Add(toolbar); @@ -342,8 +355,6 @@ private Control BuildRoot() VerticalAlignment = VerticalAlignment.Center }; leftPanel.Children.Add(_orphanCacheCheck); - _basesCountText.VerticalAlignment = VerticalAlignment.Center; - leftPanel.Children.Add(_basesCountText); Grid.SetColumn(leftPanel, 0); bottom.Children.Add(leftPanel); @@ -419,9 +430,9 @@ private Border BuildResizeGrip(int column) Width = 8, ZIndex = 1, Background = Brushes.Transparent, - Cursor = new Cursor(StandardCursorType.SizeWestEast), - ToolTip = new ToolTip { Content = LocalizationManager.T("CacheClean.ResizeColumnTooltip") } + Cursor = new Cursor(StandardCursorType.SizeWestEast) }; + ToolTip.SetTip(grip, LocalizationManager.T("CacheClean.ResizeColumnTooltip")); Grid.SetColumn(grip, column); grip.PointerPressed += OnResize_PointerPressed; grip.PointerMoved += OnResize_PointerMoved; @@ -442,7 +453,7 @@ private void OnResize_PointerPressed(object? sender, PointerPressedEventArgs e) _resizeStartWidth = _headerGrid.ColumnDefinitions[column].ActualWidth; _resizeStartX = e.GetPosition(this).X; _isResizing = true; - grip.CapturePointer(e.Pointer); + e.Pointer.Capture(grip); e.Handled = true; } @@ -460,8 +471,8 @@ private void OnResize_PointerMoved(object? sender, PointerEventArgs e) private void OnResize_PointerReleased(object? sender, PointerReleasedEventArgs e) { - if (sender is Border grip) - grip.ReleasePointerCapture(e.Pointer); + if (sender is Border) + e.Pointer.Capture(null); if (_isResizing) { @@ -508,12 +519,9 @@ private void BuildBasesList(Infobase? defaultSelected) { Content = ib.Name, IsChecked = ReferenceEquals(ib, defaultSelected), - VerticalContentAlignment = VerticalAlignment.Center, - ToolTip = new ToolTip - { - Content = string.IsNullOrWhiteSpace(ib.ConnectionPathDisplay) ? ib.Name : ib.ConnectionPathDisplay - } + VerticalContentAlignment = VerticalAlignment.Center }; + ToolTip.SetTip(check, string.IsNullOrWhiteSpace(ib.ConnectionPathDisplay) ? ib.Name : ib.ConnectionPathDisplay); check.Checked += (_, _) => OnBaseChecked(); check.Unchecked += (_, _) => OnBaseChecked(); Grid.SetColumn(check, 0); diff --git a/Configuration Management/CacheCleanWindow.xaml.cs b/Configuration Management/CacheCleanWindow.xaml.cs index 0e176728..eb6085f9 100644 --- a/Configuration Management/CacheCleanWindow.xaml.cs +++ b/Configuration Management/CacheCleanWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -420,4 +421,5 @@ private void OnClean_Click(object sender, RoutedEventArgs e) DialogResult = true; } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/ColorPickerWindow.Avalonia.cs b/Configuration Management/ColorPickerWindow.Avalonia.cs index 96ccca6a..21902c0b 100644 --- a/Configuration Management/ColorPickerWindow.Avalonia.cs +++ b/Configuration Management/ColorPickerWindow.Avalonia.cs @@ -1,6 +1,7 @@ #if LINUX using System; using System.Collections.Generic; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Layout; @@ -146,7 +147,7 @@ public ColorPickerWindow(string? initialColor = null) /// public string Result { get; private set; } = "#2D6CDF"; - private static Grid BuildRgbRow(int row, string label, string accent, Slider slider, TextBlock value) + private Grid BuildRgbRow(int row, string label, string accent, Slider slider, TextBlock value) { var grid = new Grid { Margin = new Thickness(0, 2) }; grid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(20))); diff --git a/Configuration Management/ColorPickerWindow.xaml.cs b/Configuration Management/ColorPickerWindow.xaml.cs index adfd916d..4bd92ed2 100644 --- a/Configuration Management/ColorPickerWindow.xaml.cs +++ b/Configuration Management/ColorPickerWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -132,4 +133,5 @@ private static Color ParseColor(string? hex) } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/Configuration Management.csproj b/Configuration Management/Configuration Management.csproj index b2a640b7..8aa3a268 100644 --- a/Configuration Management/Configuration Management.csproj +++ b/Configuration Management/Configuration Management.csproj @@ -12,6 +12,10 @@ true true app.ico + + Configuration_Management.Program - 0.3.3 - 0.3.3 - 0.3.3 - 0.3.3.24 + 0.3.4 + 0.3.4 + 0.3.4 + 0.3.4.15 false embedded @@ -64,6 +68,12 @@ linux-x64 + + + + + @@ -72,6 +82,14 @@ + + + + + + @@ -130,7 +148,7 @@ Окна, контролы, конвертеры, темы и Windows-сервисы портируются на более поздних этапах (Этапы 3 и 5). Для Linux остаются: App.axaml/.cs, Program.cs, MainWindow.Avalonia.cs (заглушка), чистые .NET-модели и - сервисы, а также Services/LinuxStubs.cs (временные заглушки DI). + сервисы. .xaml-файлы здесь не компилируются (нет UseWPF/Avalonia для .xaml), поэтому достаточно удалить их code-behind (.cs). ====================================================================== --> @@ -161,21 +179,20 @@ - + - - + - - + автоматически дефолтным глобом; WPF-файлы исключены выше). --> diff --git a/Configuration Management/Configuration Management_ujfv2o2k_wpftmp.csproj b/Configuration Management/Configuration Management_ujfv2o2k_wpftmp.csproj deleted file mode 100644 index 34483ff8..00000000 --- a/Configuration Management/Configuration Management_ujfv2o2k_wpftmp.csproj +++ /dev/null @@ -1,448 +0,0 @@ - - - ConfigurationManagement - obj\Debug\ - obj\ - f:\ya\Yandex.Disk\h\Configuration_Management_linux\Configuration Management\obj\ - <_TargetAssemblyProjectName>Configuration Management - Configuration_Management - - - - - WinExe - net10.0-windows - true - true - app.ico - - - - Exe - net10.0 - - $(DefineConstants);LINUX - app.ico - - - - - Configuration_Management - enable - enable - Управление конфигурациями 1С - Управление конфигурациями 1С - - 0.3.3 - 0.3.3 - 0.3.3 - 0.3.3.12 - - false - embedded - - - - true - true - true - true - - - win-x64 - - - linux-x64 - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Configuration Management/ConnectionSettingsWindow.Avalonia.cs b/Configuration Management/ConnectionSettingsWindow.Avalonia.cs index ed99d6a4..1c0f46ab 100644 --- a/Configuration Management/ConnectionSettingsWindow.Avalonia.cs +++ b/Configuration Management/ConnectionSettingsWindow.Avalonia.cs @@ -2,10 +2,13 @@ using System; using System.Collections.Generic; using System.IO; +using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Controls; using Avalonia.Data; using Avalonia.Layout; using Avalonia.Media; +using Configuration_Management.Controls; using Configuration_Management.Localization; using Configuration_Management.Models; using Configuration_Management.Services; diff --git a/Configuration Management/ConnectionSettingsWindow.xaml.cs b/Configuration Management/ConnectionSettingsWindow.xaml.cs index 6718b621..5f755c97 100644 --- a/Configuration Management/ConnectionSettingsWindow.xaml.cs +++ b/Configuration Management/ConnectionSettingsWindow.xaml.cs @@ -1,4 +1,6 @@ +#if WINDOWS using System; +using System.Collections.Generic; using System.IO; using System.Windows; using System.Windows.Controls; @@ -331,4 +333,5 @@ private void SyncConfiguratorPasswordBoxFromViewModel() } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/ConnectionStringInputWindow.Avalonia.cs b/Configuration Management/ConnectionStringInputWindow.Avalonia.cs index 033dfd5f..b6d9c563 100644 --- a/Configuration Management/ConnectionStringInputWindow.Avalonia.cs +++ b/Configuration Management/ConnectionStringInputWindow.Avalonia.cs @@ -1,10 +1,12 @@ #if LINUX using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Configuration_Management.Localization; +using Configuration_Management.Services; namespace Configuration_Management { diff --git a/Configuration Management/ConnectionStringInputWindow.xaml.cs b/Configuration Management/ConnectionStringInputWindow.xaml.cs index 61ea3b2b..aa0eb688 100644 --- a/Configuration Management/ConnectionStringInputWindow.xaml.cs +++ b/Configuration Management/ConnectionStringInputWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -143,4 +144,5 @@ private void OnInputBox_KeyDown(object sender, KeyEventArgs e) } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/Controls/HotkeyBox.Avalonia.cs b/Configuration Management/Controls/HotkeyBox.Avalonia.cs index 5b6ec95f..4b756253 100644 --- a/Configuration Management/Controls/HotkeyBox.Avalonia.cs +++ b/Configuration Management/Controls/HotkeyBox.Avalonia.cs @@ -1,6 +1,7 @@ #if LINUX using System.Collections.Generic; using Avalonia; +using System; using Avalonia.Controls; using Avalonia.Input; using Configuration_Management.Localization; @@ -15,6 +16,12 @@ namespace Configuration_Management.Controls /// public class HotkeyBox : TextBox { + /// + /// Тема ищется по ключу стиля, а для наследника её в Fluent нет: + /// без этого поле не отрисуется, как это было у дерева и PasswordBox. + /// + protected override Type StyleKeyOverride => typeof(TextBox); + /// Каноническое представление горячей клавиши (например «Ctrl+Shift+F») или пустая строка. public static readonly StyledProperty ValueProperty = AvaloniaProperty.Register(nameof(Value), string.Empty); @@ -78,6 +85,66 @@ protected override void OnKeyDown(KeyEventArgs e) Text = FormatValue(Value); } + /// + /// Разбирает сохранённое сочетание в жест Avalonia. Формат задаёт сам + /// контрол, поэтому и разбор живёт здесь: сокращения Del, Ins и Esc + /// раскрываются, а имена вроде «NumPad+» и «,» жестами не становятся, + /// и такое назначение считается недопустимым. + /// + public static bool TryParse(string? text, out KeyGesture? gesture) + { + gesture = null; + if (string.IsNullOrWhiteSpace(text)) + return false; + + var parts = text.Trim().Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) + return false; + + parts[^1] = parts[^1].ToLowerInvariant() switch + { + "del" => "Delete", + "ins" => "Insert", + "esc" => "Escape", + _ => parts[^1] + }; + + // Последняя часть должна быть именно клавишей, а не модификатором: + // «Ctrl» сам по себе Avalonia разбирает в жест с Key.None. + if (!Enum.TryParse(parts[^1], true, out var key) || key == Key.None) + return false; + + try + { + gesture = KeyGesture.Parse(string.Join("+", parts)); + return gesture.Key != Key.None; + } + catch (Exception) + { + return false; + } + } + + /// + /// Сочетание, которое нельзя отдавать окну: без модификаторов оно отберёт + /// обычный ввод, а Ctrl+C, Ctrl+V и соседние отберут работу с буфером + /// у полей ввода, потому что привязки окна проверяются раньше них. + /// + public static bool IsUnsafeForTextInput(KeyGesture gesture) + { + if (gesture.KeyModifiers == KeyModifiers.None) + { + var isFunctionKey = gesture.Key >= Key.F1 && gesture.Key <= Key.F24; + var isEditingKey = gesture.Key is Key.Delete or Key.Insert or Key.Back; + return !isFunctionKey && !isEditingKey; + } + + if (gesture.KeyModifiers == KeyModifiers.Control) + return gesture.Key is Key.C or Key.V or Key.X or Key.A or Key.Z or Key.Y; + + return false; + } + private static bool IsModifierKey(Key key) => key is Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift diff --git a/Configuration Management/Controls/InfobaseRowCard.Avalonia.cs b/Configuration Management/Controls/InfobaseRowCard.Avalonia.cs index efde01d5..8e27da48 100644 --- a/Configuration Management/Controls/InfobaseRowCard.Avalonia.cs +++ b/Configuration Management/Controls/InfobaseRowCard.Avalonia.cs @@ -22,7 +22,10 @@ public class InfobaseRowCard : Border { private TreeViewItem? _container; private IDisposable? _selectedSubscription; - private readonly List _brushSubscriptions = new(); + + private readonly List> _contentFactories = new(); + private readonly List _contentSubscriptions = new(); + private bool _attached; // Актуальные кисти темы, обновляются при смене схемы/ресурсов. private IBrush _cardBrush = Brushes.Transparent; @@ -50,15 +53,53 @@ public InfobaseRowCard() PointerEntered += OnPointerEntered; PointerExited += OnPointerExited; - SubscribeBrush("CardBackgroundBrush", value => _cardBrush = value); - SubscribeBrush("ItemHoverBrush", value => _hoverBrush = value); - SubscribeBrush("ItemSelectedBrush", value => _selectedBrush = value); - SubscribeBrush("BorderColorBrush", value => _borderBrush = value); - SubscribeBrush("AccentBrush", value => _accentBrush = value); + // Кисти карточки подписываются тем же способом, что и содержимое: + // при отсоединении подписки освобождаются, при присоединении + // создаются заново. Иначе каждая пересборка списка оставляла бы + // по пять живых наблюдателей на выброшенную строку. + AddSubscription(() => SubscribeBrush("CardBackgroundBrush", value => _cardBrush = value)); + AddSubscription(() => SubscribeBrush("ItemHoverBrush", value => _hoverBrush = value)); + AddSubscription(() => SubscribeBrush("ItemSelectedBrush", value => _selectedBrush = value)); + AddSubscription(() => SubscribeBrush("BorderColorBrush", value => _borderBrush = value)); + AddSubscription(() => SubscribeBrush("AccentBrush", value => _accentBrush = value)); + } + + /// + /// Регистрирует подписку содержимого строки: кисти темы у иконок и подписей, + /// уведомления самой базы. Подписка создаётся при присоединении карточки + /// к дереву и освобождается при отсоединении: иначе наблюдатели держали бы + /// уже выброшенное визуальное дерево. Хранится не сама подписка, а способ + /// её создать, поэтому повторное присоединение восстанавливает и цвета, + /// и слежение за моделью. + /// + public void AddSubscription(Func factory) + { + _contentFactories.Add(factory); + if (!_attached) + return; + + var subscription = factory(); + if (subscription is not null) + _contentSubscriptions.Add(subscription); + } + + /// Создаёт подписки содержимого по зарегистрированным способам. + private void SubscribeContent() + { + foreach (var factory in _contentFactories) + { + var subscription = factory(); + if (subscription is not null) + _contentSubscriptions.Add(subscription); + } } private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { + _attached = true; + if (_contentSubscriptions.Count == 0) + SubscribeContent(); + _container = this.FindAncestorOfType(); if (_container is not null) _selectedSubscription = _container @@ -69,9 +110,14 @@ private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArg private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { + _attached = false; _selectedSubscription?.Dispose(); _selectedSubscription = null; _container = null; + + foreach (var subscription in _contentSubscriptions) + subscription.Dispose(); + _contentSubscriptions.Clear(); } private void OnPointerEntered(object? sender, PointerEventArgs e) @@ -87,14 +133,14 @@ private void OnPointerExited(object? sender, PointerEventArgs e) } /// Подписывает слот-поле на ресурс-кисть темы и вызывает перерисовку при обновлении. - private void SubscribeBrush(string brushKey, Action setter) + private IDisposable? SubscribeBrush(string brushKey, Action setter) { if (Application.Current is not { } app) - return; + return null; // После обновления кисти переприменяем состояние, чтобы фон/граница // корректно перекрашивались при смене схемы даже в состоянии hover/выделение. var slot = new BrushSlot(setter, ApplyState); - _brushSubscriptions.Add(app.GetResourceObservable(brushKey).Subscribe(slot)); + return app.GetResourceObservable(brushKey).Subscribe(slot); } /// Применяет состояние к фону и границе в порядке приоритета: выделено > hover > обычное. diff --git a/Configuration Management/Controls/LeveledTreeView.Avalonia.cs b/Configuration Management/Controls/LeveledTreeView.Avalonia.cs index d168652b..2dee0b68 100644 --- a/Configuration Management/Controls/LeveledTreeView.Avalonia.cs +++ b/Configuration Management/Controls/LeveledTreeView.Avalonia.cs @@ -1,5 +1,14 @@ #if LINUX +using System; +using System.Linq; +using System.Runtime.CompilerServices; +using Avalonia; using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.VisualTree; +using Configuration_Management.ViewModels; namespace Configuration_Management.Controls { @@ -10,7 +19,195 @@ namespace Configuration_Management.Controls /// public class LeveledTreeView : TreeView { - protected override Control? GetContainerForItemOverride() => new LeveledTreeViewItem(); + /// + /// Тема оформления ищется по типу контрола, а для наследника её в Fluent нет: + /// без этого шаблон не находится и контрол не отрисовывается вовсе. + /// + protected override Type StyleKeyOverride => typeof(TreeView); + + public LeveledTreeView() + { + AddHandler(KeyDownEvent, OnNavigationKeyDown, RoutingStrategies.Tunnel); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) => new LeveledTreeViewItem(); + + // Контейнеры переиспользуются, поэтому прежняя привязка освобождается: + // иначе на одном контейнере копились бы выражения привязки. + private readonly ConditionalWeakTable _expandedBindings = new(); + + /// + /// Связывает раскрытие контейнера с моделью узла. Делается здесь, а не по + /// событию ContainerPrepared у дерева: вложенные контейнеры готовит + /// родительский TreeViewItem, и его событие до дерева не доходит, поэтому + /// подгруппы оставались свёрнутыми независимо от модели. Подготовку своих + /// детей TreeViewItem перенаправляет сюда же. + /// + protected override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + base.PrepareContainerForItemOverride(container, item, index); + ReleaseExpandedBinding(container); + + if (container is not TreeViewItem treeItem || item is not GroupNodeViewModel) + return; + + // Источником указан сам узел, а не DataContext контейнера: привязка + // тогда не зависит от того, когда и чем контекст будет установлен. + _expandedBindings.Add(treeItem, treeItem.Bind(TreeViewItem.IsExpandedProperty, + new Binding("IsExpanded") { Mode = BindingMode.TwoWay, Source = item })); + } + + /// + /// Переход в начало, в конец и на страницу. Обработчик стоит на + /// туннелировании: внутренняя прокрутка дерева лежит в маршруте ближе + /// к строке и разбирает PageUp с PageDown сама, помечая их + /// обработанными, так что до самого дерева они не доходят. + /// Распределение взято у версии для Windows: голые клавиши переносят + /// выделение, а с Ctrl прокручивают, не трогая его. + /// + private void OnNavigationKeyDown(object? sender, KeyEventArgs e) + { + if (e.Handled || e.Key is not (Key.Home or Key.End or Key.PageUp or Key.PageDown)) + return; + + if (e.KeyModifiers == KeyModifiers.Control) + { + ScrollBy(e.Key); + e.Handled = true; + return; + } + + if (e.KeyModifiers != KeyModifiers.None) + return; + + // Пустой список: у TreeView в Avalonia эти клавиши считаются + // направленными, и при пустом выборе он берёт первый элемент + // представления, которого нет. Гасим событие до него. + if (ItemsView.Count == 0) + { + e.Handled = true; + return; + } + + var rows = VisibleRows(); + if (rows.Count == 0) + return; + + var current = rows.FindIndex(row => ReferenceEquals(row.DataContext, SelectedItem)); + var target = e.Key switch + { + Key.Home => 0, + Key.End => rows.Count - 1, + Key.PageUp => PageStep(rows, current, back: true), + Key.PageDown => PageStep(rows, current, back: false), + _ => -1 + }; + if (target < 0) + return; + + e.Handled = true; + if (target == current) + return; + + // Выделение ведётся через SelectedItem дерева: правая панель и модель + // слушают именно его, а IsSelected у контейнера их не трогает. + SelectedItem = rows[target].DataContext; + rows[target].BringIntoView(); + rows[target].Focus(); + } + + /// Прокрутка без переноса выделения, вариант с Ctrl. + private void ScrollBy(Key key) + { + if (TreeScroll is not { } scroll) + return; + + var hidden = Math.Max(0, scroll.Extent.Height - scroll.Viewport.Height); + var target = key switch + { + Key.Home => 0, + Key.End => hidden, + Key.PageUp => scroll.Offset.Y - scroll.Viewport.Height, + _ => scroll.Offset.Y + scroll.Viewport.Height + }; + + var next = scroll.Offset.WithY(Math.Clamp(target, 0, hidden)); + if (next != scroll.Offset) + scroll.Offset = next; + } + + private ScrollViewer? TreeScroll => + this.GetVisualDescendants().OfType().FirstOrDefault(); + + /// + /// Строки в порядке показа: обход контейнеров сверху вниз, в развёрнутые + /// узлы с заходом внутрь. По координатам порядок не строится, потому что + /// у части контейнеров пересчёт координат к дереву не удаётся, и такие + /// строки сваливаются в начало. + /// + private List VisibleRows() + { + var rows = new List(); + Collect(this); + return rows; + + void Collect(ItemsControl parent) + { + for (var i = 0; i < parent.ItemCount; i++) + { + if (parent.ContainerFromIndex(i) is not TreeViewItem item) + continue; + rows.Add(item); + if (item.IsExpanded) + Collect(item); + } + } + } + + /// + /// Строка через экран от текущей. Отсчёт идёт по координатам, а не по + /// числу строк: высота у групп и баз разная. + /// + private int PageStep(List rows, int current, bool back) + { + if (current < 0) + return back ? 0 : rows.Count - 1; + + // Отсчёт по координатам строки, а не по сумме высот контейнеров: + // высота контейнера группы включает всех её детей, и шаг ушёл бы + // во всю группу разом. + var page = TreeScroll?.Viewport.Height ?? Bounds.Height; + var from = Top(rows[current]); + + if (back) + { + for (var i = current - 1; i >= 0; i--) + if (Top(rows[i]) <= from - page) + return i; + return 0; + } + + for (var i = current + 1; i < rows.Count; i++) + if (Top(rows[i]) >= from + page) + return i; + return rows.Count - 1; + + double Top(TreeViewItem item) => item.TranslatePoint(default, this)?.Y ?? 0; + } + + protected override void ClearContainerForItemOverride(Control container) + { + ReleaseExpandedBinding(container); + base.ClearContainerForItemOverride(container); + } + + private void ReleaseExpandedBinding(Control container) + { + if (!_expandedBindings.TryGetValue(container, out var previous)) + return; + previous.Dispose(); + _expandedBindings.Remove(container); + } } } #endif \ No newline at end of file diff --git a/Configuration Management/Controls/LeveledTreeViewItem.Avalonia.cs b/Configuration Management/Controls/LeveledTreeViewItem.Avalonia.cs index cf8f3226..152ac34b 100644 --- a/Configuration Management/Controls/LeveledTreeViewItem.Avalonia.cs +++ b/Configuration Management/Controls/LeveledTreeViewItem.Avalonia.cs @@ -1,4 +1,5 @@ #if LINUX +using System; using Avalonia.Controls; namespace Configuration_Management.Controls @@ -11,7 +12,13 @@ namespace Configuration_Management.Controls /// public class LeveledTreeViewItem : TreeViewItem { - protected override Control? GetContainerForItemOverride() => new LeveledTreeViewItem(); + /// + /// Тема оформления ищется по типу контрола, а для наследника её в Fluent нет: + /// без этого шаблон не находится и контрол не отрисовывается вовсе. + /// + protected override Type StyleKeyOverride => typeof(TreeViewItem); + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) => new LeveledTreeViewItem(); } } #endif \ No newline at end of file diff --git a/Configuration Management/Controls/PasswordBox.Avalonia.cs b/Configuration Management/Controls/PasswordBox.Avalonia.cs new file mode 100644 index 00000000..5cf202a0 --- /dev/null +++ b/Configuration Management/Controls/PasswordBox.Avalonia.cs @@ -0,0 +1,49 @@ +#if LINUX +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; + +namespace Configuration_Management.Controls +{ + /// + /// Avalonia-замена WPF-контрола PasswordBox: поле ввода со скрытыми символами, + /// свойство и событие . + /// В Avalonia 11 отдельного PasswordBox нет, роль играет TextBox с PasswordChar. + /// + public class PasswordBox : TextBox + { + /// + /// Тема оформления ищется по ключу стиля, а для наследника её в Fluent нет: + /// без этого поле ввода остаётся без шаблона и не отрисовывается. + /// + protected override Type StyleKeyOverride => typeof(TextBox); + + public PasswordBox() + { + PasswordChar = '•'; + } + + /// Введённый пароль. Пустая строка вместо null, как в WPF. + public string Password + { + get => Text ?? string.Empty; + set + { + if ((Text ?? string.Empty) != (value ?? string.Empty)) + Text = value; + } + } + + /// Возникает при изменении содержимого поля. + public event EventHandler? PasswordChanged; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == TextProperty) + PasswordChanged?.Invoke(this, new RoutedEventArgs()); + } + } +} +#endif diff --git a/Configuration Management/Controls/UiMetrics.Avalonia.cs b/Configuration Management/Controls/UiMetrics.Avalonia.cs index 8816b813..acc38584 100644 --- a/Configuration Management/Controls/UiMetrics.Avalonia.cs +++ b/Configuration Management/Controls/UiMetrics.Avalonia.cs @@ -27,9 +27,9 @@ public static class UiMetrics // ---- Отступы ---- /// Внутренний отступ секций-карточек правой панели (обычный режим). - public const double PaddingSection = 14; + public const double PaddingSection = 10; /// Внутренний отступ управляющих элементов (обычный режим). - public const double PaddingControl = 10; + public const double PaddingControl = 8; // ---- Компактный режим ---- private static bool _compact; @@ -53,40 +53,76 @@ public static bool Compact /// Масштабирует значение на коэффициент компактного режима. public static double Scaled(double value) => value * Scale; + /// + /// Коэффициент масштабирования шрифтов при компактном режиме. Задаётся мягче, + /// чем для геометрии: отступы и иконки сжимаются сильнее, + /// а текст остаётся читаемым (при общем Scale=0.8 мелкие надписи становились + /// практически неразличимыми). + /// + public static double FontScale => Compact ? 0.9 : 1.0; + + /// Масштабирует размер шрифта на коэффициент компактного режима. + public static double ScaledFont(double value) => value * FontScale; + /// Вертикальный отступ верхней панели. public static double TopBarV => Compact ? 6 : 10; /// Горизонтальный отступ верхней панели. public static double TopBarH => Compact ? 8 : 12; /// Внутренний отступ секций-карточек правой панели. - public static double SectionPad => Compact ? 8 : PaddingSection; + public static double SectionPad => Compact ? 6 : PaddingSection; /// Нижний отступ между секциями-карточками. - public static double SectionMarginBottom => Compact ? 6 : 12; + public static double SectionMarginBottom => Compact ? 4 : 8; /// Внутренний отступ управляющих элементов (кнопки, поля). - public static double ControlPad => Compact ? 7 : PaddingControl; + public static double ControlPad => Compact ? 6 : PaddingControl; /// Стандартный вертикальный промежуток между строками внутри секции. - public static double Gap => Compact ? 5 : 8; + public static double Gap => Compact ? 4 : 6; /// Вертикальный padding кнопок (primary/secondary). - public static double ButtonPadV => Compact ? 5 : 9; + public static double ButtonPadV => Compact ? 4 : 7; /// Горизонтальный padding кнопок (primary/secondary). - public static double ButtonPadH => Compact ? 8 : 12; + public static double ButtonPadH => Compact ? 6 : 10; + + /// Вертикальный padding компактных кнопок действий правой панели. + public static double ActionButtonPadV => Compact ? 3 : 6; + /// Горизонтальный padding компактных кнопок действий правой панели. + public static double ActionButtonPadH => Compact ? 6 : 8; + /// Минимальная высота кнопки действия в правой панели. + public static double ActionButtonMinHeight => Compact ? 26 : 32; + /// Размер иконки на кнопке действия правой панели. + public static double ActionIconSize => Compact ? 13 : 14; + /// Размер шрифта подписи кнопки действия правой панели. + public static double ActionFontSize => Compact ? 11 : 12; + /// Промежуток между ячейками сетки действий правой панели. + public static double ActionGridGap => Compact ? 4 : 6; /// Размер квадратной подложки под иконку статуса базы в списке. public static double RowIconBox => Compact ? 28 : 38; /// Размер самой иконки статуса внутри подложки. public static double RowIcon => Compact ? 14 : 20; /// Размер шрифта имени базы в строке списка. - public static double RowNameFont => Compact ? 13 : 14; + public static double RowNameFont => Compact ? 12.5 : 14; /// Размер шрифта вторичной информации в строке списка. - public static double RowSecondaryFont => Compact ? 10 : 11; + public static double RowSecondaryFont => 11; + + /// + /// Размер шрифта имени группы в списке. В обычном режиме имя группы наследует + /// применяемый к интерфейсу шрифт (без жёсткого размера), поэтому значение имеет + /// смысл только в компактном режиме, где имя группы задаётся явно и уменьшается. + /// + public static double GroupNameFont => 12.5; + + /// Вертикальный внутренний отступ заголовка группы (высота оформления группы). + public static double GroupHeaderPadV => Compact ? 2 : 3; + /// Вертикальный внешний отступ заголовка группы (расстояние между группами). + public static double GroupHeaderMarginV => Compact ? 0.5 : 1; /// Минимальная ширина правой панели сведений. - public static double RightPanelMin => Compact ? 230 : 300; + public static double RightPanelMin => Compact ? 200 : 280; /// Максимальная ширина правой панели сведений. - public static double RightPanelMax => Compact ? 290 : 380; + public static double RightPanelMax => Compact ? 255 : 340; // ---- Анимации ---- /// Длительность плавного перехода цвета/прозрачности. @@ -98,14 +134,19 @@ public static bool Compact /// public static void AddSoftShadow(Border target) { - if (Application.Current is not { } app) - return; - app.GetResourceObservable("BorderColorBrush").Subscribe(new ShadowObserver(target)); + // Подписка снимается вместе с уходом элемента из дерева: тень + // добавляется в содержимое главного окна, а оно пересобирается. + Themes.ThemeBrushes.Observe(target, "BorderColorBrush", brush => + { + if (brush is ISolidColorBrush solid) + ApplyShadow(target, solid.Color); + }); } /// Добавляет плавный переход цвета фона и/или границы элемента. public static void AddBrushTransition(Border target, bool background = true, bool border = true) { + target.Transitions ??= new Transitions(); if (background) target.Transitions.Add(new BrushTransition { @@ -123,6 +164,7 @@ public static void AddBrushTransition(Border target, bool background = true, boo /// Добавляет плавное появление/исчезание по прозрачности. public static void AddOpacityTransition(Visual target, double durationMs = 180) { + target.Transitions ??= new Transitions(); target.Transitions.Add(new DoubleTransition { Property = Visual.OpacityProperty, @@ -134,30 +176,17 @@ public static void AddOpacityTransition(Visual target, double durationMs = 180) /// Наблюдатель, который по значению ресурса-кисти строит мягкую полупрозрачную тень /// и применяет её к целевому Border. /// - private sealed class ShadowObserver : IObserver + private static void ApplyShadow(Border target, Color borderColor) { - private readonly Border _target; - - public ShadowObserver(Border target) => _target = target; - - public void OnCompleted() { } - public void OnError(Exception error) { } - - public void OnNext(object? value) + // Полупрозрачный вариант цвета границы: мягкая тень для обеих тем. + var shadowColor = new Color((byte)(borderColor.A * 0.26), borderColor.R, borderColor.G, borderColor.B); + target.BoxShadow = new BoxShadows(new BoxShadow { - if (value is not ISolidColorBrush solid) - return; - var c = solid.Color; - // Полупрозрачный вариант цвета границы — мягкая тень для обеих тем. - var shadowColor = new Color((byte)(c.A * 0.26), c.R, c.G, c.B); - _target.BoxShadow = new BoxShadows(new BoxShadow - { - OffsetY = 3, - Blur = 14, - Spread = 0, - Color = shadowColor - }); - } + OffsetY = 3, + Blur = 14, + Spread = 0, + Color = shadowColor + }); } } } diff --git a/Configuration Management/Converters/Avalonia/ClientTypeToIconConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/ClientTypeToIconConverter.Avalonia.cs deleted file mode 100644 index 6f8ecaa5..00000000 --- a/Configuration Management/Converters/Avalonia/ClientTypeToIconConverter.Avalonia.cs +++ /dev/null @@ -1,32 +0,0 @@ -#if LINUX -using System; -using System.Globalization; -using Avalonia.Data.Converters; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: ключ иконки (имя Geometry из Icons.axaml) в зависимости - /// от типа клиента 1С. Возвращаемое значение передаётся в IconKeyToGeometryConverter. - /// - public class ClientTypeToIconConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is string clientType) - { - return clientType switch - { - "Тонкий" => "IconPhone", - "Толстый" => "IconMonitor", - _ => "IconUnknown" - }; - } - return "IconUnknown"; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotImplementedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/ConnectionTypeToIconConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/ConnectionTypeToIconConverter.Avalonia.cs deleted file mode 100644 index e402bcf8..00000000 --- a/Configuration Management/Converters/Avalonia/ConnectionTypeToIconConverter.Avalonia.cs +++ /dev/null @@ -1,33 +0,0 @@ -#if LINUX -using System; -using System.Globalization; -using Avalonia.Data.Converters; -using Configuration_Management.Models; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: ключ иконки (имя Geometry из Icons.axaml) в зависимости - /// от типа подключения базы. Возвращаемое значение передаётся в IconKeyToGeometryConverter. - /// - public class ConnectionTypeToIconConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is ConnectionType connectionType) - { - return connectionType switch - { - ConnectionType.File => "IconFolder", - ConnectionType.ClientServer => "IconNetwork", - _ => "IconUnknown" - }; - } - return "IconUnknown"; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotImplementedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/GroupExpandSymbolConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/GroupExpandSymbolConverter.Avalonia.cs deleted file mode 100644 index 8bbc1f66..00000000 --- a/Configuration Management/Converters/Avalonia/GroupExpandSymbolConverter.Avalonia.cs +++ /dev/null @@ -1,26 +0,0 @@ -#if LINUX -using System; -using System.Collections.Generic; -using System.Globalization; -using Avalonia.Data.Converters; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: возвращает символ «−» или «+» для кнопки сворачивания/разворачивания группы. - /// - public class GroupExpandSymbolConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values is { Count: >= 2 } && values[0] is string groupName && values[1] is MainViewModel vm) - return vm.IsGroupCollapsed(groupName) ? "+" : "−"; - return "−"; - } - - public IList? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/GroupExpandedConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/GroupExpandedConverter.Avalonia.cs deleted file mode 100644 index 41238b69..00000000 --- a/Configuration Management/Converters/Avalonia/GroupExpandedConverter.Avalonia.cs +++ /dev/null @@ -1,27 +0,0 @@ -#if LINUX -using System; -using System.Collections.Generic; -using System.Globalization; -using Avalonia.Data.Converters; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: определяет состояние развёрнутости группы в списке баз. - /// Принимает имя группы и MainViewModel, возвращает true, если группа развёрнута. - /// - public class GroupExpandedConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values is { Count: >= 2 } && values[0] is string groupName && values[1] is MainViewModel vm) - return !vm.IsGroupCollapsed(groupName); - return true; - } - - public IList? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/GroupFullPathConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/GroupFullPathConverter.Avalonia.cs deleted file mode 100644 index 38776b67..00000000 --- a/Configuration Management/Converters/Avalonia/GroupFullPathConverter.Avalonia.cs +++ /dev/null @@ -1,31 +0,0 @@ -#if LINUX -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Globalization; -using Avalonia.Data.Converters; -using Configuration_Management.Models; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: полный путь группы в иерархии (например, «Учёт / Бухгалтерия»). - /// - public class GroupFullPathConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - var group = values is { Count: > 0 } ? values[0] as Group : null; - var groups = values is { Count: > 1 } ? values[1] as ObservableCollection : null; - - if (group is null) - return string.Empty; - - return GroupHierarchyHelper.GetFullPath(group, groups); - } - - public IList? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/GroupVisibilityConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/GroupVisibilityConverter.Avalonia.cs deleted file mode 100644 index 7bf1938e..00000000 --- a/Configuration Management/Converters/Avalonia/GroupVisibilityConverter.Avalonia.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if LINUX -using System; -using System.Collections.Generic; -using System.Globalization; -using Avalonia.Controls; -using Avalonia.Data.Converters; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: возвращает Visibility строки базы в зависимости от состояния свёрнутости её группы. - /// Принимает имя группы и MainViewModel; возвращает Collapsed, если группа свёрнута. - /// - public class GroupVisibilityConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values is { Count: >= 2 } && values[0] is string groupName && values[1] is MainViewModel vm) - return vm.IsGroupCollapsed(groupName) ? false : true; - return true; - } - - public IList? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/IconHelper.Avalonia.cs b/Configuration Management/Converters/Avalonia/IconHelper.Avalonia.cs index caefc3b5..eb66e6e8 100644 --- a/Configuration Management/Converters/Avalonia/IconHelper.Avalonia.cs +++ b/Configuration Management/Converters/Avalonia/IconHelper.Avalonia.cs @@ -1,4 +1,5 @@ #if LINUX +using System.Collections.Generic; using System; using Avalonia; using Avalonia.Controls; @@ -37,7 +38,13 @@ public static class IconHelper /// (например "TextPrimaryColorBrush"), поэтому цвет /// следует теме. По умолчанию используется кисть основного текста. /// - public static Avalonia.Controls.Shapes.Path MakeIcon(string key, double size = 16, string brushKey = "TextPrimaryColorBrush") + /// + /// Необязательный приёмник подписки на ресурс-кисть. Нужен там, где иконка + /// живёт меньше приложения и пересоздаётся: без освобождения подписки + /// накапливались бы на каждую пересборку. + /// + public static Avalonia.Controls.Shapes.Path MakeIcon(string key, double size = 16, + string brushKey = "TextPrimaryColorBrush") { var path = new Avalonia.Controls.Shapes.Path { @@ -49,10 +56,18 @@ public static Avalonia.Controls.Shapes.Path MakeIcon(string key, double size = 1 HorizontalAlignment = HorizontalAlignment.Center }; - if (Application.Current is { } app) - app.GetResourceObservable(brushKey).Subscribe(new ResourceBrushObserver(path)); + if (Application.Current is not null) + { + // Динамический ресурс вместо ручной подписки: его отслеживает сам + // элемент, и при выходе из дерева отслеживание снимается. Прежняя + // подписка жила у приложения и держала элемент сильной ссылкой, + // то есть укореняла всё окно, пока её не освободят вручную. + Themes.ThemeBrushes.Bind(path, Avalonia.Controls.Shapes.Path.FillProperty, brushKey); + } else + { path.Fill = Brushes.Black; + } return path; } @@ -75,19 +90,6 @@ public static Control IconAndText(string iconKey, string text, double iconSize = }; } - /// - /// Наблюдатель, который переносит текущее значение ресурса-кисти в Path.Fill. - /// - private sealed class ResourceBrushObserver : IObserver - { - private readonly Avalonia.Controls.Shapes.Path _path; - - public ResourceBrushObserver(Avalonia.Controls.Shapes.Path path) => _path = path; - - public void OnCompleted() { } - public void OnError(Exception error) { } - public void OnNext(object? value) => _path.Fill = value as IBrush; - } } } #endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/InverseBoolToVisibilityConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/InverseBoolToVisibilityConverter.Avalonia.cs index 10e0ac8c..ae8168f7 100644 --- a/Configuration Management/Converters/Avalonia/InverseBoolToVisibilityConverter.Avalonia.cs +++ b/Configuration Management/Converters/Avalonia/InverseBoolToVisibilityConverter.Avalonia.cs @@ -7,18 +7,19 @@ namespace Configuration_Management.Converters { /// - /// Avalonia-версия: true → Collapsed, false → Visible (обратный BooleanToVisibility). + /// Avalonia-версия: true → скрыт, false → показан (обратный BooleanToVisibility). + /// В Avalonia видимостью управляет булево IsVisible, перечисления Visibility нет. /// public class InverseBoolToVisibilityConverter : IValueConverter { public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { var flag = value is bool b && b; - return flag ? Visibility.Collapsed : Visibility.Visible; + return !flag; } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => value is Visibility v && v != Visibility.Visible; + => value is bool v && !v; } } #endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/MultiValueToArrayConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/MultiValueToArrayConverter.Avalonia.cs deleted file mode 100644 index dd93a858..00000000 --- a/Configuration Management/Converters/Avalonia/MultiValueToArrayConverter.Avalonia.cs +++ /dev/null @@ -1,21 +0,0 @@ -#if LINUX -using System; -using System.Collections.Generic; -using System.Globalization; -using Avalonia.Data.Converters; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: возвращает массив значений MultiBinding как единый объект. - /// - public class MultiValueToArrayConverter : IMultiValueConverter - { - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - => values; - - public IList? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/Avalonia/NullToBoolConverter.Avalonia.cs b/Configuration Management/Converters/Avalonia/NullToBoolConverter.Avalonia.cs deleted file mode 100644 index 24d60223..00000000 --- a/Configuration Management/Converters/Avalonia/NullToBoolConverter.Avalonia.cs +++ /dev/null @@ -1,24 +0,0 @@ -#if LINUX -using System; -using System.Globalization; -using Avalonia.Data.Converters; - -namespace Configuration_Management.Converters -{ - /// - /// Avalonia-версия: true, если значение не равно null (параметр «invert» инвертирует). - /// - public class NullToBoolConverter : IValueConverter - { - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - var invert = string.Equals(parameter as string, "invert", StringComparison.OrdinalIgnoreCase); - var isNotNull = value != null; - return invert ? !isNotNull : isNotNull; - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotImplementedException(); - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Converters/ClientTypeToIconConverter.cs b/Configuration Management/Converters/ClientTypeToIconConverter.cs deleted file mode 100644 index 15898cdf..00000000 --- a/Configuration Management/Converters/ClientTypeToIconConverter.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; - -namespace Configuration_Management.Converters; - -/// -/// Конвертер для отображения значков в зависимости от типа клиента. -/// -public class ClientTypeToIconConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is string clientType) - { - return clientType switch - { - "Тонкий" => "📱", - "Толстый" => "💻", - _ => "❓" - }; - } - return "❓"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Configuration Management/Converters/ConnectionTypeToIconConverter.cs b/Configuration Management/Converters/ConnectionTypeToIconConverter.cs deleted file mode 100644 index eff4a5d9..00000000 --- a/Configuration Management/Converters/ConnectionTypeToIconConverter.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Globalization; -using System.Windows; -using System.Windows.Data; -using Configuration_Management.Models; - -namespace Configuration_Management.Converters; - -/// -/// Конвертер для отображения значков в зависимости от типа подключения. -/// -public class ConnectionTypeToIconConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value is ConnectionType connectionType) - { - return connectionType switch - { - ConnectionType.File => "📁", - ConnectionType.ClientServer => "🌐", - _ => "❓" - }; - } - return "❓"; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Configuration Management/Converters/GroupExpandSymbolConverter.cs b/Configuration Management/Converters/GroupExpandSymbolConverter.cs deleted file mode 100644 index 1442f0a1..00000000 --- a/Configuration Management/Converters/GroupExpandSymbolConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters; - -/// -/// Возвращает символ «−» или «+» для кнопки сворачивания/разворачивания группы. -/// Принимает имя группы и MainViewModel; возвращает «−», если группа развёрнута, «+» — если свёрнута. -/// -public class GroupExpandSymbolConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Length >= 2 && values[0] is string groupName && values[1] is MainViewModel vm) - { - return vm.IsGroupCollapsed(groupName) ? "+" : "−"; - } - return "−"; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/Configuration Management/Converters/GroupExpandedConverter.cs b/Configuration Management/Converters/GroupExpandedConverter.cs deleted file mode 100644 index b4ceae48..00000000 --- a/Configuration Management/Converters/GroupExpandedConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Globalization; -using System.Windows.Data; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters; - -/// -/// Конвертер для определения состояния развёрнутости группы в списке баз. -/// Принимает имя группы и MainViewModel, возвращает true, если группа развёрнута. -/// -public class GroupExpandedConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Length >= 2 && values[0] is string groupName && values[1] is MainViewModel vm) - { - return !vm.IsGroupCollapsed(groupName); - } - return true; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/Configuration Management/Converters/GroupFullPathConverter.cs b/Configuration Management/Converters/GroupFullPathConverter.cs deleted file mode 100644 index 96ed5200..00000000 --- a/Configuration Management/Converters/GroupFullPathConverter.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.ObjectModel; -using System.Globalization; -using System.Windows.Data; -using Configuration_Management.Models; - -namespace Configuration_Management.Converters; - -/// -/// Возвращает полный путь группы в иерархии (например, «Учёт / Бухгалтерия»). -/// Используется для отображения групп с учётом их родительских групп. -/// -public class GroupFullPathConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - var group = values.Length > 0 ? values[0] as Group : null; - var groups = values.Length > 1 ? values[1] as ObservableCollection : null; - - if (group is null) - return string.Empty; - - return GroupHierarchyHelper.GetFullPath(group, groups); - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/Configuration Management/Converters/GroupOffsetConverter.cs b/Configuration Management/Converters/GroupOffsetConverter.cs index a03d2799..298bee9c 100644 --- a/Configuration Management/Converters/GroupOffsetConverter.cs +++ b/Configuration Management/Converters/GroupOffsetConverter.cs @@ -29,11 +29,15 @@ public object Convert(object[] values, Type targetType, object parameter, Cultur { var level = values.Length > 0 && values[0] is int i ? i : 0; var hasItems = values.Length > 1 && values[1] is bool b && b; + // В компактном режиме вертикальный зазор между группами убирается почти полностью, + // чтобы группы располагались плотнее (сохраняется только горизонтальный отступ уровня). + var compact = values.Length > 2 && values[2] is bool cb && cb; // У групп с дочерними элементами отступ уже обеспечивает кнопка разворота. // У пустых (листовых) групп компенсируем отсутствие кнопки, чтобы иерархия сохранялась. var offset = hasItems ? 0d : level * IndentStep + ExpanderWidth; - // Вертикальный 1px — как прежний Margin="0,1" у заголовка группы - return new Thickness(offset, 1, 0, 1); + // Вертикальный 1px — как прежний Margin="0,1" у заголовка группы (в компактном — 0). + var vert = compact ? 0d : 1d; + return new Thickness(offset, vert, 0, vert); } public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) diff --git a/Configuration Management/Converters/GroupVisibilityConverter.cs b/Configuration Management/Converters/GroupVisibilityConverter.cs deleted file mode 100644 index 4b9a73a7..00000000 --- a/Configuration Management/Converters/GroupVisibilityConverter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Globalization; -using System.Windows; -using System.Windows.Data; -using Configuration_Management.ViewModels; - -namespace Configuration_Management.Converters; - -/// -/// Возвращает Visibility строки базы в зависимости от состояния свёрнутости её группы. -/// Принимает имя группы и MainViewModel; возвращает Collapsed, если группа свёрнута. -/// -public class GroupVisibilityConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - if (values.Length >= 2 && values[0] is string groupName && values[1] is MainViewModel vm) - { - return vm.IsGroupCollapsed(groupName) ? Visibility.Collapsed : Visibility.Visible; - } - return Visibility.Visible; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - => throw new NotSupportedException(); -} \ No newline at end of file diff --git a/Configuration Management/Converters/MultiFormatConverter.cs b/Configuration Management/Converters/MultiFormatConverter.cs new file mode 100644 index 00000000..24641201 --- /dev/null +++ b/Configuration Management/Converters/MultiFormatConverter.cs @@ -0,0 +1,45 @@ +using System.Globalization; +using System.Windows.Data; + +namespace Configuration_Management.Converters; + +/// +/// IMultiValueConverter, форматирующий несколько значений привязки в строку по шаблону, +/// заданному через ConverterParameter (например "{0}: {1}" или "{0} ({1})"). +/// +/// Используется для Button.ToolTip и других target-свойств типа object, где WPF не +/// применяет MultiBinding.StringFormat без явного IMultiValueConverter — там возникает +/// ошибка "Cannot set MultiBinding because MultiValueConverter must be specified". +/// +public class MultiFormatConverter : IMultiValueConverter +{ + public static readonly MultiFormatConverter Instance = new(); + + /// + /// Публичный конструктор без параметров, требуемый WPF/XAML-парсером + /// для создания экземпляра при объявлении ресурса в XAML. + /// + public MultiFormatConverter() { } + + public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) + { + var format = parameter as string; + if (string.IsNullOrWhiteSpace(format)) + return values.Length == 1 ? values[0]! : string.Join(" ", values); + + try + { + return string.Format(culture, format, values); + } + catch (FormatException) + { + // Некорректный шаблон — возвращаем его как есть, чтобы не ронять привязку. + return format; + } + } + + public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/Configuration Management/Converters/MultiValueToArrayConverter.cs b/Configuration Management/Converters/MultiValueToArrayConverter.cs deleted file mode 100644 index 74dc0b70..00000000 --- a/Configuration Management/Converters/MultiValueToArrayConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Globalization; -using System.Windows.Data; - -namespace Configuration_Management.Converters; - -/// -/// Конвертер, возвращающий массив значений MultiBinding как единый объект. -/// Используется для передачи нескольких значений в CommandParameter -/// (например, базы и тега в команду удаления тега). -/// -public class MultiValueToArrayConverter : IMultiValueConverter -{ - public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture) - { - return values; - } - - public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } -} \ No newline at end of file diff --git a/Configuration Management/Converters/NullToBoolConverter.cs b/Configuration Management/Converters/NullToBoolConverter.cs deleted file mode 100644 index 4e8a3ef0..00000000 --- a/Configuration Management/Converters/NullToBoolConverter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using System.Globalization; -using System.Windows.Data; - -namespace Configuration_Management.Converters; - -/// -/// Конвертер, возвращающий true, если значение не равно null. -/// Используется для управления доступностью элементов управления -/// в зависимости от наличия выбранного объекта. -/// -public class NullToBoolConverter : IValueConverter -{ - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - // Если передан параметр "invert" — инвертируем результат. - var invert = string.Equals(parameter as string, "invert", StringComparison.OrdinalIgnoreCase); - var isNotNull = value != null; - return invert ? !isNotNull : isNotNull; - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} \ No newline at end of file diff --git a/Configuration Management/CreateInfobaseWindow.Avalonia.cs b/Configuration Management/CreateInfobaseWindow.Avalonia.cs index 5ee586d7..a4acb65c 100644 --- a/Configuration Management/CreateInfobaseWindow.Avalonia.cs +++ b/Configuration Management/CreateInfobaseWindow.Avalonia.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; diff --git a/Configuration Management/CreateInfobaseWindow.xaml.cs b/Configuration Management/CreateInfobaseWindow.xaml.cs index 62505d93..0d0e7865 100644 --- a/Configuration Management/CreateInfobaseWindow.xaml.cs +++ b/Configuration Management/CreateInfobaseWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Collections.Generic; using System.IO; using System.Linq; @@ -386,3 +387,4 @@ private sealed class PlatformItem } } } +#endif diff --git a/Configuration Management/DeleteInfobaseWindow.Avalonia.cs b/Configuration Management/DeleteInfobaseWindow.Avalonia.cs index 78d76391..f365237e 100644 --- a/Configuration Management/DeleteInfobaseWindow.Avalonia.cs +++ b/Configuration Management/DeleteInfobaseWindow.Avalonia.cs @@ -1,5 +1,6 @@ #if LINUX using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; diff --git a/Configuration Management/DeleteInfobaseWindow.xaml.cs b/Configuration Management/DeleteInfobaseWindow.xaml.cs index 1efceaf1..f4321ef8 100644 --- a/Configuration Management/DeleteInfobaseWindow.xaml.cs +++ b/Configuration Management/DeleteInfobaseWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.IO; using System.Windows; using Configuration_Management.Localization; @@ -98,3 +99,4 @@ private void OnCancel_Click(object sender, RoutedEventArgs e) } } } +#endif diff --git a/Configuration Management/GroupEditWindow.Avalonia.cs b/Configuration Management/GroupEditWindow.Avalonia.cs index 61699aa9..b92d4f86 100644 --- a/Configuration Management/GroupEditWindow.Avalonia.cs +++ b/Configuration Management/GroupEditWindow.Avalonia.cs @@ -7,6 +7,7 @@ using Avalonia.Controls.Primitives; using Avalonia.Layout; using Avalonia.Media; +using Path = Avalonia.Controls.Shapes.Path; using Configuration_Management.Localization; using Configuration_Management.Models; using Configuration_Management.ViewModels; @@ -273,9 +274,9 @@ private void BuildIconPicker() Width = 40, Height = 40, Margin = new Thickness(3), - ToolTip = new ToolTip { Content = label }, Tag = key }; + ToolTip.SetTip(btn, label); if (string.IsNullOrEmpty(key)) { diff --git a/Configuration Management/GroupEditWindow.xaml.cs b/Configuration Management/GroupEditWindow.xaml.cs index 9068eca1..34e7c560 100644 --- a/Configuration Management/GroupEditWindow.xaml.cs +++ b/Configuration Management/GroupEditWindow.xaml.cs @@ -1,4 +1,7 @@ +#if WINDOWS +using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Windows; using System.Windows.Controls; using System.Windows.Media; @@ -386,3 +389,4 @@ private static Color ParseColor(string? hex) } } } +#endif diff --git a/Configuration Management/GroupPickerWindow.Avalonia.cs b/Configuration Management/GroupPickerWindow.Avalonia.cs index 3d8bf185..4162fc8f 100644 --- a/Configuration Management/GroupPickerWindow.Avalonia.cs +++ b/Configuration Management/GroupPickerWindow.Avalonia.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; @@ -100,7 +102,8 @@ private Control BuildRoot() // Дерево _tree.SelectionMode = SelectionMode.Single; _tree.ItemTemplate = new FuncTreeDataTemplate( - BuildTreeRow, + typeof(object), + (item, _) => BuildTreeRow(item), item => item is GroupNodeViewModel g ? g.Children : null); _tree.SelectionChanged += (_, _) => { @@ -147,7 +150,7 @@ private Control BuildRoot() return grid; } - private static Control BuildTreeRow(object? item) + private Control BuildTreeRow(object? item) { if (item is GroupNodeViewModel node) { @@ -164,6 +167,7 @@ private static Control BuildTreeRow(object? item) /// Перестраивает дерево с учётом текущего направления сортировки. private void RefreshTree() { + var roots = GroupNodeViewModel.BuildTree(_allowed); var groupComparer = StringComparer.OrdinalIgnoreCase; roots.Sort(_sortAscending diff --git a/Configuration Management/GroupPickerWindow.xaml.cs b/Configuration Management/GroupPickerWindow.xaml.cs index 13e1e8b3..bf4a2649 100644 --- a/Configuration Management/GroupPickerWindow.xaml.cs +++ b/Configuration Management/GroupPickerWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Controls; using Configuration_Management.Localization; @@ -188,3 +189,4 @@ private void OnSelect_Click(object sender, RoutedEventArgs e) } } } +#endif diff --git a/Configuration Management/GroupSettingsWindow.Avalonia.cs b/Configuration Management/GroupSettingsWindow.Avalonia.cs index 8c02d8e8..151990b6 100644 --- a/Configuration Management/GroupSettingsWindow.Avalonia.cs +++ b/Configuration Management/GroupSettingsWindow.Avalonia.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Layout; @@ -78,7 +80,8 @@ private Control BuildRoot() _tree.SelectionMode = SelectionMode.Single; _tree.ItemTemplate = new FuncTreeDataTemplate( - BuildTreeRow, + typeof(object), + (item, _) => BuildTreeRow(item), item => item is GroupNodeViewModel g ? g.Children : null); var treeBorder = new Border @@ -120,7 +123,7 @@ private Control BuildRoot() return grid; } - private static Button MakeButton(string text, string iconKey, Action onClick) + private Button MakeButton(string text, string iconKey, Action onClick) { var button = new Button { Content = IconHelper.IconAndText(iconKey, text) }; button.Click += (_, _) => onClick(); diff --git a/Configuration Management/GroupSettingsWindow.xaml.cs b/Configuration Management/GroupSettingsWindow.xaml.cs index 015619e8..af1ab657 100644 --- a/Configuration Management/GroupSettingsWindow.xaml.cs +++ b/Configuration Management/GroupSettingsWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Collections.ObjectModel; using System.Windows; using System.Windows.Controls; @@ -249,4 +250,5 @@ public static void RemoveAllInPlace(this ObservableCollection collection, } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/LaunchParametersWindow.Avalonia.cs b/Configuration Management/LaunchParametersWindow.Avalonia.cs index 7940b47e..594e7333 100644 --- a/Configuration Management/LaunchParametersWindow.Avalonia.cs +++ b/Configuration Management/LaunchParametersWindow.Avalonia.cs @@ -1,6 +1,8 @@ #if LINUX using System; using System.Collections.Generic; +using Avalonia.Controls.Primitives; +using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; @@ -27,9 +29,9 @@ public class LaunchParametersWindow : ModalWindowBase public LaunchParametersWindow(string currentParameters) { Title = LocalizationManager.T("LaunchParams.Title"); - Width = 620; - Height = 560; - MinWidth = 540; + Width = 800; + Height = 640; + MinWidth = 720; MinHeight = 480; _txtCustom = new TextBox @@ -50,11 +52,14 @@ public LaunchParametersWindow(string currentParameters) private Control BuildRoot() { var grid = new Grid { Margin = new Thickness(16) }; + // По высоте тянется справочник (строка 4), а не подпись над ним: + // раньше звёздочка стояла на подписи, справочник разворачивался + // на всю высоту содержимого и выдавливал кнопки за край окна. grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); - grid.RowDefinitions.Add(new RowDefinition(new GridLength(1, GridUnitType.Star))); grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + grid.RowDefinitions.Add(new RowDefinition(new GridLength(1, GridUnitType.Star))); var title = new TextBlock { @@ -89,14 +94,28 @@ private Control BuildRoot() list.ItemTemplate = new FuncDataTemplate((item, _) => { var panel = new Grid { Margin = new Thickness(2, 3) }; - panel.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(150))); + // 220 под текст, как в колонке WPF, плюс зазор до описания. + panel.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(232))); panel.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); - var key = new TextBlock { Text = item.Key, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + var key = new TextBlock + { + Text = item.Key, + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 0, 12, 0) + }; Grid.SetColumn(key, 0); panel.Children.Add(key); - var desc = new TextBlock { Text = item.Description, FontSize = 12, VerticalAlignment = VerticalAlignment.Center }; + var desc = new TextBlock + { + Text = item.Description, + FontSize = 12, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; Grid.SetColumn(desc, 1); panel.Children.Add(desc); return panel; diff --git a/Configuration Management/LaunchParametersWindow.xaml.cs b/Configuration Management/LaunchParametersWindow.xaml.cs index 9ad00c8b..8fe0ad30 100644 --- a/Configuration Management/LaunchParametersWindow.xaml.cs +++ b/Configuration Management/LaunchParametersWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Input; using Configuration_Management.Localization; @@ -149,4 +150,5 @@ public ParamRef(string key, string description) public string Description { get; } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/LinkInputWindow.Avalonia.cs b/Configuration Management/LinkInputWindow.Avalonia.cs index dbf38589..d012ae67 100644 --- a/Configuration Management/LinkInputWindow.Avalonia.cs +++ b/Configuration Management/LinkInputWindow.Avalonia.cs @@ -1,5 +1,6 @@ #if LINUX using System; +using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; diff --git a/Configuration Management/LinkInputWindow.xaml.cs b/Configuration Management/LinkInputWindow.xaml.cs index 78c93d88..706ff3d3 100644 --- a/Configuration Management/LinkInputWindow.xaml.cs +++ b/Configuration Management/LinkInputWindow.xaml.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.Windows; using System.Windows.Controls; using System.Windows.Input; @@ -48,4 +49,5 @@ private void OnLinkBox_KeyDown(object sender, KeyEventArgs e) } } } -} \ No newline at end of file +} +#endif \ No newline at end of file diff --git a/Configuration Management/Localization/Languages/en.json b/Configuration Management/Localization/Languages/en.json index d3fd53c0..7ace42ea 100644 --- a/Configuration Management/Localization/Languages/en.json +++ b/Configuration Management/Localization/Languages/en.json @@ -78,6 +78,7 @@ "Main.LaunchMode": "Launch mode", "Main.Bitness": "Bitness", "Main.LastLaunch": "Last launch", + "Main.CacheSize": "Cache size", "Main.Description": "Description", "Main.Exit": "Exit", "Main.RightPanel": "Right panel", @@ -94,7 +95,7 @@ "Main.DumpConfigToCf": "Dump configuration .cf", "Main.LaunchHistory": "Launch history", "Main.SessionClientAuto": "Auto", - "Main.SessionClientOrdinary": "Thin (managed)", + "Main.SessionClientOrdinary": "Ordinary", "Main.SessionClientThickManaged": "Thick (managed forms)", "Main.SessionClientThickOrdinary": "Thick (ordinary forms)", "Main.SessionClientThin": "Thin", @@ -108,6 +109,12 @@ "Main.RecentTooltip": "Recently launched", "Main.SyncDetailedTooltip": "Synchronize with ibases.v8i according to the settings mode", "Main.RefreshAllConfigInfo": "Request configuration info for all 1C infobases", + "Main.CheckAvailabilityTooltip": "Check availability of all 1C infobases", + "Main.CheckAvailabilityLabel": "Availability", + "Main.AvailabilityTitle": "Infobase availability check", + "Main.AvailabilityDone": "Availability check finished.\n\nAvailable: {0}\nUnavailable: {1}\nTotal: {2}", + "Main.AvailabilityUnavailableList": "Unavailable infobases:", + "Main.AvailabilityStatus": "Availability check: {0} total, {1} unavailable", "Main.TagFilterTitle": "Tags — click to add to the filter", "Main.ClearTagFilters": "Clear selected tags", "Main.FilterByTagLabel": "Filter by tag", @@ -148,6 +155,7 @@ "Main.DesktopShortcutTooltip": "Create a desktop shortcut for the selected infobase", "Main.MoreLaunchOptions": "Additional launch options", "Main.NativeStarterTooltip": "Open the standard 1C starter (1CEStart.exe)", + "Main.NativeStarterTooltipLinux": "Open the standard 1C starter (1cestart)", "Main.NativeStarter": "1C:Starter", "Main.CurrentSession": "Current session", "Main.SessionOnceHint": "Only for the next launch of 1C:Enterprise.", @@ -163,6 +171,8 @@ "Main.ClearCacheTooltip": "Clear 1C cache", "Main.Delete": "Delete", "Main.DeleteTooltip": "Delete", + "Main.EditGroupTooltip": "Edit group", + "Main.DeleteGroupTooltip": "Delete group", "Main.OpenByLinkShort": "By link", "Main.OpenLinkTooltip": "Enter a link to an infobase and launch it (like in the standard 1C loader)", "Main.ExitTooltip": "Fully exit the application (ignoring the 'Minimize to tray on close' setting)", @@ -185,6 +195,7 @@ "Main.ConfirmDeleteBase": "Remove infobase «{0}» from the list?", "Main.RefreshConfigInfoMsg": "Request configuration info for all infobases.\n\n(Implementation — Stage 5, 1C platform services.)", "Main.ErrOpenBaseFolder": "Could not open the file-based base directory.", + "Main.ErrOpenLink": "Failed to launch the infobase by link:\n{0}", "Main.ShortcutCreated": "Shortcut for infobase «{0}» created on the desktop.", "Main.ErrShortcutCreate": "Could not create a shortcut for infobase «{0}».\nCheck that the 1C platform is installed and the desktop is available.", "Main.ErrStartStarter": "Could not find and launch the 1C starter (1cestart).\nExpected paths: /opt/1cv8//common/1cestart, /usr/bin/1cestart.", @@ -215,6 +226,7 @@ "Column.ServerBase": "Server/Base", "Column.LastLaunch": "Last launch", "Column.Size": "Size", + "Column.Actions": "Actions", "Main.ColumnNameTooltip": "Name and version of the 1C configuration", "Settings.Title": "Settings", @@ -243,11 +255,31 @@ "Settings.DangerousOps": "Dangerous operations", "Settings.BackupBeforeSync": "Create ibases.v8i backups before synchronization", + "Settings.TabProfile": "Backup", + "Settings.Profile.Description": "Save the application settings, infobase list (including users and launch passwords), groups and the ibases.v8i file into an arbitrary folder. After reinstalling the system, specify this folder and the application will immediately open configured as before.", + "Settings.Profile.Includes": "The profile includes: settings.json, infobases.json (launch users and passwords), groups.json and the ibases.v8i file.", + "Settings.Profile.Directory": "Backup folder:", + "Settings.Profile.Browse": "Browse…", + "Settings.Profile.BrowseTooltip": "Choose the folder for the profile backup", + "Settings.Profile.RestoreOnStartup": "Restore profile on startup", + "Settings.Profile.RestoreOnStartupHint": "On every launch the application copies the settings from the chosen folder. Enable this after reinstalling the system to get your usual state immediately without manual steps.", + "Settings.Profile.BackupNow": "Save profile", + "Settings.Profile.BackupNowTooltip": "Copy current settings, infobase list with users/passwords, groups and ibases.v8i to the chosen folder", + "Settings.Profile.RestoreNow": "Restore profile", + "Settings.Profile.RestoreNowTooltip": "Copy the settings from the chosen folder and apply them", + "Settings.Profile.BackupDone": "Profile saved: files copied: {0}.", + "Settings.Profile.RestoreDone": "Profile restored: files copied: {0}. Settings applied.", + "Settings.Profile.NoDirectory": "Specify the profile backup folder.", + "Settings.Profile.NoBackup": "No profile backup found in the specified folder.", + "Settings.Profile.BackupFailed": "Failed to save profile: {0}", + "Settings.Profile.RestoreFailed": "Failed to restore profile: {0}", + "Settings.Platforms.Intro": "Installed versions of the 1C:Enterprise platform.", "Settings.Platforms.TreeTooltip": "Line (8.3) → bitness → build with path", "Settings.Platforms.Refresh": "Refresh list", "Settings.Platforms.RefreshTooltip": "Refresh the list of installed platform versions (standard and additional paths)", "Settings.AdditionalPaths.Hint": "Specify folders where 1C platforms are installed outside the standard Program Files directories. You can specify the installation root, the 1cv8 directory, or a specific version folder.", + "Settings.AdditionalPaths.HintLinux": "List directories where the 1C platform is installed outside the standard ones (/opt/1cv8, /usr/local/1cv8, ~/.1cv8). Point to the installation root, the versions directory or a specific version folder.", "Settings.AdditionalPaths.ListTooltip": "Additional directories for searching platform versions", "Settings.AdditionalPaths.Add": "Add folder", "Settings.AdditionalPaths.AddTooltip": "Add a folder for searching the 1C platform", @@ -275,6 +307,10 @@ "Settings.Columns.Description": "Visible columns in the infobase table.", "Settings.Columns.Configuration": "Configuration (name and version)", "Settings.Columns.Size": "Size (file-based)", + "Settings.Columns.OrderTitle": "Column order", + "Settings.Columns.OrderHint": "Set the left-to-right order of columns in the infobase list (the Name column is always first and cannot be moved).", + "Settings.Columns.OrderUp": "Up", + "Settings.Columns.OrderDown": "Down", "Settings.Panels.Description": "Right action panel and related options.", "Settings.Panels.RightPanelDetails": "Details in the right panel (name, connection, tags)", @@ -327,6 +363,8 @@ "Hotkey.Tooltip": "Set cursor and press a key combination. Backspace/Delete — reset, Esc — cancel.", "Settings.Hotkeys.Title": "Action hotkeys", + "Settings.Hotkeys.Unsafe": "The {0} shortcut cannot be assigned: it would take plain typing or clipboard keys away from text fields.", + "Settings.Hotkeys.Unsupported": "The {0} shortcut is not supported: choose a key with a modifier or a function key.", "Settings.Hotkeys.HelpText": "Assign hotkeys for actions (similar to the 1C Configurator).\n\n• Place the cursor in the field and press the desired combination: Ctrl, Shift, Alt, Win (e.g. Ctrl+Shift+F), or F2–F12, Delete, Insert.\n• Backspace/Delete — clear the assignment; None — the key is not used.\n• Esc — cancel the current input.\n• The same key cannot be assigned to different actions.\n• Assigned keys are applied immediately after saving settings and are shown on the right panel buttons and in the context menu.", "Settings.Hotkeys.Description": "The same key cannot be assigned to different actions. Place the cursor in the field and press the desired combination (Ctrl, Shift, Alt, etc.). Backspace/Delete — reset, None — the key is not used.", "Settings.Hotkeys.Enterprise": "1C:Enterprise:", @@ -350,7 +388,12 @@ "Settings.General.ShowTrayIcon": "Icon in the notification area", "Settings.General.CloseToTray": "Minimize to tray on close", "Settings.General.EscapeToTray": "Minimize to tray on Esc", + "Main.AfterLaunchTrayUnavailable": "No tray icon is available, the window was minimized instead of hiding: a hidden window could not be restored.", "Settings.General.RememberWindowLayout": "Remember window position, size and monitor", + "Settings.General.AfterLaunchAction": "After launching an infobase or configurator:", + "Settings.General.AfterLaunchAction.None": "Do nothing", + "Settings.General.AfterLaunchAction.MinimizeToTray": "Minimize to tray", + "Settings.General.AfterLaunchAction.Close": "Close the program", "Settings.Ibases.Description": "Synchronization of the application with the standard 1C base list file (ibases.v8i).", "Settings.Ibases.HelpText": "Synchronization with the standard 1C base list file ibases.v8i.\n\n• Mode: disabled / load only / export only / two-way (export first, then load).\n• File: path to ibases.v8i (default %APPDATA%\\1C\\1CEStart\\).\n• Synchronization moment: on start, by interval (min) or by schedule (HH:MM).\n• Load from file adds infobases from the file into the application; Export to file does the opposite.\n• It is recommended to enable ibases.v8i backups before synchronization — they can be restored with the Restore from the latest copy button.", @@ -371,6 +414,7 @@ "Settings.Bases.Description": "Export and load of the base list, template directories and other operations.", "Settings.Bases.TemplateDirsHint": "Like in the 1C starter. Empty list — paths from 1C settings and %PUBLIC%\\Documents\\1C\\1cv8\\tmplts.", + "Settings.Bases.TemplateDirsHintLinux": "Same as in the 1C starter. An empty list means paths from the 1C settings and the standard tmplts directory (~/.local/share/1C/1cv8/tmplts).", "Settings.Bases.AddTemplate": "Add…", "Settings.Bases.AddTemplateTooltip": "Add a template directory", "Settings.Bases.EditTemplate": "Edit…", @@ -399,7 +443,7 @@ "Settings.About.Author": "Author: Sivatorov", "Settings.About.Infostart": "Publication on Infostart:", "Settings.About.GitHub": "GitHub repository:", - "Settings.About.HelpText": "Version: 0.3.3.16.\n\nThis is a desktop application for managing 1C:Enterprise 8.3 infobases — a replacement for the standard 1C base list.\n\n• In many windows there is a ? icon next to elements — click it to see a hint about how that element works.\n• A full description of features and architecture is in the README.md file of the repository.", + "Settings.About.HelpText": "Version: 0.3.4.15.\n\nThis is a desktop application for managing 1C:Enterprise 8.3 infobases — a replacement for the standard 1C base list.\n\n• In many windows there is a ? icon next to elements — click it to see a hint about how that element works.\n• A full description of features and architecture is in the README.md file of the repository.", "AddEdit.Title": "Add to list", "DeleteInfobase.Title": "Delete infobase", @@ -785,6 +829,8 @@ "Settings.ExportDoneTitle": "Export scheme", "Settings.ExportFailed": "Failed to export the scheme.\n{0}", "Settings.ImportSchemeTitle": "Load color scheme", + "Settings.SchemeFailedLinux": "The color scheme operation failed.\n{0}", + "Settings.ImportReplaceLinux": "Scheme \"{0}\" already exists. Replace it with the imported one?", "Settings.ImportFailed": "Failed to load the color scheme from the file. Check the file.", "Settings.ImportDoneTitle": "Load scheme", "Settings.ImportedOk": "The color scheme «{0}» has been loaded.", @@ -920,12 +966,27 @@ "ConnectionSettings.TabConfigurator": "Configurator", "Settings.DefaultClientLabel": "Default client:", - "Settings.AvaloniaPendingTabs": "The 'Display', 'Platforms', 'ibases.v8i', 'Bases' tabs and the color scheme editor will be available after adding a public settings-saving API to the Avalonia MainViewModel.", + "Settings.AvaloniaPendingTabs": "In the Linux build the Bases tab has no template directories and no list export or import yet.", "Settings.AvaloniaHotkeysPending": "Reassigning hotkeys is deferred — a reference list of current combinations is available.", "Settings.About.Version": "Version: v{0}", "Settings.About.AvaloniaText": "An application for managing the list of 1C:Enterprise infobases.\nPortable version for Linux (Avalonia) — Stage 3: dialog windows.", "Settings.About.RuntimeInfo": "Platform: {0} | {1}", "Settings.About.DataDir": "Data directory: {0}/ConfigurationManagement", + "Settings.About.CopyTechInfo": "Copy technical information", + "Settings.About.TechInfoCopied": "Technical information has been copied to the clipboard.", + "Settings.About.TechInfoCopyFailed": "Failed to copy technical information to the clipboard.", + "Settings.About.TechInfo.Na": "N/A", + "Settings.About.TechInfo.App": "Application", + "Settings.About.TechInfo.AppVersion": "Application version", + "Settings.About.TechInfo.Ui": "UI", + "Settings.About.TechInfo.Os": "Operating system", + "Settings.About.TechInfo.Architecture": "Architecture", + "Settings.About.TechInfo.Runtime": "Runtime (.NET)", + "Settings.About.TechInfo.Processors": "Logical processors", + "Settings.About.TechInfo.Memory": "Physical memory", + "Settings.About.TechInfo.WorkingSet": "Process working set", + "Settings.About.TechInfo.Language": "UI language", + "Settings.About.TechInfo.Culture": "Region", "Common.Confirm": "Confirmation", @@ -1019,6 +1080,7 @@ "Main.IbasesFileFilter": "1C base list file (*.v8i)|*.v8i|All files (*.*)|*.*", "Main.ImportDone": "Import finished.\n\nNew bases added: {0}\nBases updated: {1}\nSkipped (disabled): {2}\nNew groups created: {3}", "Main.ImportIbasesTitle": "Import from ibases.v8i", + "Main.ImportRemovesConfirm": "The ibases.v8i file has no record of {0} infobases from the current list, and they will be removed.\n\nTo be added: {1}\nTo be updated: {2}\n\nContinue?", "Main.ErrImportFailed": "Import failed.\n{0}", "Main.ImportErrorTitle": "Import error", "Main.SyncDisabledConfirm": "Sync with ibases.v8i is disabled in settings.\n\nOpen settings to choose a mode (import / export / both)?", @@ -1045,9 +1107,11 @@ "Main.ImportConfirm": "Load {0} infobases and {1} groups from the file?\n\nThe current base and group list will be replaced.", "Main.ImportDoneMsg": "The infobase list was loaded successfully.\n\nNumber of bases: {0}\nNumber of groups: {1}", "Main.ErrLoadFailed": "Load failed.\n{0}", + "Main.SaveFailedHint": "The list could not be written to disk. Check free space and permissions on the settings directory.", "Main.LoadErrorTitle": "Load error", "Main.ClearAllAlreadyEmpty": "The infobase list is already empty.", "Main.ClearAllTitle": "Clear base list", + "Main.ClearAllPartial": "The infobase list was cleared but the groups were not, and restoring the previous list failed too. The disk now holds an empty infobase list and the previous groups.", "Main.ClearAllConfirm": "Clear the entire infobase list?\n\nBases to delete: {0}\nGroups to delete: {1}\n\nThis action is irreversible.", "Main.ClearAllDone": "The infobase list has been cleared.", "Main.ErrCopyConnection": "Failed to copy the connection string.\n{0}", @@ -1085,6 +1149,11 @@ "Main.NoProcesses": "No 1C platform processes found.", "Main.OneCProcessesTitle": "1C processes", "Main.KillProcessesConfirm": "About {0} 1C processes will be terminated.\n\nUnsaved data in open sessions may be lost.\nContinue?", + "Main.MissingUnchecked": "Could not be checked because the directory is unavailable: {0}. These infobases are kept in the list.", + "Main.MissingOnlyUnchecked": "All infobases that could be checked are in place. {0} more could not be checked: the directory is unavailable.", + "Main.ProcessesKillFailed": "Processes that could not be stopped: {0}. These usually belong to another user, such as the 1C server.", + "Main.KillProcessesServerNote": "The list includes 1C server and platform service processes: stopping them shuts down the cluster and ongoing work with infobases.", + "Main.KillProcessesBreakdown": "Found: {0}.", "Main.KillProcessesTitle": "Terminate 1C processes", "Main.ProcessesKilled": "Processes terminated: {0}.", @@ -1110,8 +1179,8 @@ "Main.ComRegNoDll": "COM connector DLLs not found in the bin directory.", "Main.ComRegRegistered": " — registered", "Main.ComRegErrorSuffix": " — error: {0}", - "Main.ComRegProgIdOk": "✓ ProgID V83.COMConnector is available to the app.", - "Main.ComRegProgIdFail": "✗ ProgID V83.COMConnector is not visible to the app.", + "Main.ComRegProgIdOk": "✓ The 1C COM connector is available to the app.", + "Main.ComRegProgIdFail": "✗ The 1C COM connector is not visible to the app.", "Main.ExportTooltipData": "Exporting data…\nOperation: {0}\nBase: {1}", "Main.ExportTooltipFile": "\nFile: {0}", @@ -1168,6 +1237,7 @@ "Launcher.BaseLinkTitle": "1C infobase link", "Launcher.PlatformExeNotFound": "Failed to find the 1C platform (1cv8.exe).\nInstall the 1C:Enterprise platform.", "Launcher.CreateExeNotFound": "1cv8.exe not found for the specified platform version.", + "Launcher.CreateExeNotFoundLinux": "The 1cv8 executable was not found for the selected platform version.", "Launcher.CreateFileDirNotSpecified": "The file infobase directory is not specified.", "Launcher.CreateDirCreateFailedFormat": "Failed to create the directory:\n{0}\n{1}", "Launcher.CreateServerOrDbNotSpecified": "Server or database name is not specified.", @@ -1182,18 +1252,27 @@ "Com.TimeoutReadFormat": "Read timeout exceeded ({0} ms).", "Com.MetadataPropertyFailed": "Failed to get the Metadata property from the connection.", "Com.MetadataReadFailed": "Failed to read the configuration name/version from Metadata.", + "Com.DisabledForSession": "The 1C COM connector is unavailable: it was disabled after failures. To try again, run \"Refresh configuration info\" or register the COM connector.", + "Com.AgentStartFailed": "Failed to start the helper process for reading configuration info.", + "Com.AgentCrashedFormat": "The 1C COM connector aborted the helper process (exit code {0}).", + "Com.AgentCrashedUnknownCode": "The 1C COM connector aborted the helper process.", + "Com.UnknownCode": "code unknown", + "Com.DbErrorCodeOnlyFormat": "1C error while connecting to the infobase ({0}).", + "Com.DbErrorHiddenFormat": "1C error while connecting to the infobase ({0}). The error text is hidden: the infobase has a password and the text may contain it.", + "Com.AgentNoResult": "The helper process returned no result.", + "Com.AgentBadRequest": "The helper process rejected the configuration read request.", "Com.ConnStringBuildFailed": "Failed to build the COM connection string (server/database or file infobase path is not specified).", "Com.ProgIdTypeFailedFormat": "Failed to get the COM type {0}: {1}", "Com.ProgIdNotRegisteredFormat": "COM connector {0} is not registered on the system.", "Com.ProgIdInstanceFailedFormat": "Failed to create an instance of the COM connector {0}.", "Com.ProgIdNoConnectionFormat": "COM connector {0} did not return a connection for the specified connection string.", "Com.ConnectFailedFormat": "COM connection via {0} failed: {1}", - "Com.NotFound": "1C COM connector not found (V83/V82/V81.COMConnector are not registered).", + "Com.NotFound": "1C COM connector not found (V85/V83/V82/V81.COMConnector are not registered).", "Com.NotFoundInstallHint": "Install the 1C:Enterprise platform on this machine (or register the COM connector via the menu).", - "Com.ProgIdStatusBothVisible": "(V83.COMConnector is registered in both the 64-bit and 32-bit registry, but is not visible to the application process)", - "Com.ProgIdStatusOnly64": "(V83.COMConnector is present only in the 64-bit registry; the application is probably running as 32-bit)", - "Com.ProgIdStatusOnly32": "(V83.COMConnector is present only in the 32-bit registry; the application is probably running as 64-bit)", - "Com.ProgIdStatusAbsent": "(V83.COMConnector is missing from the registry — the 1C platform is not installed or the COM connector is not registered)", + "Com.ProgIdStatusBothVisible": "(the 1C COM connector is registered in both the 64-bit and 32-bit registry, but is not visible to the application process)", + "Com.ProgIdStatusOnly64": "(the 1C COM connector is present only in the 64-bit registry; the application is probably running as 32-bit)", + "Com.ProgIdStatusOnly32": "(the 1C COM connector is present only in the 32-bit registry; the application is probably running as 64-bit)", + "Com.ProgIdStatusAbsent": "(the 1C COM connector is missing from the registry — the 1C platform is not installed or the COM connector is not registered)", "Com.LinuxUnavailable": "COM connector is unavailable on Linux.", "Com.LinuxConfigViaFileDesigner": "Configuration details are read via the database file (1Cv8.1CD) or DESIGNER.", "Com.LinuxFileAndDesignerFailed": "Failed to read the configuration version from 1Cv8.1CD and via DESIGNER.", @@ -1204,7 +1283,7 @@ "ComReg.LinuxUnavailableNote": "1C COM connector is unavailable on Linux. Configuration reading is performed without COM.", "ComReg.PlatformNotFoundFormat": "1C platform not found. Checked directories:\n{0}\n\n", "ComReg.PlatformNotFoundHint": "If 1C is installed in a non-standard folder, add its path in Settings → Platforms.", - "ComReg.ProgIdNotVisibleNote": "DLLs are registered, but the ProgID V83.COMConnector is not visible to the application process.", + "ComReg.ProgIdNotVisibleNote": "DLLs are registered, but the 1C COM connector is not visible to the application process.", "ComReg.ProgIdNotVisibleHint": "Check that the COM connector of the architecture matching the application is registered.", "ComReg.RegSvr32StartFailed": "Failed to start regsvr32.", "ComReg.RegSvr32Timeout": "Timed out waiting for regsvr32.", @@ -1233,6 +1312,7 @@ "Maint.PhysicalDeleteOnlyFile": "Physical deletion is available only for file infobases.", "Maint.FileBaseDirNotFound": "The file infobase directory was not found on disk.", + "Maint.CannotDeleteSymlinkFormat": "The infobase directory is a symbolic link:\n{0}\n\nDeleting the link would not remove the infobase files, so physical deletion was cancelled.", "Maint.CannotDeleteSystemRootFormat": "Refused: cannot delete a system or root directory ({0}).", "Maint.DeleteFailedFormat": "Failed to delete the directory:\n{0}\n\n{1}", "Maint.StarterNotFound": "1CEStart.exe not found.\nExpected path: Program Files\\1cv8\\common\\1CEStart.exe", diff --git a/Configuration Management/Localization/Languages/ru.json b/Configuration Management/Localization/Languages/ru.json index 966844f0..146ed8a7 100644 --- a/Configuration Management/Localization/Languages/ru.json +++ b/Configuration Management/Localization/Languages/ru.json @@ -78,6 +78,7 @@ "Main.LaunchMode": "Режим запуска", "Main.Bitness": "Разрядность", "Main.LastLaunch": "Последний запуск", + "Main.CacheSize": "Размер кеша", "Main.Description": "Описание", "Main.Exit": "Выход", "Main.RightPanel": "Правая панель", @@ -108,6 +109,12 @@ "Main.RecentTooltip": "Недавно запускавшиеся", "Main.SyncDetailedTooltip": "Синхронизация с ibases.v8i по режиму из настроек", "Main.RefreshAllConfigInfo": "Запросить информацию о конфигурации по всем базам 1С", + "Main.CheckAvailabilityTooltip": "Проверить доступность всех баз 1С", + "Main.CheckAvailabilityLabel": "Доступность", + "Main.AvailabilityTitle": "Проверка доступности баз", + "Main.AvailabilityDone": "Проверка доступности завершена.\n\nДоступно баз: {0}\nНедоступно: {1}\nВсего: {2}", + "Main.AvailabilityUnavailableList": "Недоступные базы:", + "Main.AvailabilityStatus": "Проверка доступности: всего {0}, недоступно {1}", "Main.TagFilterTitle": "Теги — нажмите, чтобы добавить в фильтр", "Main.ClearTagFilters": "Сбросить выбранные теги", "Main.FilterByTagLabel": "Отбор по тегу", @@ -148,6 +155,7 @@ "Main.DesktopShortcutTooltip": "Создать ярлык на рабочем столе для выбранной базы", "Main.MoreLaunchOptions": "Дополнительные варианты запуска", "Main.NativeStarterTooltip": "Открыть стандартный стартер 1С (1CEStart.exe)", + "Main.NativeStarterTooltipLinux": "Открыть стандартный стартер 1С (1cestart)", "Main.NativeStarter": "1С:Стартер", "Main.CurrentSession": "Текущая сессия", "Main.SessionOnceHint": "Только для следующего запуска 1С:Предприятие.", @@ -163,6 +171,8 @@ "Main.ClearCacheTooltip": "Очистить кэш 1С", "Main.Delete": "Удалить", "Main.DeleteTooltip": "Удалить", + "Main.EditGroupTooltip": "Изменить группу", + "Main.DeleteGroupTooltip": "Удалить группу", "Main.OpenByLinkShort": "По ссылке", "Main.OpenLinkTooltip": "Ввести ссылку на информационную базу и запустить её (как в стандартном загрузчике 1С)", "Main.ExitTooltip": "Полностью завершить работу приложения (игнорируя настройку «Закрывать в трей»)", @@ -185,6 +195,7 @@ "Main.ConfirmDeleteBase": "Удалить базу «{0}» из списка?", "Main.RefreshConfigInfoMsg": "Запрос информации о конфигурации по всем базам.\n\n(Реализация — Этап 5, сервисы платформы 1С.)", "Main.ErrOpenBaseFolder": "Не удалось открыть каталог файловой базы.", + "Main.ErrOpenLink": "Не удалось запустить базу по ссылке:\n{0}", "Main.ShortcutCreated": "Ярлык для базы «{0}» создан на рабочем столе.", "Main.ErrShortcutCreate": "Не удалось создать ярлык для базы «{0}».\nПроверьте, что установлена платформа 1С и доступен рабочий стол.", "Main.ErrStartStarter": "Не удалось найти и запустить стартер 1С (1cestart).\nОжидаемые пути: /opt/1cv8/<вер>/common/1cestart, /usr/bin/1cestart.", @@ -215,6 +226,7 @@ "Column.ServerBase": "Сервер/База", "Column.LastLaunch": "Последний запуск", "Column.Size": "Размер", + "Column.Actions": "Действия", "Main.ColumnNameTooltip": "Название и версия конфигурации 1С", "Settings.Title": "Настройки", @@ -243,11 +255,31 @@ "Settings.DangerousOps": "Опасные операции", "Settings.BackupBeforeSync": "Создавать резервные копии ibases.v8i перед синхронизацией", + "Settings.TabProfile": "Резервное копирование", + "Settings.Profile.Description": "Сохраните настройки приложения, список баз (включая пользователей и пароли запуска), группы и файл ibases.v8i в произвольный каталог. После переустановки системы укажите этот каталог — и приложение сразу откроется настроенным как раньше.", + "Settings.Profile.Includes": "В профиль входят: settings.json, infobases.json (пользователи и пароли запуска), groups.json и файл ibases.v8i.", + "Settings.Profile.Directory": "Каталог резервной копии:", + "Settings.Profile.Browse": "Обзор…", + "Settings.Profile.BrowseTooltip": "Выбрать каталог для резервного копирования профиля", + "Settings.Profile.RestoreOnStartup": "Восстанавливать профиль при запуске", + "Settings.Profile.RestoreOnStartupHint": "При каждом запуске приложение копирует настройки из выбранного каталога. Включите это после переустановки системы, чтобы сразу получить привычное состояние без ручных действий.", + "Settings.Profile.BackupNow": "Сохранить профиль", + "Settings.Profile.BackupNowTooltip": "Скопировать текущие настройки, список баз с пользователями и паролями, группы и ibases.v8i в выбранный каталог", + "Settings.Profile.RestoreNow": "Восстановить профиль", + "Settings.Profile.RestoreNowTooltip": "Скопировать настройки из выбранного каталога и применить их", + "Settings.Profile.BackupDone": "Профиль сохранён: скопировано файлов: {0}.", + "Settings.Profile.RestoreDone": "Профиль восстановлен: скопировано файлов: {0}. Настройки применены.", + "Settings.Profile.NoDirectory": "Укажите каталог резервной копии профиля.", + "Settings.Profile.NoBackup": "В указанном каталоге резервных копий профиля не найдено.", + "Settings.Profile.BackupFailed": "Не удалось сохранить профиль: {0}", + "Settings.Profile.RestoreFailed": "Не удалось восстановить профиль: {0}", + "Settings.Platforms.Intro": "Установленные версии платформы 1С:Предприятие.", "Settings.Platforms.TreeTooltip": "Линия (8.3) → разрядность → сборка с путём", "Settings.Platforms.Refresh": "Обновить список", "Settings.Platforms.RefreshTooltip": "Обновить список установленных версий платформы (стандартные и дополнительные пути)", "Settings.AdditionalPaths.Hint": "Укажите папки, в которых установлены платформы 1С вне стандартных каталогов Program Files. Можно указать корень установки, каталог 1cv8 или папку конкретной версии.", + "Settings.AdditionalPaths.HintLinux": "Укажите каталоги, где установлена платформа 1С помимо стандартных (/opt/1cv8, /usr/local/1cv8, ~/.1cv8). Можно указать корень установки, каталог с версиями или папку конкретной версии.", "Settings.AdditionalPaths.ListTooltip": "Дополнительные каталоги для поиска версий платформы", "Settings.AdditionalPaths.Add": "Добавить папку", "Settings.AdditionalPaths.AddTooltip": "Добавить папку для поиска платформы 1С", @@ -275,6 +307,10 @@ "Settings.Columns.Description": "Видимые колонки в таблице информационных баз.", "Settings.Columns.Configuration": "Конфигурация (название и версия)", "Settings.Columns.Size": "Размер (файловые ИБ)", + "Settings.Columns.OrderTitle": "Порядок колонок", + "Settings.Columns.OrderHint": "Задайте порядок колонок списка баз слева направо (колонка «Название» всегда первая и не перемещается).", + "Settings.Columns.OrderUp": "Вверх", + "Settings.Columns.OrderDown": "Вниз", "Settings.Panels.Description": "Правая панель действий и связанные опции.", "Settings.Panels.RightPanelDetails": "Подробности в правой панели (имя, подключение, теги)", @@ -327,6 +363,8 @@ "Hotkey.Tooltip": "Установите курсор и нажмите сочетание клавиш. Backspace/Delete — сбросить, Esc — отмена.", "Settings.Hotkeys.Title": "Горячие клавиши действий", + "Settings.Hotkeys.Unsafe": "Сочетание {0} нельзя назначить: оно отберёт обычный ввод или работу с буфером обмена у полей ввода.", + "Settings.Hotkeys.Unsupported": "Сочетание {0} не поддерживается: выберите клавишу с модификатором или функциональную клавишу.", "Settings.Hotkeys.HelpText": "Назначение горячих клавиш действий (по аналогии с конфигуратором 1С).\n\n• Установите курсор в поле и нажмите нужную комбинацию: Ctrl, Shift, Alt, Win (например Ctrl+Shift+F), либо F2–F12, Delete, Insert.\n• Backspace/Delete — сбросить назначение; «Нет» — клавиша не используется.\n• Esc — отменить текущий ввод.\n• Одна и та же клавиша не может быть назначена разным действиям.\n• Назначенные клавиши применяются сразу после сохранения настроек и отображаются на кнопках правой панели и в контекстном меню.", "Settings.Hotkeys.Description": "Одна и та же клавиша не может быть назначена разным действиям. Установите курсор в поле и нажмите нужное сочетание (Ctrl, Shift, Alt и др.). Backspace/Delete — сбросить, «Нет» — клавиша не используется.", "Settings.Hotkeys.Enterprise": "1С:Предприятие:", @@ -350,7 +388,12 @@ "Settings.General.ShowTrayIcon": "Значок в области уведомлений", "Settings.General.CloseToTray": "Сворачивать в трей при закрытии", "Settings.General.EscapeToTray": "По Esc сворачивать в трей", + "Main.AfterLaunchTrayUnavailable": "Значка в трее нет, окно свёрнуто вместо ухода в трей: спрятанное окно было бы нечем вернуть.", "Settings.General.RememberWindowLayout": "Запоминать расположение, размер и монитор окна", + "Settings.General.AfterLaunchAction": "После запуска базы или конфигуратора:", + "Settings.General.AfterLaunchAction.None": "Ничего", + "Settings.General.AfterLaunchAction.MinimizeToTray": "Свернуть в трей", + "Settings.General.AfterLaunchAction.Close": "Закрыть программу", "Settings.Ibases.Description": "Синхронизация приложения со стандартным файлом списка баз 1С (ibases.v8i).", "Settings.Ibases.HelpText": "Синхронизация со стандартным файлом списка баз 1С ibases.v8i.\n\n• Режим: отключена / только загрузка / только выгрузка / двусторонняя (сначала выгрузка, затем загрузка).\n• Файл: путь к ibases.v8i (по умолчанию %APPDATA%\\1C\\1CEStart\\).\n• Момент синхронизации: при старте, по интервалу (мин) или по расписанию (ЧЧ:ММ).\n• «Загрузить из файла» — добавить базы из файла в приложение; «Выгрузить в файл» — наоборот.\n• Рекомендуется включать резервные копии ibases.v8i перед синхронизацией — их можно восстановить кнопкой «Восстановить из последней копии».", @@ -371,6 +414,7 @@ "Settings.Bases.Description": "Экспорт и загрузка списка баз, каталоги шаблонов и другие операции.", "Settings.Bases.TemplateDirsHint": "Как в стартере 1С. Пустой список — пути из настроек 1С и %PUBLIC%\\Documents\\1C\\1cv8\\tmplts.", + "Settings.Bases.TemplateDirsHintLinux": "Как в стартере 1С. Пустой список: пути из настроек 1С и стандартный каталог tmplts (~/.local/share/1C/1cv8/tmplts).", "Settings.Bases.AddTemplate": "Добавить…", "Settings.Bases.AddTemplateTooltip": "Добавить каталог шаблонов", "Settings.Bases.EditTemplate": "Изменить…", @@ -399,7 +443,7 @@ "Settings.About.Author": "Автор: Sivatorov", "Settings.About.Infostart": "Публикация на Infostart:", "Settings.About.GitHub": "Репозиторий GitHub:", - "Settings.About.HelpText": "Версия: 0.3.3.16.\n\nЭто десктопное приложение для управления информационными базами 1С:Предприятие 8.3 — замена стандартному списку баз 1С.\n\n• Во многих окнах рядом с элементами есть значок «?» — нажмите его, чтобы увидеть подсказку о том, как работает этот элемент.\n• Полное описание возможностей и архитектуры — в файле README.md репозитория.", + "Settings.About.HelpText": "Версия: 0.3.4.15.\n\nЭто десктопное приложение для управления информационными базами 1С:Предприятие 8.3 — замена стандартному списку баз 1С.\n\n• Во многих окнах рядом с элементами есть значок «?» — нажмите его, чтобы увидеть подсказку о том, как работает этот элемент.\n• Полное описание возможностей и архитектуры — в файле README.md репозитория.", "AddEdit.Title": "Добавление в список", "DeleteInfobase.Title": "Удаление информационной базы", @@ -785,6 +829,8 @@ "Settings.ExportDoneTitle": "Выгрузить схему", "Settings.ExportFailed": "Не удалось выгрузить схему.\n{0}", "Settings.ImportSchemeTitle": "Загрузить цветовую схему", + "Settings.SchemeFailedLinux": "Не удалось выполнить операцию со схемой.\n{0}", + "Settings.ImportReplaceLinux": "Схема «{0}» уже есть. Заменить её загруженной?", "Settings.ImportFailed": "Не удалось загрузить цветовую схему из файла. Проверьте файл.", "Settings.ImportDoneTitle": "Загрузить схему", "Settings.ImportedOk": "Цветовая схема «{0}» загружена.", @@ -920,12 +966,27 @@ "ConnectionSettings.TabConfigurator": "Конфигуратор", "Settings.DefaultClientLabel": "Клиент по умолчанию:", - "Settings.AvaloniaPendingTabs": "Вкладки «Отображение», «Платформы», «ibases.v8i», «Базы» и редактор цветовых схем будут доступны после добавления публичного API сохранения настроек в Avalonia-версию MainViewModel.", + "Settings.AvaloniaPendingTabs": "В Linux-версии на вкладке «Базы» пока нет каталогов шаблонов, экспорта и импорта списка баз.", "Settings.AvaloniaHotkeysPending": "Изменение назначения клавиш (переназначение) отложено — доступен справочный список текущих сочетаний.", "Settings.About.Version": "Версия: v{0}", "Settings.About.AvaloniaText": "Приложение для управления списком информационных баз 1С:Предприятие.\nПортируемая версия для Linux (Avalonia) — Этап 3: диалоговые окна.", "Settings.About.RuntimeInfo": "Платформа: {0} | {1}", "Settings.About.DataDir": "Каталог данных: {0}/ConfigurationManagement", + "Settings.About.CopyTechInfo": "Скопировать техническую информацию", + "Settings.About.TechInfoCopied": "Техническая информация скопирована в буфер обмена.", + "Settings.About.TechInfoCopyFailed": "Не удалось скопировать техническую информацию в буфер обмена.", + "Settings.About.TechInfo.Na": "н/д", + "Settings.About.TechInfo.App": "Приложение", + "Settings.About.TechInfo.AppVersion": "Версия приложения", + "Settings.About.TechInfo.Ui": "Интерфейс", + "Settings.About.TechInfo.Os": "Операционная система", + "Settings.About.TechInfo.Architecture": "Архитектура", + "Settings.About.TechInfo.Runtime": "Среда выполнения (.NET)", + "Settings.About.TechInfo.Processors": "Логические процессоры", + "Settings.About.TechInfo.Memory": "Физическая память", + "Settings.About.TechInfo.WorkingSet": "Память процесса", + "Settings.About.TechInfo.Language": "Язык интерфейса", + "Settings.About.TechInfo.Culture": "Регион", "Common.Confirm": "Подтверждение", @@ -1019,6 +1080,7 @@ "Main.IbasesFileFilter": "Файл списка баз 1С (*.v8i)|*.v8i|Все файлы (*.*)|*.*", "Main.ImportDone": "Импорт завершён.\n\nДобавлено новых баз: {0}\nОбновлено баз: {1}\nПропущено (отключено): {2}\nСоздано новых групп: {3}", "Main.ImportIbasesTitle": "Импорт из ibases.v8i", + "Main.ImportRemovesConfirm": "Файл ibases.v8i не содержит {0} баз из текущего списка, и они будут удалены.\n\nБудет добавлено: {1}\nБудет обновлено: {2}\n\nПродолжить?", "Main.ErrImportFailed": "Не удалось выполнить импорт.\n{0}", "Main.ImportErrorTitle": "Ошибка импорта", "Main.SyncDisabledConfirm": "Синхронизация с файлом ibases.v8i отключена в настройках.\n\nОткрыть настройки, чтобы выбрать режим (загрузка / выгрузка / двусторонняя)?", @@ -1045,9 +1107,11 @@ "Main.ImportConfirm": "Загрузить {0} информационных баз и {1} групп из файла?\n\nТекущий список баз и групп будет заменён.", "Main.ImportDoneMsg": "Список информационных баз успешно загружен.\n\nКоличество баз: {0}\nКоличество групп: {1}", "Main.ErrLoadFailed": "Не удалось выполнить загрузку.\n{0}", + "Main.SaveFailedHint": "Не удалось записать список на диск. Проверьте свободное место и права на каталог настроек.", "Main.LoadErrorTitle": "Ошибка загрузки", "Main.ClearAllAlreadyEmpty": "Список информационных баз уже пуст.", "Main.ClearAllTitle": "Очистка списка баз", + "Main.ClearAllPartial": "Список баз удалось очистить, а группы нет, и вернуть прежний список тоже не вышло. На диске сейчас пустой список баз и прежние группы.", "Main.ClearAllConfirm": "Очистить весь список информационных баз?\n\nБудет удалено баз: {0}\nБудет удалено групп: {1}\n\nЭто действие необратимо.", "Main.ClearAllDone": "Список информационных баз очищен.", "Main.ErrCopyConnection": "Не удалось скопировать строку подключения.\n{0}", @@ -1085,6 +1149,11 @@ "Main.NoProcesses": "Процессы платформы 1С не найдены.", "Main.OneCProcessesTitle": "Процессы 1С", "Main.KillProcessesConfirm": "Будет завершено процессов 1С: примерно {0}.\n\nНесохранённые данные в открытых сеансах могут быть потеряны.\nПродолжить?", + "Main.MissingUnchecked": "Проверить не удалось, каталог недоступен: {0}. Эти базы оставлены в списке.", + "Main.MissingOnlyUnchecked": "Все файловые базы, которые удалось проверить, на месте. Ещё {0} проверить не удалось: каталог недоступен.", + "Main.ProcessesKillFailed": "Не удалось завершить процессов: {0}. Обычно это процессы другого пользователя, например сервера 1С.", + "Main.KillProcessesServerNote": "Среди них есть процессы сервера 1С и служебные процессы платформы: их завершение остановит кластер и работу с базами.", + "Main.KillProcessesBreakdown": "Найдено: {0}.", "Main.KillProcessesTitle": "Завершение процессов 1С", "Main.ProcessesKilled": "Завершено процессов: {0}.", @@ -1110,8 +1179,8 @@ "Main.ComRegNoDll": "DLL COM-коннектора не найдены в каталоге bin.", "Main.ComRegRegistered": " — зарегистрирован", "Main.ComRegErrorSuffix": " — ошибка: {0}", - "Main.ComRegProgIdOk": "✓ ProgID V83.COMConnector доступен приложению.", - "Main.ComRegProgIdFail": "✗ ProgID V83.COMConnector не виден приложению.", + "Main.ComRegProgIdOk": "✓ COM-коннектор 1С доступен приложению.", + "Main.ComRegProgIdFail": "✗ COM-коннектор 1С не виден приложению.", "Main.ExportTooltipData": "Выгружаются данные…\nОперация: {0}\nБаза: {1}", "Main.ExportTooltipFile": "\nФайл: {0}", @@ -1168,6 +1237,7 @@ "Launcher.BaseLinkTitle": "Ссылка на базу 1С", "Launcher.PlatformExeNotFound": "Не удалось найти платформу 1С (1cv8.exe).\nУстановите платформу 1С:Предприятие.", "Launcher.CreateExeNotFound": "Не найден 1cv8.exe для указанной версии платформы.", + "Launcher.CreateExeNotFoundLinux": "Не найден исполняемый файл 1cv8 для указанной версии платформы.", "Launcher.CreateFileDirNotSpecified": "Не указан каталог файловой базы.", "Launcher.CreateDirCreateFailedFormat": "Не удалось создать каталог:\n{0}\n{1}", "Launcher.CreateServerOrDbNotSpecified": "Не указаны сервер или имя базы.", @@ -1182,18 +1252,27 @@ "Com.TimeoutReadFormat": "Превышен таймаут чтения ({0} мс).", "Com.MetadataPropertyFailed": "Не удалось получить свойство Metadata из соединения.", "Com.MetadataReadFailed": "Не удалось прочитать наименование/версию конфигурации из Metadata.", + "Com.DisabledForSession": "COM-коннектор 1С недоступен: отключён после сбоев. Чтобы попробовать снова, выполните «Обновить сведения о конфигурации» или зарегистрируйте COM-коннектор.", + "Com.AgentStartFailed": "Не удалось запустить вспомогательный процесс чтения сведений о конфигурации.", + "Com.AgentCrashedFormat": "COM-коннектор 1С аварийно завершил вспомогательный процесс (код возврата {0}).", + "Com.AgentCrashedUnknownCode": "COM-коннектор 1С аварийно завершил вспомогательный процесс.", + "Com.UnknownCode": "код неизвестен", + "Com.DbErrorCodeOnlyFormat": "Ошибка 1С при подключении к базе ({0}).", + "Com.DbErrorHiddenFormat": "Ошибка 1С при подключении к базе ({0}). Текст ошибки скрыт: у базы задан пароль, и текст может его содержать.", + "Com.AgentNoResult": "Вспомогательный процесс не вернул результат.", + "Com.AgentBadRequest": "Вспомогательный процесс не принял запрос на чтение сведений.", "Com.ConnStringBuildFailed": "Не удалось построить строку подключения COM (не заполнены сервер/база или путь к файловой базе).", "Com.ProgIdTypeFailedFormat": "Не удалось получить COM-тип {0}: {1}", "Com.ProgIdNotRegisteredFormat": "COM-коннектор {0} не зарегистрирован в системе.", "Com.ProgIdInstanceFailedFormat": "Не удалось создать экземпляр COM-коннектора {0}.", "Com.ProgIdNoConnectionFormat": "COM-коннектор {0} не вернул соединение для указанной строки подключения.", "Com.ConnectFailedFormat": "COM-подключение через {0} не удалось: {1}", - "Com.NotFound": "COM-коннектор 1С не найден (не зарегистрированы V83/V82/V81.COMConnector).", + "Com.NotFound": "COM-коннектор 1С не найден (не зарегистрированы V85/V83/V82/V81.COMConnector).", "Com.NotFoundInstallHint": "Установите платформу 1С:Предприятие на этой машине (или зарегистрируйте COM-коннектор через меню).", - "Com.ProgIdStatusBothVisible": "(V83.COMConnector зарегистрирован и в 64-битном, и в 32-битном реестре, но не виден процессу приложения)", - "Com.ProgIdStatusOnly64": "(V83.COMConnector есть только в 64-битном реестре; приложение, вероятно, запущено как 32-битное)", - "Com.ProgIdStatusOnly32": "(V83.COMConnector есть только в 32-битном реестре; приложение, вероятно, запущено как 64-битное)", - "Com.ProgIdStatusAbsent": "(V83.COMConnector отсутствует в реестре — платформа 1С не установлена или COM-коннектор не зарегистрирован)", + "Com.ProgIdStatusBothVisible": "(COM-коннектор 1С зарегистрирован и в 64-битном, и в 32-битном реестре, но не виден процессу приложения)", + "Com.ProgIdStatusOnly64": "(COM-коннектор 1С есть только в 64-битном реестре; приложение, вероятно, запущено как 32-битное)", + "Com.ProgIdStatusOnly32": "(COM-коннектор 1С есть только в 32-битном реестре; приложение, вероятно, запущено как 64-битное)", + "Com.ProgIdStatusAbsent": "(COM-коннектор 1С отсутствует в реестре — платформа 1С не установлена или COM-коннектор не зарегистрирован)", "Com.LinuxUnavailable": "COM-коннектор недоступен на Linux.", "Com.LinuxConfigViaFileDesigner": "Сведения о конфигурации читаются через файл базы (1Cv8.1CD) или DESIGNER.", "Com.LinuxFileAndDesignerFailed": "Не удалось прочитать версию конфигурации из 1Cv8.1CD и через DESIGNER.", @@ -1204,7 +1283,7 @@ "ComReg.LinuxUnavailableNote": "COM-коннектор 1С недоступен на Linux. Чтение конфигурации выполняется без COM.", "ComReg.PlatformNotFoundFormat": "Платформа 1С не найдена. Проверены каталоги:\n{0}\n\n", "ComReg.PlatformNotFoundHint": "Если 1С установлена в нестандартную папку — добавьте её путь в «Настройки → Платформы».", - "ComReg.ProgIdNotVisibleNote": "DLL зарегистрированы, но ProgID V83.COMConnector не виден процессу приложения.", + "ComReg.ProgIdNotVisibleNote": "DLL зарегистрированы, но COM-коннектор 1С не виден процессу приложения.", "ComReg.ProgIdNotVisibleHint": "Проверьте, что зарегистрирован COM-коннектор разрядности, совпадающей с приложением.", "ComReg.RegSvr32StartFailed": "Не удалось запустить regsvr32.", "ComReg.RegSvr32Timeout": "Превышено время ожидания regsvr32.", @@ -1233,6 +1312,7 @@ "Maint.PhysicalDeleteOnlyFile": "Физическое удаление доступно только для файловых баз.", "Maint.FileBaseDirNotFound": "Каталог файловой базы не найден на диске.", + "Maint.CannotDeleteSymlinkFormat": "Каталог базы задан символической ссылкой:\n{0}\n\nУдаление ссылки не удалило бы файлы базы, поэтому физическое удаление отменено.", "Maint.CannotDeleteSystemRootFormat": "Отказ: нельзя удалить системный или корневой каталог ({0}).", "Maint.DeleteFailedFormat": "Не удалось удалить каталог:\n{0}\n\n{1}", "Maint.StarterNotFound": "Не найден 1CEStart.exe.\nОжидаемый путь: Program Files\\1cv8\\common\\1CEStart.exe", diff --git a/Configuration Management/Localization/LocExtension.cs b/Configuration Management/Localization/LocExtension.cs index 5daeae80..59bbdcfc 100644 --- a/Configuration Management/Localization/LocExtension.cs +++ b/Configuration Management/Localization/LocExtension.cs @@ -8,12 +8,18 @@ namespace Configuration_Management.Localization /// /// WPF-расширение разметки для локализации XAML: {loc:Loc Key}. /// - /// Создаёт привязку к источнику локализации - /// с конвертером (ключ передаётся через - /// ). Такой способ не зависит от парсинга - /// пути индексера и корректно работает с ключами, содержащими точки - /// (например Column.Name). При смене языка источник уведомляет привязку, - /// и текст обновляется автоматически. + /// Создаёт КОНВЕРТЕРНУЮ привязку к : + /// Source = LocalizationManager.Instance.Source, + /// Converter = LocalizationValueConverter.Instance, + /// ConverterParameter = Key. Конвертер читает перевод текущего языка + /// напрямую через , поэтому при + /// загрузке элемент сразу получает корректный текст (а не сырой ключ). + /// + /// Привязка WPF без пути НЕ подписывается на INotifyPropertyChanged источника, + /// поэтому после создания привязка дополнительно регистрируется в источнике + /// (). При смене языка + /// вызывает UpdateTarget() у всех + /// зарегистрированных выражений, и текст обновляется без перезапуска. /// /// Примеры: /// @@ -43,8 +49,6 @@ public override object ProvideValue(IServiceProvider serviceProvider) var binding = new Binding { - // Привязываемся к самому источнику (без индексерного пути), - // значение получаем через конвертер по ConverterParameter=Key. Source = LocalizationManager.Instance.Source, Mode = BindingMode.OneWay, Converter = LocalizationValueConverter.Instance, @@ -53,7 +57,19 @@ public override object ProvideValue(IServiceProvider serviceProvider) // Возвращаем результат ProvideValue у Binding (BindingExpression), // а не сам объект Binding. - return binding.ProvideValue(serviceProvider); + var value = binding.ProvideValue(serviceProvider); + + // Динамическое обновление: привязка без пути не реагирует на + // PropertyChanged источника, поэтому регистрируем выражение в источнике, + // который вызовет UpdateTarget() при NotifyAll() (смене языка). + if (value is BindingExpression expression) + { + LocalizationManager.Instance.Source.RegisterForUpdate( + expression, + static target => ((BindingExpression)target).UpdateTarget()); + } + + return value; } } } diff --git a/Configuration Management/Localization/LocalizationManager.cs b/Configuration Management/Localization/LocalizationManager.cs index b46a59b7..eb5159f8 100644 --- a/Configuration Management/Localization/LocalizationManager.cs +++ b/Configuration Management/Localization/LocalizationManager.cs @@ -125,6 +125,13 @@ public void Initialize(string? preferredLanguage = null, string? dataDirectory = string selected = ResolveLanguage(preferredLanguage); SetLanguage(selected); + // [DEBUG] Диагностика локализации: какие языки загружены и какой выбран. + Console.Error.WriteLine( + "[l10n-debug] Initialize: preferred=" + (preferredLanguage ?? "null") + + ", dataDir=" + (dataDirectory ?? "null") + + ", selected=" + selected + + ", loaded=[" + string.Join(",", _languages.Keys) + "]"); + _initialized = true; } @@ -141,9 +148,11 @@ public void Reset() } /// Устанавливает активный язык и уведомляет подписчиков. - public void SetLanguage(string code) + public void SetLanguage(string? code) { - if (!_languages.TryGetValue(code, out var lang)) + Console.Error.WriteLine("[l10n-debug] SetLanguage(requested=" + (code ?? "null") + ", current=" + CurrentLanguage + ")"); + + if (code is null || !_languages.TryGetValue(code, out var lang)) lang = _languages.TryGetValue(BuiltInRussian, out var ru) ? ru : _languages.Values.FirstOrDefault(); if (lang is null) diff --git a/Configuration Management/Localization/LocalizationSource.cs b/Configuration Management/Localization/LocalizationSource.cs index 014d1ad7..6ec5548e 100644 --- a/Configuration Management/Localization/LocalizationSource.cs +++ b/Configuration Management/Localization/LocalizationSource.cs @@ -8,8 +8,9 @@ namespace Configuration_Management.Localization; /// Поддерживает индексную привязку {Binding Loc[Key]} на обеих /// платформах (WPF и Avalonia). Реализует : /// при смене языка вызывает , которое уведомляет об -/// изменении всех индексных привязок ("Item[]"), благодаря чему открытые -/// окна автоматически обновляют переведённый текст. +/// изменении всех индексных привязок ("Item[]") и дополнительно вызывает +/// UpdateTarget() у зарегистрированных выражений (WPF LocExtension), благодаря +/// чему открытые окна автоматически обновляют переведённый текст. /// /// Обычно экземпляр выставляется через свойство Loc на ViewModel /// (см. ) или на окне. @@ -17,6 +18,12 @@ namespace Configuration_Management.Localization; public sealed class LocalizationSource : INotifyPropertyChanged { private readonly LocalizationManager _manager; + private readonly object _sync = new(); + private readonly List _registrations = new(); + + // Порог, по достижении которого список регистраций принудительно чистится + // от «мёртвых» (собранных GC) слабых ссылок, чтобы список не рос бесконечно. + private const int CleanupThreshold = 64; internal LocalizationSource(LocalizationManager manager) { @@ -26,6 +33,30 @@ internal LocalizationSource(LocalizationManager manager) /// Возвращает перевод ключа для текущего языка (индексная привязка). public string this[string key] => _manager.Translate(key); + /// + /// Регистрирует объект для динамического обновления при смене языка. + /// + /// Хранит СЛАБУЮ ссылку на и делегат обновления, + /// который не захватывает target (принимает его параметром), поэтому объект + /// не удерживается источником и может быть собран GC. Это позволяет источнику + /// оставаться платформонезависимым: тип объекта и способ обновления задаёт + /// вызывающий код (например WPF-выражение привязки из LocExtension). + /// + /// Объект, который нужно обновлять (например BindingExpression). + /// Делегат, выполняющий обновление; получает target параметром. + public void RegisterForUpdate(object target, Action update) + { + if (target is null || update is null) + return; + + lock (_sync) + { + _registrations.Add(new Registration(new WeakReference(target), update)); + if (_registrations.Count >= CleanupThreshold) + CleanupDeadRegistrationsLocked(); + } + } + /// /// Уведомляет привязки о том, что все строки могли измениться /// (при смене языка). @@ -37,7 +68,46 @@ public void NotifyAll() // Пустая строка означает «изменились все свойства» — пересчитывает привязки // к объекту целиком (WPF-конвертер LocExtension). PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(string.Empty)); + + // Принудительно вызываем UpdateTarget() у всех зарегистрированных выражений + // (WPF LocExtension: конвертерная привязка без пути не реагирует на + // PropertyChanged). Защищаемся от мёртвых/отвязанных ссылок и сбоев. + Registration[] snapshot; + lock (_sync) + { + snapshot = _registrations.ToArray(); + CleanupDeadRegistrationsLocked(); + } + + foreach (var reg in snapshot) + { + var target = reg.Target.IsAlive ? reg.Target.Target : null; + if (target is null) + continue; // выражение уже собрано GC — пропускаем + + try + { + reg.Update(target); + } + catch (Exception ex) + { + Console.Error.WriteLine("[l10n] UpdateTarget failed: " + ex.Message); + } + } } public event PropertyChangedEventHandler? PropertyChanged; + + /// Слабая ссылка на целевой объект + делегат его обновления. + private readonly record struct Registration(WeakReference Target, Action Update); + + /// Удаляет регистрации, чьи целевые объекты уже собраны GC. + private void CleanupDeadRegistrationsLocked() + { + for (var i = _registrations.Count - 1; i >= 0; i--) + { + if (!_registrations[i].Target.IsAlive) + _registrations.RemoveAt(i); + } + } } \ No newline at end of file diff --git a/Configuration Management/Localization/README.md b/Configuration Management/Localization/README.md index ab5b001f..023f2a83 100644 --- a/Configuration Management/Localization/README.md +++ b/Configuration Management/Localization/README.md @@ -14,6 +14,14 @@ * Язык можно сменить в окне «Настройки» (вкладка «Отображение»/«Цветовое оформление» на Windows, вкладка «Настройки» на Linux). Выбранный язык применяется сразу и сохраняется. +* Смена языка **применяется мгновенно к открытым окнам**: + * на Windows (WPF) тексты связаны через `{loc:Loc ...}` и обновляются + автоматически через `Source.NotifyAll()`; + * на Linux (Avalonia) главное окно подписывается на событие + `LocalizationManager.Instance.LanguageChanged` и пересобирает своё + содержимое, поэтому названия колонок, кнопки правой панели и подсказки + обновляются сразу; остальные диалоговые окна (наследники `ModalWindowBase`) + тоже реагируют на `LanguageChanged`. ## Добавление нового языка (без пересборки) diff --git a/Configuration Management/MainWindow.Avalonia.cs b/Configuration Management/MainWindow.Avalonia.cs index 8e282784..0ba0efb8 100644 --- a/Configuration Management/MainWindow.Avalonia.cs +++ b/Configuration Management/MainWindow.Avalonia.cs @@ -1,9 +1,12 @@ #if LINUX using System; +using System.Collections.Generic; +using System.Collections.Specialized; using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; +using Avalonia.Controls.Presenters; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; @@ -15,8 +18,10 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; +using Avalonia.Utilities; using Avalonia.Platform; using Avalonia.Styling; +using Avalonia.VisualTree; using Configuration_Management.Controls; using Configuration_Management.Localization; using Configuration_Management.Models; @@ -41,8 +46,40 @@ public class MainWindow : Window // Поля empty-state (заглушка пустого списка / «ничего не найдено»). private Border _emptyState = null!; - private Path _emptyIcon = null!; + private Avalonia.Controls.Shapes.Path _emptyIcon = null!; private TextBlock _emptyTitle = null!; + private SegmentButton? _tagsToggle; + private SegmentButton? _groupByToggle; + private Border? _columnHeader; + private Grid? _columnHeaderRow; + private ColumnDefinition? _headerOffsetColumn; + private Control? _headerPinMark; + private double _headerToolbarWidth; + private StackPanel? _groupToolbar; + private Grid? _listContent; + /// Шаг прокрутки колесом, как у штатного ScrollContentPresenter. + private const double WheelScrollStep = 50; + + private ScrollBar? _listVerticalBar; + private ScrollViewer? _listScroll; + private ScrollViewer? _boundTreeScroll; + private ScrollBar? _boundScrollBar; + private readonly List _scrollBarLinks = new(); + private bool _syncingScrollBar; + private bool _columnHeaderRefreshQueued; + private bool _headerAlignQueued; + private readonly Dictionary _headerColumnIndex = new(StringComparer.Ordinal); + private object? _dragPayload; + private Point _dragStartPoint; + private bool _isDragging; + private string? _resizeKey; + private int _resizePointerId; + private readonly List _resizeRowGrids = new(); + private double _resizeStartWidth; + private double _resizeStartX; + private Border? _tagPanel; + private WrapPanel? _tagPanelItems; + private Button? _tagClearButton; private TextBlock _emptyHint = null!; /// @@ -66,6 +103,78 @@ public MainWindow(MainViewModel viewModel) Content = BuildRoot(); Loaded += OnWindowLoaded; + KeyDown += OnWindowKeyDown; + + // Действие после запуска базы или конфигуратора по глобальной настройке. + _vm.AfterLaunchRequested += OnAfterLaunchRequested; + + // Подписка здесь, а не в построении содержимого: компактный режим + // пересобирает содержимое, и обработчики копились бы на каждый показ. + _vm.TraySettingsChanged += ApplyTrayVisibility; + _vm.TreeRebuilding += RememberTreeScroll; + _vm.TreeRebuilt += RestoreTreeSelection; + + // Смена языка интерфейса: названия колонок, кнопки правой панели и подсказки + // создаются в коде через LocalizationManager.T(...), поэтому окно пересобирается, + // чтобы переведённый текст появился сразу, а не после перезапуска. + LocalizationManager.Instance.LanguageChanged += OnLanguageChanged; + } + + /// Значок трея создан без ошибки: значение проверяется перед тем, как прятать окно. + private bool _trayIconCreated; + + /// Ссылка на значок трея, чтобы обновлять меню при смене языка. + private TrayIcon? _trayIcon; + private NativeMenu? _trayMenu; + private string? _traySignature; + private bool _trayRefreshQueued; + private IDisposable? _toolbarWidthLink; + private NotifyCollectionChangedEventHandler? _groupNodesChanged; + private NotifyCollectionChangedEventHandler? _flatItemsChanged; + private EventHandler? _tagFiltersRebuilt; + private PropertyChangedEventHandler? _vmPropertyChanged; + + /// + /// Можно ли вернуть спрятанное окно. Значок трея это первый путь, но + /// на GNOME Shell без AppIndicator он не появится, а ошибки при этом не + /// будет. Второй путь, повторный запуск приложения через файл-сигнал, + /// работает только пока включён режим единственного экземпляра. + /// + private bool CanRestoreHiddenWindow => + (_trayIconCreated && TrayIconWanted && !Services.LinuxDesktopEnvironment.TrayMayBeUnavailable) + || _vm?.AllowMultipleInstances == false; + + /// Нужен ли значок по настройкам: сам значок либо закрытие в трей. + private bool TrayIconWanted => _vm is null || _vm.ShowTrayIcon || _vm.CloseToTray; + + /// + /// Уводит окно в трей после успешного запуска, как это делает WPF-версия + /// для обоих значений настройки. Выполняется через диспетчер: запрос + /// приходит из обработчика команды, а окно к этому моменту ещё показывает + /// нажатую кнопку. + /// + private void OnAfterLaunchRequested(Models.AfterLaunchAction action) + { + if (action == Models.AfterLaunchAction.None) + return; + + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + // Спрятанное окно живёт только в трее, поэтому без пути возврата + // оно сворачивается: иначе пользователь остался бы с работающим + // процессом, который нечем показать. + if (!CanRestoreHiddenWindow) + { + WindowState = WindowState.Minimized; + _vm?.LogWarning(LocalizationManager.T("Main.AfterLaunchTrayUnavailable")); + return; + } + + Hide(); + if (action == Models.AfterLaunchAction.MinimizeToTray + && WindowState == WindowState.Minimized) + WindowState = WindowState.Normal; + }); } // ======================= Построение UI ======================= @@ -74,18 +183,22 @@ private Control BuildRoot() { var grid = new Grid(); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Star }); grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); var topBar = BuildTopBar(); + var tagPanel = BuildTagFilterPanel(); var mainArea = BuildMainArea(); var statusBar = BuildStatusBar(); Grid.SetRow(topBar, 0); - Grid.SetRow(mainArea, 1); - Grid.SetRow(statusBar, 2); + Grid.SetRow(tagPanel, 1); + Grid.SetRow(mainArea, 2); + Grid.SetRow(statusBar, 3); grid.Children.Add(topBar); + grid.Children.Add(tagPanel); grid.Children.Add(mainArea); grid.Children.Add(statusBar); @@ -111,15 +224,15 @@ private Control BuildTopBar() Spacing = 2 }; - var groupByToggle = MakeSegmentToggle("IconGroups", LocalizationManager.T("Main.ToggleGroups")); - groupByToggle.IsChecked = _vm?.GroupByGroup ?? true; - groupByToggle.Click += (_, _) => { if (_vm is not null) _vm.GroupByGroup = groupByToggle.IsChecked == true; }; - left.Children.Add(groupByToggle); + _groupByToggle = MakeSegmentToggle("IconGroups", LocalizationManager.T("Main.ToggleGroups")); + _groupByToggle.IsChecked = _vm?.GroupByGroup ?? true; + _groupByToggle.Click += (_, _) => { if (_vm is not null) _vm.GroupByGroup = _groupByToggle.IsChecked == true; }; + left.Children.Add(_groupByToggle); - var tagsToggle = MakeSegmentToggle("IconTag", LocalizationManager.T("Main.ToggleTags")); - tagsToggle.IsChecked = _vm?.ShowTagFilterPanel ?? true; - tagsToggle.Click += (_, _) => { if (_vm is not null) _vm.ShowTagFilterPanel = tagsToggle.IsChecked == true; }; - left.Children.Add(tagsToggle); + _tagsToggle = MakeSegmentToggle("IconTag", LocalizationManager.T("Main.ToggleTags")); + _tagsToggle.IsChecked = _vm?.ShowTagFilterPanel ?? true; + _tagsToggle.Click += (_, _) => { if (_vm is not null) _vm.ShowTagFilterPanel = _tagsToggle.IsChecked == true; }; + left.Children.Add(_tagsToggle); grid.Children.Add(left); Grid.SetColumn(left, 0); @@ -146,6 +259,12 @@ private Control BuildTopBar() addBtn.Bind(Button.CommandProperty, new Binding("AddInfobaseCommand")); actions.Children.Add(addBtn); + // Очистить кеш выбранной базы: перенесено в верхнюю панель команд, + // действует на SelectedInfobase (недоступна, если база не выбрана). + var clearCacheBtn = TopBarIconButton("IconDelete", LocalizationManager.T("Main.ClearCacheTooltip")); + clearCacheBtn.Bind(Button.CommandProperty, new Binding("ClearCacheCommand")); + actions.Children.Add(clearCacheBtn); + var syncBtn = TopBarSecondaryButton("IconSync", LocalizationManager.T("Main.Sync"), LocalizationManager.T("Main.SyncWithIbases")); syncBtn.Bind(Button.CommandProperty, new Binding("SynchronizeWithIbasesCommand")); actions.Children.Add(syncBtn); @@ -158,6 +277,38 @@ private Control BuildTopBar() settingsBtn.Bind(Button.CommandProperty, new Binding("OpenSettingsCommand")); actions.Children.Add(settingsBtn); + // Проверить доступность всех баз 1С: ручная команда вместо автопроверки при запуске. + // Иконка — зелёный гидролокатор (сонар), как экран на подводных лодках. + var checkAvailBtn = new PanelButton( + "SecondaryButtonBackgroundBrush", + "SecondaryButtonHoverBrush", + "SecondaryButtonPressedBrush", + "BorderColorBrush") + { + Content = new Avalonia.Controls.Shapes.Path + { + Width = UiMetrics.Scaled(16), + Height = UiMetrics.Scaled(16), + Data = IconHelper.Geometry("IconSonar"), + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + Fill = new SolidColorBrush(Color.Parse("#14B8A6")) + }, + Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV), + HorizontalContentAlignment = HorizontalAlignment.Center + }; + ToolTip.SetTip(checkAvailBtn, LocalizationManager.T("Main.CheckAvailabilityTooltip")); + checkAvailBtn.Bind(Button.CommandProperty, new Binding("CheckAvailabilityCommand")); + actions.Children.Add(checkAvailBtn); + + // Подсказка «?»: справа, после всех команд верхней панели. + actions.Children.Add(new HelpLink + { + HelpText = LocalizationManager.T("Main.BaseListHelp"), + Margin = new Thickness(4, 0, 0, 0) + }); + grid.Children.Add(actions); Grid.SetColumn(actions, 3); @@ -175,11 +326,12 @@ private Control BuildTopBar() /// Сегментный переключатель (например «группы»/«теги») с иконкой и состояниями. private SegmentButton MakeSegmentToggle(string iconKey, string tooltip) { - return new SegmentButton(iconKey, string.Empty, "ItemHoverBrush", "ItemSelectedBrush", lockOn: false) + var segment = new SegmentButton(iconKey, string.Empty, "ItemHoverBrush", "ItemSelectedBrush", lockOn: false) { - ToolTip = new ToolTip { Content = tooltip }, IsChecked = true }; + ToolTip.SetTip(segment, tooltip); + return segment; } /// Сегментированный контроль фильтра списка: Все / Избранное / Недавние. @@ -200,14 +352,17 @@ private Control BuildListModeSegments() var panel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2 }; var allSeg = new SegmentButton("IconList", LocalizationManager.T("Main.AllBases"), "ItemHoverBrush", "ItemSelectedBrush"); + ToolTip.SetTip(allSeg, LocalizationManager.T("Main.AllBasesTooltip")); allSeg.Bind(ToggleButton.IsCheckedProperty, new Binding("IsListModeAll") { Mode = BindingMode.TwoWay }); panel.Children.Add(allSeg); var favSeg = new SegmentButton("IconFavorite", LocalizationManager.T("Main.Favorites"), "ItemHoverBrush", "ItemSelectedBrush"); + ToolTip.SetTip(favSeg, LocalizationManager.T("Main.FavoritesTooltip")); favSeg.Bind(ToggleButton.IsCheckedProperty, new Binding("IsListModeFavorites") { Mode = BindingMode.TwoWay }); panel.Children.Add(favSeg); var recSeg = new SegmentButton("IconRecent", LocalizationManager.T("Main.Recent"), "ItemHoverBrush", "ItemSelectedBrush"); + ToolTip.SetTip(recSeg, LocalizationManager.T("Main.RecentTooltip")); recSeg.Bind(ToggleButton.IsCheckedProperty, new Binding("IsListModeRecent") { Mode = BindingMode.TwoWay }); panel.Children.Add(recSeg); @@ -255,9 +410,9 @@ private Border BuildSearchBox() Background = Brushes.Transparent, BorderThickness = new Thickness(0), Padding = new Thickness(6, 0), - Cursor = new Cursor(StandardCursorType.Hand), - ToolTip = new ToolTip { Content = LocalizationManager.T("Main.ClearSearch") } + Cursor = new Cursor(StandardCursorType.Hand) }; + ToolTip.SetTip(clearBtn, LocalizationManager.T("Main.ClearSearch")); clearBtn.Bind(Button.CommandProperty, new Binding("ClearSearchCommand")); grid.Children.Add(clearBtn); Grid.SetColumn(clearBtn, 2); @@ -265,11 +420,11 @@ private Border BuildSearchBox() border.Child = grid; // Hover-состояние: фон и граница подсвечиваются из ресурсов темы (без жёстких цветов). - var baseBg = Brushes.Transparent; - var hoverBg = Brushes.Transparent; - var baseBorder = Brushes.Transparent; - var hoverBorder = Brushes.Transparent; - var accentBorder = Brushes.Transparent; + IBrush baseBg = Brushes.Transparent; + IBrush hoverBg = Brushes.Transparent; + IBrush baseBorder = Brushes.Transparent; + IBrush hoverBorder = Brushes.Transparent; + IBrush accentBorder = Brushes.Transparent; var hovered = false; var focused = false; @@ -280,13 +435,13 @@ void Refresh() border.BorderThickness = focused ? new Thickness(2) : new Thickness(1); } - if (Application.Current is { } app) - { - app.GetResourceObservable("CardBackgroundColorBrush").Subscribe(new BrushObserver(b => baseBg = b, Refresh)); - app.GetResourceObservable("ItemHoverBrush").Subscribe(new BrushObserver(b => hoverBg = b, Refresh)); - app.GetResourceObservable("BorderColorBrush").Subscribe(new BrushObserver(b => baseBorder = b, Refresh)); - app.GetResourceObservable("AccentBrush").Subscribe(new BrushObserver(b => { hoverBorder = b; accentBorder = b; }, Refresh)); - } + // Подписки привязаны к жизни рамки: содержимое окна пересобирается + // при переключении компактного режима, и наблюдатель, живущий + // у приложения, удерживал бы прежнее дерево целиком. + ThemeBrushes.Observe(border, "CardBackgroundColorBrush", b => { baseBg = b; Refresh(); }); + ThemeBrushes.Observe(border, "ItemHoverBrush", b => { hoverBg = b; Refresh(); }); + ThemeBrushes.Observe(border, "BorderColorBrush", b => { baseBorder = b; Refresh(); }); + ThemeBrushes.Observe(border, "AccentBrush", b => { hoverBorder = b; accentBorder = b; Refresh(); }); border.PointerEntered += (_, _) => { hovered = true; Refresh(); }; border.PointerExited += (_, _) => { hovered = false; Refresh(); }; @@ -300,45 +455,48 @@ void Refresh() /// Primary-кнопка топ-бара: акцентный фон, иконка + подпись цветом «на акценте». private static PanelButton TopBarPrimaryButton(string iconKey, string text, string tooltip) { - return new PanelButton("AccentBrush", "AccentHoverBrush", "AccentPressedBrush", "AccentBrush") + var button = new PanelButton("AccentBrush", "AccentHoverBrush", "AccentPressedBrush", "AccentBrush") { - Content = ThemedIconAndText(iconKey, text, "TextOnAccentBrush", UiMetrics.Scaled(15), centered: false), - ToolTip = new ToolTip { Content = tooltip }, + Content = ThemedIconAndText(iconKey, text, "TextOnAccentBrush", UiMetrics.ScaledFont(15), centered: false), Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV), HorizontalContentAlignment = HorizontalAlignment.Center }; + ToolTip.SetTip(button, tooltip); + return button; } /// Secondary-кнопка топ-бара: приглушённый фон, иконка + подпись, hover/pressed. private static PanelButton TopBarSecondaryButton(string iconKey, string text, string tooltip) { - return new PanelButton( + var button = new PanelButton( "SecondaryButtonBackgroundBrush", "SecondaryButtonHoverBrush", "SecondaryButtonPressedBrush", "BorderColorBrush") { - Content = ThemedIconAndText(iconKey, text, "TextPrimaryBrush", UiMetrics.Scaled(15), centered: false), - ToolTip = new ToolTip { Content = tooltip }, + Content = ThemedIconAndText(iconKey, text, "ButtonTextBrush", UiMetrics.ScaledFont(15), centered: false), Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV), HorizontalContentAlignment = HorizontalAlignment.Center }; + ToolTip.SetTip(button, tooltip); + return button; } /// Компактная иконко-кнопка топ-бара (например тема) с состояниями из темы. private static PanelButton TopBarIconButton(string iconKey, string tooltip) { - return new PanelButton( + var button = new PanelButton( "SecondaryButtonBackgroundBrush", "SecondaryButtonHoverBrush", "SecondaryButtonPressedBrush", "BorderColorBrush") { - Content = IconHelper.MakeIcon(iconKey, UiMetrics.Scaled(16), "TextPrimaryBrush"), - ToolTip = new ToolTip { Content = tooltip }, + Content = IconHelper.MakeIcon(iconKey, UiMetrics.Scaled(16), "ButtonTextBrush"), Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV), HorizontalContentAlignment = HorizontalAlignment.Center }; + ToolTip.SetTip(button, tooltip); + return button; } private Control BuildMainArea() @@ -352,32 +510,191 @@ private Control BuildMainArea() BorderThickness = new Thickness(0) }; // Фон списка баз — фон рабочей области из темы. - ThemeBrushes.Bind(_tree, Control.BackgroundProperty, "ContentBackgroundColorBrush"); + ThemeBrushes.Bind(_tree, TemplatedControl.BackgroundProperty, "ContentBackgroundColorBrush"); + // Горизонтальная прокрутка отключена: иначе строка растягивается + // по сумме ширин колонок и уезжает за правый край, а заголовки, + // живущие вне области прокрутки, перестают совпадать со значениями. + ScrollViewer.SetHorizontalScrollBarVisibility(_tree, ScrollBarVisibility.Disabled); + // Внутренняя прокрутка появляется только вместе с шаблоном, а он + // применяется заново при каждой пересборке окна компактным режимом. + _tree.TemplateApplied += (_, _) => AttachVerticalScrollBar(); _tree.Bind(TreeView.ItemsSourceProperty, new Binding("GroupNodes")); _tree.SelectionMode = SelectionMode.Single; - // Убираем стандартную подсветку контейнера TreeViewItem — карточка строки - // сама рисует hover/выделение из ресурсов темы (без жёстких цветов). - var tviStyle = new Style(x => x.OfType()); - tviStyle.Setters.Add(new Setter(Control.BackgroundProperty, Brushes.Transparent)); + // Убираем стандартную подсветку контейнера TreeViewItem: карточка строки + // сама рисует hover и выделение из ресурсов темы. Селектор сопоставляется + // по ключу стиля, а он переопределён на TreeViewItem, иначе стиль + // не нашёл бы контейнеры. + var tviStyle = new Style(x => x.OfType()); + tviStyle.Setters.Add(new Setter(TemplatedControl.BackgroundProperty, Brushes.Transparent)); _tree.Styles.Add(tviStyle); + // Фон покоя этим снят, но состояния выделения и наведения Fluent задаёт + // не свойством контейнера, а вложенным стилем на части шаблона, поэтому + // синяя полоса рисовалась бы за карточкой. Гасим её адресно. + foreach (var state in new[] { ":selected", ":pointerover" }) + { + var stateStyle = new Style(x => x.OfType().Class(state) + .Template().OfType().Name("PART_LayoutRoot")); + stateStyle.Setters.Add(new Setter(Border.BackgroundProperty, Brushes.Transparent)); + _tree.Styles.Add(stateStyle); + } + + // Раскрытие узла связывает с моделью сам LeveledTreeView, при подготовке + // контейнера на любом уровне вложенности. Здесь остаётся только + // выравнивание заголовка: раскрытие группы добавляет строки, а с ними + // может измениться и самый левый отступ, по которому выровнен заголовок. + _tree.ContainerPrepared += (_, _) => QueueHeaderAlign(); + + // Меню висит на дереве, как в WPF: над группой и над пустым местом + // оно тоже открывается, а недоступные пункты гасит CanExecute. + // Строку под курсором дерево выделяет само, по правому нажатию. + _tree.ContextMenu = BuildRowContextMenu(); + _tree.ItemTemplate = new FuncTreeDataTemplate( - BuildTreeRow, + typeof(object), + (item, _) => BuildTreeRow(item), item => item is GroupNodeViewModel g ? g.Items : null); _tree.SelectionChanged += OnTreeSelectionChanged; - // Дерево само предоставляет ScrollViewer и штатную виртуализацию - // (VirtualizingStackPanel). Внешний ScrollViewer здесь не нужен — он давал бы дереву - // бесконечную высоту и отключал бы виртуализацию, поэтому убран. + // Перетаскивание баз и групп. Нажатие ловится по туннелю: TreeView + // помечает PointerPressed обработанным, обновляя выделение, и + // обычная подписка не сработала бы. Это прямой аналог + // PreviewMouseLeftButtonDown в WPF-версии. + _tree.AddHandler(InputElement.PointerPressedEvent, OnTreeDragPointerPressed, RoutingStrategies.Tunnel); + _tree.PointerMoved += OnTreeDragPointerMoved; + DragDrop.SetAllowDrop(_tree, true); + _tree.AddHandler(DragDrop.DragOverEvent, OnTreeDragOver); + _tree.AddHandler(DragDrop.DropEvent, OnTreeDrop); + + // Горизонтальную прокрутку списка ведёт внешний ScrollViewer, общий + // с заголовком колонок, а вертикальную сам TreeView. + // Прежнее опасение про бесконечную высоту и потерю виртуализации здесь + // неприменимо: у TreeView в Avalonia 11.3.20 виртуализации нет вовсе, + // панель элементов по умолчанию обычный StackPanel, и ни тема Fluent, + // ни сам контрол её не переопределяют. _emptyState = BuildEmptyState(); var leftInner = new Grid(); leftInner.Children.Add(_tree); leftInner.Children.Add(_emptyState); + // Заголовки колонок и строки живут в одной области горизонтальной + // прокрутки: колонок может не хватить по ширине, и если прокручивать + // только список, заголовки перестанут совпадать со значениями. + _listContent = new Grid(); + _listContent.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + _listContent.RowDefinitions.Add(new RowDefinition { Height = GridLength.Star }); + var columnHeader = BuildColumnHeader(); + _listContent.Children.Add(columnHeader); + Grid.SetRow(columnHeader, 0); + _listContent.Children.Add(leftInner); + Grid.SetRow(leftInner, 1); + + var listArea = new ScrollViewer + { + HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled, + Content = _listContent + }; + _listScroll = listArea; + + // Вертикальная полоса вынесена из области горизонтальной прокрутки + // и стоит отдельным столбцом справа. Собственная полоса дерева + // рисуется у правого края его содержимого, поэтому уезжала за границу, + // как только колонки переставали помещаться по ширине. + ScrollViewer.SetVerticalScrollBarVisibility(_tree, ScrollBarVisibility.Hidden); + // Ширина и авто-скрытие не задаются: полоса должна выглядеть так же, + // как горизонтальная полоса списка и полоса правой панели, то есть + // по правилам темы. Видимость тоже ведёт сам контрол: при Auto он + // показывает полосу ровно когда есть что прокручивать. Присваивать + // IsVisible руками нельзя, при значении Visible полоса включает себя + // обратно на каждое изменение Maximum и ViewportSize. + _listVerticalBar = new ScrollBar + { + Orientation = Orientation.Vertical, + Visibility = ScrollBarVisibility.Auto, + Minimum = 0, + Maximum = 0, + ViewportSize = 0 + }; + // Локальная ссылка на созданную полосу: поле к этому моменту + // указывает на неё, но при следующей пересборке окна начнёт + // указывать на другую, а подписки живут вместе с этой. + var verticalBar = _listVerticalBar; + // Полоса не входит в шаблон ScrollViewer, поэтому колесо над ней + // некому переадресовать. Шаг и разбор осей взяты из + // ScrollContentPresenter платформы: с Shift вертикальная дельта + // становится горизонтальной, и в версии для Windows сделано так же. + // Вертикаль ведёт прокрутка дерева, горизонталь общая с шапкой. + _listVerticalBar.PointerWheelChanged += (_, e) => + { + if (!ReferenceEquals(_listVerticalBar, verticalBar)) + return; + var delta = e.Delta; + if (e.KeyModifiers == KeyModifiers.Shift && MathUtilities.IsZero(delta.X)) + delta = new Vector(delta.Y, delta.X); + + // Событие считается разобранным только если список сдвинулся: + // на краю платформа отдаёт прокрутку выше по дереву, и глушить + // её здесь значит ломать это правило. + var moved = false; + if (delta.Y != 0 && TreeScroll is { } vertical) + { + var hidden = Math.Max(0, vertical.Extent.Height - vertical.Viewport.Height); + var next = vertical.Offset.WithY( + Math.Clamp(vertical.Offset.Y - delta.Y * WheelScrollStep, 0, hidden)); + if (next != vertical.Offset) + { + vertical.Offset = next; + moved = true; + } + } + + if (delta.X != 0 && _listScroll is { } horizontal) + { + var hidden = Math.Max(0, horizontal.Extent.Width - horizontal.Viewport.Width); + var next = horizontal.Offset.WithX( + Math.Clamp(horizontal.Offset.X - delta.X * WheelScrollStep, 0, hidden)); + if (next != horizontal.Offset) + { + horizontal.Offset = next; + moved = true; + } + } + + e.Handled = moved; + }; + // Прокручиваются только строки, поэтому полоса начинается под шапкой + // колонок. Высота шапки меняется вместе с компактным режимом и темой. + columnHeader.GetObservable(Visual.BoundsProperty).Subscribe(new PropertyObserver(bounds => + verticalBar.Margin = new Thickness(0, bounds.Height, 0, 0))); + _listVerticalBar.GetObservable(RangeBase.ValueProperty).Subscribe(new PropertyObserver(value => + { + // Флаг разводит два направления: пользователь тянет полосу, + // и наоборот, прокрутка списка двигает полосу. + if (_syncingScrollBar || !ReferenceEquals(_listVerticalBar, verticalBar) + || TreeScroll is not { } scroll) + return; + _syncingScrollBar = true; + try { scroll.Offset = scroll.Offset.WithY(value); } + finally { _syncingScrollBar = false; } + })); + + // Полоса занимает свой столбец, как в версии для Windows. Ширина + // ей задана темой и при наведении не меняется, поэтому список от неё + // не дёргается, а пока прокручивать нечего, полоса скрыта и столбец + // пуст. Поверх списка её класть нельзя: она забирала бы клики, + // контекстное меню и перетаскивание по правой кромке строк. + var listWithBar = new Grid(); + listWithBar.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); + listWithBar.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + Grid.SetColumn(_listVerticalBar, 1); + listWithBar.Children.Add(listArea); + listWithBar.Children.Add(_listVerticalBar); + var leftPanel = new Border { - Child = leftInner, + Child = listWithBar, Margin = new Thickness(UiMetrics.TopBarH, UiMetrics.TopBarV, 8, UiMetrics.TopBarV), Padding = new Thickness(UiMetrics.Scaled(8), UiMetrics.Scaled(8)) }; @@ -388,23 +705,70 @@ private Control BuildMainArea() // Показываем/скрываем заглушку при любых изменениях списка и поиска. if (_vm is not null) { - _vm.GroupNodes.CollectionChanged += (_, _) => UpdateEmptyState(); - _vm.FlatItems.CollectionChanged += (_, _) => UpdateEmptyState(); - _vm.PropertyChanged += (_, e) => + // Содержимое окна пересобирается при смене языка и компактного + // режима, а вьюмодель живёт дальше: без снятия прежние + // обработчики накапливались бы и делали ту же работу заново. + DetachViewModelHandlers(); + + // Строки пересобираются вместе с деревом, поэтому заголовок + // выравнивается по ним заново: отступ уровня мог измениться. + _groupNodesChanged = (_, _) => { UpdateEmptyState(); QueueHeaderAlign(); }; + _flatItemsChanged = (_, _) => UpdateEmptyState(); + _tagFiltersRebuilt = (_, _) => RefreshTagFilterPanel(); + _vmPropertyChanged = (_, e) => { if (e.PropertyName == nameof(MainViewModel.SearchText)) UpdateEmptyState(); + // Меню трея показывает выбранную базу и недавние: без этого + // оно осталось бы таким, каким было собрано при запуске. + if (e.PropertyName == nameof(MainViewModel.SelectedInfobase) + || e.PropertyName == nameof(MainViewModel.RecentInfobases)) + QueueTrayMenuRefresh(); + // Заголовок строится до загрузки настроек, поэтому обновляется + // при уведомлении о колонках: иначе сохранённые ширина и состав + // применились бы к строкам, но не к уже собранному заголовку. + if (e.PropertyName is not null && e.PropertyName.Contains("Column", StringComparison.Ordinal)) + QueueColumnHeaderRefresh(); + // Кнопки групп живут в заголовке и видны только при группировке. + if (e.PropertyName == nameof(MainViewModel.ShowExpandCollapseButtons)) + QueueColumnHeaderRefresh(); + // Переключатель тегов в списке живёт в том же заголовке, + // а его настройка меняется и из окна настроек. + if (e.PropertyName == nameof(MainViewModel.ShowTags)) + QueueColumnHeaderRefresh(); + // Группировку меняют и верхняя панель, и окно настроек, + // поэтому переключатель подтягивает состояние вьюмодели. + if (e.PropertyName == nameof(MainViewModel.GroupByGroup) && _groupByToggle is not null) + _groupByToggle.IsChecked = _vm.GroupByGroup; + if (e.PropertyName == nameof(MainViewModel.ShowTagFilterPanel) + || e.PropertyName == nameof(MainViewModel.HasActiveTagFilter)) + { + // Кнопка «теги» строится до загрузки настроек, поэтому + // её состояние подтягивается отсюда, иначе после перезапуска + // она разошлась бы с реальной видимостью панели. + if (_tagsToggle is not null) + _tagsToggle.IsChecked = _vm.ShowTagFilterPanel; + RefreshTagFilterPanel(); + } }; + + _vm.GroupNodes.CollectionChanged += _groupNodesChanged; + _vm.FlatItems.CollectionChanged += _flatItemsChanged; + _vm.TagFiltersRebuilt += _tagFiltersRebuilt; + _vm.PropertyChanged += _vmPropertyChanged; } UpdateEmptyState(); + RefreshTagFilterPanel(); + RefreshColumnHeader(); var rightPanel = new ScrollViewer { Name = "RightPanelBorder", Content = BuildRightPanel(), + // Более плотные отступы — кнопки и карточки занимают меньше места. HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - Padding = new Thickness(UiMetrics.Scaled(16), UiMetrics.Scaled(14)), + Padding = new Thickness(UiMetrics.Scaled(12), UiMetrics.Scaled(10)), MinWidth = UiMetrics.RightPanelMin, MaxWidth = UiMetrics.RightPanelMax }; @@ -533,24 +897,93 @@ private Control BuildTreeRow(object? item) private Control BuildGroupRow(GroupNodeViewModel group) { + // Высота оформления группы и расстояние между группами берутся из метрик: + // в компактном режиме вертикальный padding заголовка и внешний отступ + // уменьшаются, чтобы группы занимали меньше места. var header = new Border { CornerRadius = new CornerRadius(UiMetrics.RadiusSm), - Padding = new Thickness(6, 3), - Margin = new Thickness(0, 1) + Padding = new Thickness(6, UiMetrics.GroupHeaderPadV), + Margin = new Thickness(0, UiMetrics.GroupHeaderMarginV) }; header.Bind(Border.BackgroundProperty, new Binding("HeaderBrush") { Source = group }); - var text = new TextBlock + // Имя и счётчик привязаны к узлу, а не подставлены строкой: состав узла + // меняется и без пересборки дерева (закрепление базы), и тогда готовый + // текст остался бы со старым числом. + var caption = new StackPanel { - Text = $"{group.DisplayName} ({group.TotalInfobaseCount})", - FontWeight = FontWeight.SemiBold + Orientation = Orientation.Horizontal, + Spacing = 6, + VerticalAlignment = VerticalAlignment.Center }; + + // Имя группы наследует применяемый к интерфейсу шрифт; в компактном режиме + // размер задаётся явно и уменьшается, чтобы строки групп были плотнее. + var text = new TextBlock { FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + if (UiMetrics.Compact) + text.FontSize = UiMetrics.GroupNameFont; + text.Bind(TextBlock.TextProperty, new Binding("DisplayName") { Source = group }); text.Bind(TextBlock.ForegroundProperty, new Binding("HeaderTextBrush") { Source = group }); - header.Child = text; + caption.Children.Add(text); + + var count = new TextBlock { VerticalAlignment = VerticalAlignment.Center }; + if (UiMetrics.Compact) + count.FontSize = UiMetrics.GroupNameFont; + count.Bind(TextBlock.TextProperty, + new Binding("TotalInfobaseCount") { Source = group, StringFormat = "({0})" }); + count.Bind(TextBlock.ForegroundProperty, new Binding("HeaderTextBrush") { Source = group }); + caption.Children.Add(count); + + // Команды группы «Изменить группу» и «Удалить группу» выровнены по + // левому краю строки; имя и счётчик группы следуют за ними правее. + var row = new Grid(); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(ActionsColumnWidth) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center + }; + actions.Children.Add(GroupRowActionButton(group, "IconEdit", "EditGroupCommand", LocalizationManager.T("Main.EditGroupTooltip"))); + actions.Children.Add(GroupRowActionButton(group, "IconDelete", "DeleteGroupCommand", LocalizationManager.T("Main.DeleteGroupTooltip"))); + Grid.SetColumn(actions, 0); + row.Children.Add(actions); + + Grid.SetColumn(caption, 1); + row.Children.Add(caption); + + header.Child = row; return header; } + /// + /// Кнопка действия в колонке «Действия» строки группы: иконка, команда из вьюмодели, + /// параметром служит узел группы строки. + /// + private Button GroupRowActionButton(GroupNodeViewModel group, string iconKey, string commandPath, string tooltip) + { + var button = new Button + { + Content = IconHelper.MakeIcon(iconKey, UiMetrics.Scaled(15), "TextSecondaryBrush"), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(4), + MinWidth = 0, + MinHeight = 0, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand), + CommandParameter = group + }; + ToolTip.SetTip(button, tooltip); + // Команда живёт во вьюмодели, а контекстом строки служит узел группы. + button.Bind(Button.CommandProperty, new Binding(commandPath) { Source = _vm }); + return button; + } + private Control BuildInfobaseRow(Infobase ib) { // Карточка с фоном/границей из темы; hover и выделение отслеживает сама @@ -559,8 +992,54 @@ private Control BuildInfobaseRow(Infobase ib) var card = new InfobaseRowCard(); var grid = new Grid(); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + // Слева направо: звезда, булавка, иконка типа подключения, имя базы, + // дальше колонки значений. Звезда и булавка повторяют колонки заголовка + // теми же ширинами и подчиняются тем же настройкам. + var showFavorite = _vm?.ShowFavoritesButton ?? true; + var showPin = _vm?.ShowPinnedButton ?? true; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(showFavorite ? FavoriteColumnWidth : 0) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(showPin ? PinColumnWidth : 0) }); + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(IconColumnWidth) }); + grid.ColumnDefinitions.Add(new ColumnDefinition + { + Width = NameColumnLength(), + MinWidth = MinColumnWidth + }); + + // Колонки идут теми же ширинами, что и в заголовке, поэтому значения + // строк выстраиваются под своими заголовками. + var columns = ListColumns(); + // Колонка «Действия» встаёт сразу после колонки «Режим запуска», поэтому её + // определение встраивается в последовательность, как и в заголовке. + var actionsOffset = ActionsOffsetInColumns(columns); + for (var i = 0; i < columns.Count; i++) + { + if (i == actionsOffset) + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(ActionsColumnWidth) }); + grid.ColumnDefinitions.Add( + new ColumnDefinition { Width = new GridLength(columns[i].Width), MinWidth = MinColumnWidth }); + } + // «Режим запуска» скрыт или стоит последним — действия уходят в самый конец. + if (actionsOffset >= columns.Count) + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(ActionsColumnWidth) }); + + if (showFavorite) + { + var favorite = RowMarkButton(card, ib, "IconFavorite", "FavoriteBrush", + nameof(Infobase.IsFavorite), () => ib.IsFavorite, + LocalizationManager.T("Main.ToggleFavoriteTooltip"), "ToggleFavoriteForCommand", FavoriteColumnWidth); + grid.Children.Add(favorite); + Grid.SetColumn(favorite, 0); + } + + if (showPin) + { + var pin = RowMarkButton(card, ib, "IconPin", "AccentBrush", + nameof(Infobase.IsPinned), () => ib.IsPinned, + LocalizationManager.T("Main.TogglePinTooltip"), "TogglePinForCommand", PinColumnWidth); + grid.Children.Add(pin); + Grid.SetColumn(pin, 1); + } // Иконка статуса базы слева: тип подключения (папка / глобус / сеть) // или «недоступна». Цвет зависит от статуса: янтарный — файловая, @@ -575,9 +1054,9 @@ private Control BuildInfobaseRow(Infobase ib) BorderThickness = new Thickness(1), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Top, - Margin = new Thickness(0, 0, 10, 0), - ToolTip = ib.StatusDisplay + Margin = new Thickness(0, 0, 10, 0) }; + ToolTip.SetTip(iconBox, ib.StatusDisplay); ThemeBrushes.Bind(iconBox, Border.BackgroundProperty, "CardBackgroundBrush"); ThemeBrushes.Bind(iconBox, Border.BorderBrushProperty, "BorderColorBrush"); iconBox.Child = new Avalonia.Controls.Shapes.Path @@ -592,15 +1071,16 @@ private Control BuildInfobaseRow(Infobase ib) }; grid.Children.Add(iconBox); - Grid.SetColumn(iconBox, 0); + Grid.SetColumn(iconBox, 2); // Правая колонка: имя (крупно) + строки вторичной информации. // В компактном режиме уменьшаем и межстрочный промежуток, чтобы строки с // полным набором метаданных тоже «сжимались», а не оставались прежней высоты. var content = new StackPanel { Spacing = UiMetrics.Scaled(2), VerticalAlignment = VerticalAlignment.Center }; - // Строка имени с маркерами избранного/закрепления. - var nameRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + // Имя базы кладётся в колонку напрямую: в горизонтальной панели оно + // получало бы бесконечную ширину и при узкой колонке налезало бы + // на соседние значения вместо обрезки многоточием. var name = new TextBlock { Text = ib.Name, @@ -610,40 +1090,328 @@ private Control BuildInfobaseRow(Infobase ib) VerticalAlignment = VerticalAlignment.Center }; ThemeBrushes.Bind(name, TextBlock.ForegroundProperty, "TextPrimaryBrush"); - nameRow.Children.Add(name); - if (ib.IsFavorite) - nameRow.Children.Add(IconHelper.MakeIcon("IconFavorite", 13, "FavoriteBrush")); - if (ib.IsPinned) - nameRow.Children.Add(IconHelper.MakeIcon("IconPin", 13, "TextSecondaryBrush")); - content.Children.Add(nameRow); - - // Версия платформы + конфигурация (название (версия)). - var versionLine = JoinSegments(ib.PlatformVersion, ib.ConfigurationDisplay); - if (!string.IsNullOrWhiteSpace(versionLine)) - content.Children.Add(SecondaryText(versionLine)); - - // Тип подключения + сервер/путь (для веб — URL публикации). + content.Children.Add(name); + + // Вторичной строкой остаётся только то, чего нет в колонках: тип + // подключения и путь. Остальное ушло в колонки, иначе одни и те же + // данные показывались бы дважды. + var shown = new HashSet(columns.Select(c => c.Key), StringComparer.Ordinal); var location = ib.Connection.Type switch { ConnectionType.WebServer => ib.Connection.WebUrl, _ => ib.ServerDatabaseDisplay }; - var serverLine = JoinSegments(ib.ConnectionTypeDisplay, location); - if (!string.IsNullOrWhiteSpace(serverLine)) - content.Children.Add(SecondaryText(serverLine)); - - // Режим запуска (тонкий/толстый/веб-клиент) + последний запуск. - var launchLine = JoinSegments(ib.ParsedLaunchMode, ib.LastLaunchDisplay); - if (!string.IsNullOrWhiteSpace(launchLine)) - content.Children.Add(SecondaryText(launchLine)); + var summary = shown.Contains("ServerBase") + ? ib.ConnectionTypeDisplay + : JoinSegments(ib.ConnectionTypeDisplay, location); + if (!string.IsNullOrWhiteSpace(summary)) + content.Children.Add(SecondaryText(summary, card)); grid.Children.Add(content); - Grid.SetColumn(content, 1); + Grid.SetColumn(content, 3); + + var dataColumn = NameRowColumn + 1; + for (var i = 0; i < columns.Count; i++) + { + if (i == actionsOffset) + dataColumn++; + var value = ColumnValue(ib, columns[i].Key); + var cell = SecondaryText(string.IsNullOrWhiteSpace(value) ? string.Empty : value, card); + cell.VerticalAlignment = VerticalAlignment.Center; + grid.Children.Add(cell); + Grid.SetColumn(cell, dataColumn); + dataColumn++; + } + + // Кнопки действий в колонке «Действия» (после колонки «Режим запуска»): + // запуск, конфигуратор, изменить настройки, очистить кеш, удалить. + var actionsCol = NameRowColumn + 1 + actionsOffset; + var actions = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Spacing = 1 + }; + actions.Children.Add(RowActionButton(ib, "IconPlay", "LaunchEnterpriseCommand", LocalizationManager.T("Main.LaunchEnterpriseTooltip"))); + actions.Children.Add(RowActionButton(ib, "IconWrench", "LaunchConfiguratorCommand", LocalizationManager.T("Main.LaunchConfiguratorSectionTooltip"))); + actions.Children.Add(RowActionButton(ib, "IconEdit", "EditInfobaseCommand", LocalizationManager.T("Main.EditBaseTooltip"))); + actions.Children.Add(RowActionButton(ib, "IconDelete", "ClearCacheCommand", LocalizationManager.T("Main.ClearCacheTooltip"))); + actions.Children.Add(RowActionButton(ib, "IconDelete", "DeleteInfobaseCommand", LocalizationManager.T("Main.DeleteTooltip"))); + grid.Children.Add(actions); + Grid.SetColumn(actions, actionsCol); + + if (_vm?.ShowTags == true) + { + // Теги идут второй строкой сетки во всю ширину, как в WPF-версии: + // внутри колонки имени они переносились бы по её ширине и тянули + // высоту строки вверх. + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + var tags = BuildRowTags(card, ib); + grid.Children.Add(tags); + Grid.SetRow(tags, 1); + Grid.SetColumn(tags, NameRowColumn); + Grid.SetColumnSpan(tags, grid.ColumnDefinitions.Count - NameRowColumn); + } card.Child = grid; return card; } + /// + /// Теги базы под её именем: чип с крестиком на каждый тег и кнопка + /// «+ тег». Панель перестраивается по уведомлению самой базы, поэтому + /// после правки тегов строку пересобирать не нужно. + /// + private Control BuildRowTags(InfobaseRowCard card, Infobase infobase) + { + var panel = new WrapPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 2, 0, 0) }; + var chipSubscriptions = new List(); + + void Fill() + { + foreach (var subscription in chipSubscriptions) + subscription.Dispose(); + chipSubscriptions.Clear(); + panel.Children.Clear(); + + foreach (var tag in infobase.Tags) + panel.Children.Add(BuildTagChip(infobase, tag, chipSubscriptions)); + + panel.Children.Add(BuildAddTagButton(infobase, chipSubscriptions)); + } + + void OnInfobaseChanged(object? _, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Infobase.Tags)) + Fill(); + } + + card.AddSubscription(() => + { + infobase.PropertyChanged += OnInfobaseChanged; + Fill(); + return new ActionDisposable(() => + { + infobase.PropertyChanged -= OnInfobaseChanged; + foreach (var subscription in chipSubscriptions) + subscription.Dispose(); + chipSubscriptions.Clear(); + }); + }); + + return panel; + } + + /// Чип тега: клик отбирает базы по тегу, крестик убирает тег у базы. + private Control BuildTagChip(Infobase infobase, string tag, ICollection subscriptions) + { + var text = new TextBlock + { + Text = tag, + FontSize = UiMetrics.ScaledFont(10), + VerticalAlignment = VerticalAlignment.Center, + MaxWidth = UiMetrics.Scaled(180), + TextTrimming = TextTrimming.CharacterEllipsis + }; + ToolTip.SetTip(text, tag); + ThemeBrushes.Bind(text, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + + var name = new Button + { + Content = text, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + MinWidth = 0, + MinHeight = 0, + Cursor = new Cursor(StandardCursorType.Hand), + CommandParameter = tag + }; + ToolTip.SetTip(name, LocalizationManager.T("Main.ShowTagBases")); + name.Bind(Button.CommandProperty, new Binding("SearchByTagCommand") { Source = _vm }); + + var remove = new Button + { + Content = IconHelper.MakeIcon("IconClose", UiMetrics.Scaled(9), "TextSecondaryBrush"), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + Margin = new Thickness(4, 0, 0, 0), + MinWidth = 0, + MinHeight = 0, + Cursor = new Cursor(StandardCursorType.Hand), + // Форма параметра та же, что в WPF-версии: база и тег. + CommandParameter = new object[] { infobase, tag } + }; + ToolTip.SetTip(remove, LocalizationManager.T("Main.RemoveTag")); + remove.Bind(Button.CommandProperty, new Binding("RemoveTagCommand") { Source = _vm }); + + var row = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; + row.Children.Add(name); + row.Children.Add(remove); + + var chip = new Border + { + CornerRadius = new CornerRadius(UiMetrics.RadiusMd), + BorderThickness = new Thickness(1), + Padding = new Thickness(6, 1), + Margin = new Thickness(0, 0, 4, 2), + Child = row + }; + ThemeBrushes.Bind(chip, Border.BackgroundProperty, "CardBackgroundBrush"); + ThemeBrushes.Bind(chip, Border.BorderBrushProperty, "BorderColorBrush"); + return chip; + } + + /// Складывает подписку в приёмник, пропуская пустую (Application ещё не поднят). + private static void Track(ICollection sink, IDisposable? subscription) + { + if (subscription is not null) + sink.Add(subscription); + } + + /// Кнопка «+ тег» в конце списка тегов строки. + private Control BuildAddTagButton(Infobase infobase, ICollection subscriptions) + { + var text = new TextBlock + { + Text = LocalizationManager.T("Main.AddTagShort"), + FontSize = UiMetrics.ScaledFont(10), + VerticalAlignment = VerticalAlignment.Center + }; + ThemeBrushes.Bind(text, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + + var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4 }; + content.Children.Add(IconHelper.MakeIcon("IconTag", UiMetrics.Scaled(9), "TextSecondaryBrush")); + content.Children.Add(text); + + var button = new Button + { + Content = content, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(2, 1), + MinWidth = 0, + MinHeight = 0, + Cursor = new Cursor(StandardCursorType.Hand), + CommandParameter = infobase + }; + ToolTip.SetTip(button, LocalizationManager.T("Main.AddTag")); + button.Bind(Button.CommandProperty, new Binding("AddTagCommand") { Source = _vm }); + return button; + } + + /// + /// Кнопка-маркер в строке базы: звезда «избранное» или булавка «закреплено». + /// Цвет иконки следит за состоянием самой базы, поэтому после переключения + /// строку не нужно пересобирать, и за кистями темы он тоже следует. + /// + private Button RowMarkButton(InfobaseRowCard card, Infobase infobase, string iconKey, + string activeBrushKey, string stateProperty, Func isActive, + string tooltip, string commandPath, double width) + { + var icon = new Avalonia.Controls.Shapes.Path + { + Width = UiMetrics.Scaled(14), + Height = UiMetrics.Scaled(14), + Data = IconHelper.Geometry(iconKey), + Stretch = Stretch.Uniform, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + + IBrush? active = null; + IBrush? idle = null; + void ApplyState() + { + var brush = isActive() ? active : idle; + if (brush is not null) + icon.Fill = brush; + } + + card.AddSubscription(() => Application.Current?.GetResourceObservable(activeBrushKey) + .Subscribe(new BrushObserver(brush => active = brush, ApplyState))); + card.AddSubscription(() => Application.Current?.GetResourceObservable("TextSecondaryBrush") + .Subscribe(new BrushObserver(brush => idle = brush, ApplyState))); + + void OnInfobaseChanged(object? _, PropertyChangedEventArgs e) + { + if (e.PropertyName == stateProperty) + ApplyState(); + } + + card.AddSubscription(() => + { + infobase.PropertyChanged += OnInfobaseChanged; + // Состояние могло измениться, пока строка была отсоединена. + ApplyState(); + return new ActionDisposable(() => infobase.PropertyChanged -= OnInfobaseChanged); + }); + + var button = new Button + { + Content = icon, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(0), + MinWidth = 0, + MinHeight = 0, + Width = width, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand), + CommandParameter = infobase + }; + ToolTip.SetTip(button, tooltip); + // Команда живёт во вьюмодели, а контекстом строки служит сама база, + // поэтому источник привязки указывается явно. + button.Bind(Button.CommandProperty, new Binding(commandPath) { Source = _vm }); + return button; + } + + /// + /// Кнопка действия в колонке «Действия» строки базы: иконка, команда из вьюмодели, + /// параметром служит сама информационная база строки. + /// + private Button RowActionButton(Infobase ib, string iconKey, string commandPath, string tooltip) + { + var button = new Button + { + Content = IconHelper.MakeIcon(iconKey, UiMetrics.Scaled(15), "TextSecondaryBrush"), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(4), + MinWidth = 0, + MinHeight = 0, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand), + CommandParameter = ib + }; + ToolTip.SetTip(button, tooltip); + // Команда живёт во вьюмодели, а контекстом строки служит сама база. + button.Bind(Button.CommandProperty, new Binding(commandPath) { Source = _vm }); + return button; + } + + /// Освобождение по вызову действия: снятие подписки на событие модели. + private sealed class ActionDisposable : IDisposable + { + private Action? _dispose; + + public ActionDisposable(Action dispose) => _dispose = dispose; + + public void Dispose() + { + var action = _dispose; + _dispose = null; + action?.Invoke(); + } + } + /// Объединяет непустые фрагменты в одну строку с разделителем «•». private static string JoinSegments(params string?[] parts) { @@ -655,102 +1423,330 @@ private static string JoinSegments(params string?[] parts) } /// Строка вторичной информации: приглушённый текст из темы с подсказкой по полному значению. - private static TextBlock SecondaryText(string text) + private static TextBlock SecondaryText(string text, InfobaseRowCard? owner = null) { var block = new TextBlock { Text = text, FontSize = UiMetrics.RowSecondaryFont, - TextTrimming = TextTrimming.CharacterEllipsis, - ToolTip = text + TextTrimming = TextTrimming.CharacterEllipsis }; - ThemeBrushes.Bind(block, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + ToolTip.SetTip(block, text); + if (owner is null) + ThemeBrushes.Bind(block, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + else + ThemeBrushes.Bind(block, TextBlock.ForegroundProperty, "TextSecondaryBrush"); return block; } private Control BuildRightPanel() { - var panel = new StackPanel { HorizontalAlignment = HorizontalAlignment.Stretch }; + // Компактная правая панель: primary-запуски на всю ширину, вторичные + // действия — списком в один столбец, секции без тяжёлых карточек. + var panel = new StackPanel + { + HorizontalAlignment = HorizontalAlignment.Stretch, + Spacing = UiMetrics.ActionGridGap + }; // Заголовок базы - var nameBlock = new TextBlock { FontSize = 16, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap }; - nameBlock.Bind(TextBlock.TextProperty, new Binding("SelectedInfobase.Name")); - - var groupBlock = new TextBlock { FontSize = 12, Opacity = 0.7, TextWrapping = TextWrapping.Wrap }; - groupBlock.Bind(TextBlock.TextProperty, new Binding("SelectedInfobase.GroupDisplay")); + var nameBlock = new TextBlock + { + FontSize = UiMetrics.ScaledFont(15), + FontWeight = FontWeight.SemiBold, + TextWrapping = TextWrapping.Wrap + }; + nameBlock.Bind(TextBlock.TextProperty, new Binding("RightPanelTitle")); - var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 12), Spacing = 10 }; - header.Children.Add(IconHelper.MakeIcon("IconDatabase", 28)); - var headerText = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var groupBlock = new TextBlock + { + FontSize = UiMetrics.ScaledFont(11.5), + Opacity = 0.7, + TextWrapping = TextWrapping.Wrap + }; + groupBlock.Bind(TextBlock.TextProperty, new Binding("RightPanelSubtitle")); + + // Заголовок сеткой, а не горизонтальной панелью: в панели подпись + // получала бы бесконечную ширину и не переносилась бы по словам. + var header = new Grid { Margin = new Thickness(0, 0, 0, 4), ColumnSpacing = 8 }; + header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + // Значок базы показывается только когда база выбрана: при выбранной + // группе и при пустом выборе он висел бы один без подписи. + var headerIcon = IconHelper.MakeIcon("IconDatabase", UiMetrics.Scaled(24)); + headerIcon.Bind(Avalonia.Controls.Shapes.Path.DataProperty, + new Binding("RightPanelIconKey") { Converter = IconKeyConverter }); + headerIcon.Bind(Control.IsVisibleProperty, new Binding("HasRightPanelIcon")); + header.Children.Add(headerIcon); + var headerText = new StackPanel { VerticalAlignment = VerticalAlignment.Center, Spacing = 1 }; headerText.Children.Add(nameBlock); headerText.Children.Add(groupBlock); header.Children.Add(headerText); - panel.Children.Add(header); - - // Основное действие (primary) — крупная акцентная кнопка вверху. - panel.Children.Add(PrimaryActionButton("IconPlay", LocalizationManager.T("Main.LaunchEnterprise"), "LaunchEnterpriseCommand", LocalizationManager.T("Main.LaunchEnterpriseTooltip"))); - - // Секции secondary-действий, сгруппированные по смыслу. - panel.Children.Add(SectionCard(LocalizationManager.T("Main.SectionConfigurator"), "IconConfiguration", - SecondaryActionButton("IconWrench", LocalizationManager.T("Main.LaunchConfiguratorSection"), "LaunchConfiguratorCommand", LocalizationManager.T("Main.LaunchConfiguratorSectionTooltip")))); - - panel.Children.Add(SectionCard(LocalizationManager.T("Main.SectionMaintenance"), "IconWrench", - BuildClearCacheSplitButton(), - SecondaryActionButton("IconEdit", LocalizationManager.T("Main.EditSettings"), "EditInfobaseCommand", LocalizationManager.T("Main.EditBaseTooltip")), - SecondaryActionButton("IconOpen", LocalizationManager.T("Main.OpenFolder"), "OpenInfobaseFolderCommand", LocalizationManager.T("Main.OpenFolderTooltip")), - SecondaryActionButton("IconKeyboard", LocalizationManager.T("Main.RunStarter"), "OpenNativeStarterCommand", LocalizationManager.T("Main.NativeStarterTooltip")))); + Grid.SetColumn(headerText, 1); - panel.Children.Add(SectionCard(LocalizationManager.T("Main.SectionBaseList"), "IconList", - SecondaryActionButton("IconAdd", LocalizationManager.T("Main.AddBaseOrGroup"), "AddInfobaseCommand", LocalizationManager.T("Main.AddBaseOrGroupTooltip")), - SecondaryActionButton("IconShortcut", LocalizationManager.T("Main.DesktopShortcut"), "CreateDesktopShortcutCommand", LocalizationManager.T("Main.DesktopShortcutTooltip")), - SecondaryActionButton("IconDelete", LocalizationManager.T("Main.Delete"), "DeleteInfobaseCommand", LocalizationManager.T("Main.DeleteTooltip")))); - - panel.Children.Add(SectionCard(LocalizationManager.T("Main.SectionMarks"), "IconStar", - SecondaryActionButton("IconFavorite", LocalizationManager.T("Main.ToFavorites"), "ToggleFavoriteCommand", LocalizationManager.T("Main.ToggleFavoriteTooltip")), - SecondaryActionButton("IconPin", LocalizationManager.T("Main.Pin"), "TogglePinCommand", LocalizationManager.T("Main.PinBaseTooltip")))); + // Подсказка «выберите базу» отдельной строкой под заголовком, + // как в WPF: там она видна, пока база не выбрана. + var hintBlock = new TextBlock + { + FontSize = UiMetrics.ScaledFont(11.5), + TextWrapping = TextWrapping.Wrap, + Opacity = 0.7, + Margin = new Thickness(0, 0, 0, 4) + }; + hintBlock.Bind(TextBlock.TextProperty, new Binding("RightPanelHint")); + hintBlock.Bind(Control.IsVisibleProperty, new Binding("!IsInfobaseSelected")); + panel.Children.Add(header); + panel.Children.Add(hintBlock); + + // Запуск 1С:Предприятие (primary) — акцентная кнопка запуска с меню + // дополнительных вариантов. + panel.Children.Add(BuildLaunchSplitButton( + "IconPlay", + LocalizationManager.T("Main.LaunchEnterprise"), + "LaunchEnterpriseCommand", + LocalizationManager.T("Main.LaunchEnterpriseTooltip"), + primary: true, + new[] + { + (LocalizationManager.T("Main.LaunchWithParams"), "LaunchEnterpriseWithParamsCommand"), + (LocalizationManager.T("Main.LaunchWithAuth"), "LaunchEnterpriseWithAuthCommand") + })); + + // Конфигуратор — secondary full-width (без отдельной тяжёлой карточки). + panel.Children.Add(BuildLaunchSplitButton( + "IconWrench", + LocalizationManager.T("Main.LaunchConfiguratorSection"), + "LaunchConfiguratorCommand", + LocalizationManager.T("Main.LaunchConfiguratorSectionTooltip"), + primary: false, + new[] + { + (LocalizationManager.T("Main.LaunchWithParams"), "LaunchConfiguratorWithParamsCommand") + })); + + // Остальные действия («Очистить кеш», «Изменить настройки», «Удалить», + // «Добавить») перенесены в колонку «Действия» строк базы и верхнюю панель + // команд. Здесь остаются вторичные действия списком. + panel.Children.Add(BuildActionList( + CompactActionButton("IconOpen", LocalizationManager.T("Main.OpenFolder"), "OpenInfobaseFolderCommand", LocalizationManager.T("Main.OpenFolderTooltip")), + CompactActionButton("IconKeyboard", LocalizationManager.T("Main.RunStarter"), "OpenNativeStarterCommand", LocalizationManager.T("Main.NativeStarterTooltipLinux")), + CompactActionButton("IconWeb", LocalizationManager.T("LinkInput.Title"), "OpenInfobaseByLinkCommand", LocalizationManager.T("Main.OpenLinkTooltip")), + CompactActionButton("IconShortcut", LocalizationManager.T("Main.DesktopShortcut"), "CreateDesktopShortcutCommand", LocalizationManager.T("Main.DesktopShortcutTooltip")) + )); // Информация о подключении. - panel.Children.Add(SectionCard(LocalizationManager.T("Main.SectionConnInfo"), "IconInfo", + // Блок сведений подчинён переключателю подробностей правой панели, + // как в WPF: там по нему прячется та же таблица. + var connectionCard = SectionCard(LocalizationManager.T("Main.SectionConnInfo"), "IconInfo", DetailRow(LocalizationManager.T("Main.Type"), new Binding("SelectedInfobase.ConnectionTypeDisplay")), DetailRow(LocalizationManager.T("Main.ServerPath"), new Binding("SelectedInfobase.ConnectionPathDisplay")), + DetailRow(LocalizationManager.T("Column.ServerBase"), new Binding("SelectedInfobase.ServerDatabaseDisplay")), DetailRow(LocalizationManager.T("Main.ConnectionString"), new Binding("SelectedInfobase.ConnectionStringDisplay")), DetailRow(LocalizationManager.T("Main.Platform"), new Binding("SelectedInfobase.PlatformVersion")), DetailRow(LocalizationManager.T("Main.LaunchMode"), new Binding("SelectedInfobase.ParsedLaunchMode")), + DetailRow(LocalizationManager.T("Main.Client"), new Binding("SelectedInfobase.ClientTypeDisplay")), DetailRow(LocalizationManager.T("Main.Bitness"), new Binding("SelectedInfobase.ArchitectureDisplay")), - DetailRow(LocalizationManager.T("Main.LastLaunch"), new Binding("SelectedInfobase.LastLaunchDisplay")))); + DetailRow(LocalizationManager.T("Main.Parameters"), new Binding("SelectedInfobase.LaunchParameters")), + DetailRow(LocalizationManager.T("Main.LastLaunch"), new Binding("SelectedInfobase.LastLaunchDisplay")), + DetailRow(LocalizationManager.T("Main.CacheSize"), new Binding("SelectedInfobase.CacheSizeDisplay"))); + connectionCard.Bind(Control.IsVisibleProperty, new Binding("ShowConnectionInfo")); + panel.Children.Add(connectionCard); + + // Блок «Текущая сессия»: значения действуют только на следующий запуск. + panel.Children.Add(BuildSessionCard()); // Описание. - var desc = new TextBlock { TextWrapping = TextWrapping.Wrap }; + var desc = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = UiMetrics.ScaledFont(12) }; desc.Bind(TextBlock.TextProperty, new Binding("SelectedInfobase.Description")); panel.Children.Add(SectionCard(LocalizationManager.T("Main.Description"), "IconInfo", desc)); - panel.Children.Add(SecondaryActionButton("IconExit", LocalizationManager.T("Main.Exit"), "ExitCommand")); + // Выход — компактная кнопка внизу, без лишней «карточки». + var exitBtn = CompactActionButton("IconExit", LocalizationManager.T("Main.Exit"), "ExitCommand", + LocalizationManager.T("Main.ExitTooltip")); + exitBtn.Margin = new Thickness(0, UiMetrics.ActionGridGap, 0, 0); + panel.Children.Add(exitBtn); return panel; } - /// Карточка-секция: скруглённый фон/граница из темы + заголовок с иконкой и вложенные элементы. + /// + /// Двухколоночная сетка компактных кнопок действий правой панели. + /// Равномерно заполняет ширину и заметно экономит вертикальное место + /// по сравнению со стеком полноширинных secondary-кнопок. + /// + private static Control BuildActionList(params Control[] buttons) + { + // Кнопки идут в один столбец, как в версии для Windows. Двухколоночная + // раскладка экономила высоту, но подписи в неё не помещались ни при + // какой ширине панели: при её пределе 340 на ячейку остаётся около 160 + // пикселей, а «Ярлык на рабочем столе» требует почти 190, и подпись + // обрывалась на середине слова. + var stack = new StackPanel + { + Orientation = Orientation.Vertical, + Spacing = UiMetrics.ActionGridGap, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + foreach (var btn in buttons) + stack.Children.Add(btn); + + return stack; + } + + /// + /// Компактная кнопка действия правой панели: иконка + текст, низкая высота, + /// растягивается на всю ширину панели. + /// + private static Control CompactActionButton(string iconKey, string text, string commandPath, string tooltip) + { + var btn = new PanelButton( + "SecondaryButtonBackgroundBrush", + "SecondaryButtonHoverBrush", + "SecondaryButtonPressedBrush", + "BorderColorBrush", + new CornerRadius(UiMetrics.RadiusMd)) + { + Content = CompactIconAndText(iconKey, text, "ButtonTextBrush"), + HorizontalContentAlignment = HorizontalAlignment.Left, + HorizontalAlignment = HorizontalAlignment.Stretch, + MinHeight = UiMetrics.ActionButtonMinHeight, + Padding = new Thickness(UiMetrics.ActionButtonPadH, UiMetrics.ActionButtonPadV), + Margin = new Thickness(0) + }; + ToolTip.SetTip(btn, tooltip); + btn.Bind(Button.CommandProperty, new Binding(commandPath)); + return btn; + } + + /// Компактное содержимое кнопки: иконка + подпись меньшего размера. + private static Control CompactIconAndText(string iconKey, string text, string brushKey) + { + // Сеткой, а не горизонтальной панелью: панель меряет подпись + // бесконечной шириной, поэтому обрезка многоточием не срабатывает + // и длинный текст вылезает за кнопку вместо того, чтобы сократиться. + var sp = new Grid { ColumnSpacing = 6, VerticalAlignment = VerticalAlignment.Center }; + sp.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + sp.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + sp.Children.Add(IconHelper.MakeIcon(iconKey, UiMetrics.ActionIconSize, brushKey)); + var tb = new TextBlock + { + Text = text, + FontSize = UiMetrics.ActionFontSize, + FontWeight = FontWeight.Medium, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + ThemeBrushes.Bind(tb, TextBlock.ForegroundProperty, brushKey); + Grid.SetColumn(tb, 1); + sp.Children.Add(tb); + return sp; + } + + /// + /// Блок «Текущая сессия»: режим клиента и разрядность только для + /// следующего запуска, сохранённые настройки базы он не меняет. + /// Видимостью управляет настройка, как и в WPF-версии. + /// + private Control BuildSessionCard() + { + var hint = new TextBlock + { + Text = LocalizationManager.T("Main.SessionOnceHint"), + FontSize = UiMetrics.ScaledFont(11), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 6) + }; + ThemeBrushes.Bind(hint, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + ToolTip.SetTip(hint, LocalizationManager.T("Main.CurrentSessionHelp")); + + var card = SectionCard(LocalizationManager.T("Main.CurrentSession"), "IconInfo", + hint, + SessionGroupLabel(LocalizationManager.T("Main.ClientMode")), + SessionOption(LocalizationManager.T("Main.SessionClientAuto"), "SessionClient", "IsSessionClientAuto"), + SessionOption(LocalizationManager.T("Main.SessionClientOrdinary"), "SessionClient", "IsSessionClientOrdinary"), + SessionOption(LocalizationManager.T("Main.SessionClientThickManaged"), "SessionClient", "IsSessionClientThick", + LocalizationManager.T("Main.SessionThickManagedTooltip")), + SessionOption(LocalizationManager.T("Main.SessionClientThickOrdinary"), "SessionClient", "IsSessionClientThickOrdinary", + LocalizationManager.T("Main.SessionThickOrdinaryTooltip")), + SessionOption(LocalizationManager.T("Main.SessionClientThin"), "SessionClient", "IsSessionClientThin"), + SessionGroupLabel(LocalizationManager.T("Main.Bitness")), + SessionOption(LocalizationManager.T("Main.SessionClientAuto"), "SessionArch", "IsSessionArchAuto"), + SessionOption("32", "SessionArch", "IsSessionArch32"), + SessionOption("64", "SessionArch", "IsSessionArch64")); + + card.Bind(Control.IsVisibleProperty, new Binding("ShowSessionLaunchPanel")); + return card; + } + + /// Подпись группы переключателей в блоке текущей сессии. + private Control SessionGroupLabel(string text) + { + var block = new TextBlock + { + Text = text, + FontSize = UiMetrics.ScaledFont(11), + FontWeight = FontWeight.SemiBold, + Margin = new Thickness(0, 6, 0, 2) + }; + ThemeBrushes.Bind(block, TextBlock.ForegroundProperty, "TextSecondaryBrush"); + return block; + } + + /// Переключатель в блоке текущей сессии: одна из взаимоисключающих опций. + private static Control SessionOption(string text, string group, string propertyPath, string? tooltip = null) + { + var option = new RadioButton + { + Content = text, + GroupName = group, + FontSize = UiMetrics.ScaledFont(12), + Margin = new Thickness(0, 1) + }; + option.Bind(RadioButton.IsCheckedProperty, new Binding(propertyPath) { Mode = BindingMode.TwoWay }); + if (tooltip is not null) + ToolTip.SetTip(option, tooltip); + return option; + } + + /// Ключ значка в геометрию из Icons.axaml для привязок заголовка. + private static readonly Avalonia.Data.Converters.FuncValueConverter IconKeyConverter = + new(key => string.IsNullOrEmpty(key) ? null : IconHelper.Geometry(key)); + + /// + /// Карточка-секция: лёгкий фон/граница из темы + компактный заголовок с иконкой. + /// Без тяжёлой тени — правая панель выглядит современнее и занимает меньше места. + /// private static Control SectionCard(string title, string iconKey, params Control[] children) { var card = new Border { - CornerRadius = new CornerRadius(UiMetrics.RadiusXl), + CornerRadius = new CornerRadius(UiMetrics.RadiusLg), BorderThickness = new Thickness(1), Padding = new Thickness(UiMetrics.SectionPad), - Margin = new Thickness(0, 0, 0, UiMetrics.SectionMarginBottom), + Margin = new Thickness(0, UiMetrics.ActionGridGap, 0, 0), HorizontalAlignment = HorizontalAlignment.Stretch }; ThemeBrushes.Bind(card, Border.BackgroundProperty, "CardBackgroundBrush"); ThemeBrushes.Bind(card, Border.BorderBrushProperty, "BorderColorBrush"); - // Мягкая тень и плавные переходы цвета у секций-карточек. - UiMetrics.AddSoftShadow(card); UiMetrics.AddBrushTransition(card); var content = new StackPanel { Spacing = UiMetrics.Gap }; - var header = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 0, 0, 2) }; - header.Children.Add(IconHelper.MakeIcon(iconKey, 16, "TextSecondaryBrush")); - var titleBlock = new TextBlock { Text = title, FontSize = 13, FontWeight = FontWeight.SemiBold, VerticalAlignment = VerticalAlignment.Center }; + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Margin = new Thickness(0, 0, 0, 2) + }; + header.Children.Add(IconHelper.MakeIcon(iconKey, 14, "TextSecondaryBrush")); + var titleBlock = new TextBlock + { + Text = title, + FontSize = UiMetrics.ScaledFont(12), + FontWeight = FontWeight.SemiBold, + VerticalAlignment = VerticalAlignment.Center + }; ThemeBrushes.Bind(titleBlock, TextBlock.ForegroundProperty, "TextSecondaryBrush"); header.Children.Add(titleBlock); content.Children.Add(header); @@ -767,13 +1763,14 @@ private static Control PrimaryActionButton(string iconKey, string text, string c { var btn = new PanelButton("AccentBrush", "AccentHoverBrush", "AccentPressedBrush", "AccentBrush") { - Content = ThemedIconAndText(iconKey, text, "TextOnAccentBrush", UiMetrics.Scaled(18), centered: true), + Content = ThemedIconAndText(iconKey, text, "TextOnAccentBrush", UiMetrics.ScaledFont(18), centered: true), HorizontalContentAlignment = HorizontalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Stretch, Margin = new Thickness(0, 0, 0, UiMetrics.SectionMarginBottom), - Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV), - ToolTip = new ToolTip { Content = tooltip } + Padding = new Thickness(UiMetrics.ButtonPadH, UiMetrics.ButtonPadV) }; + // В Avalonia подсказка это присоединённое свойство, а не свойство контрола. + ToolTip.SetTip(btn, tooltip); btn.Bind(Button.CommandProperty, new Binding(commandPath)); return btn; } @@ -785,14 +1782,17 @@ private static Control SecondaryActionButton(string iconKey, string text, string "SecondaryButtonBackgroundBrush", "SecondaryButtonHoverBrush", "SecondaryButtonPressedBrush", - "BorderColorBrush") + "BorderColorBrush", + new CornerRadius(UiMetrics.RadiusMd)) { - Content = ThemedIconAndText(iconKey, text, "TextPrimaryBrush", 16, centered: false), + Content = ThemedIconAndText(iconKey, text, "ButtonTextBrush", UiMetrics.ActionIconSize, centered: false), HorizontalContentAlignment = HorizontalAlignment.Left, HorizontalAlignment = HorizontalAlignment.Stretch, - Margin = new Thickness(0, 0, 0, 2), - ToolTip = new ToolTip { Content = tooltip } + MinHeight = UiMetrics.ActionButtonMinHeight, + Padding = new Thickness(UiMetrics.ActionButtonPadH, UiMetrics.ActionButtonPadV), + Margin = new Thickness(0, 0, 0, UiMetrics.ActionGridGap / 2) }; + ToolTip.SetTip(btn, tooltip); btn.Bind(Button.CommandProperty, new Binding(commandPath)); return btn; } @@ -816,12 +1816,15 @@ private static Control BuildClearCacheSplitButton() "BorderColorBrush", new CornerRadius(radius, 0, 0, radius)) { - Content = ThemedIconAndText("IconDelete", LocalizationManager.T("Main.ClearCache"), "TextPrimaryBrush", 16, centered: false), + Content = ThemedIconAndText("IconDelete", LocalizationManager.T("Main.ClearCache"), "ButtonTextBrush", + UiMetrics.ActionIconSize, centered: false), HorizontalContentAlignment = HorizontalAlignment.Left, HorizontalAlignment = HorizontalAlignment.Stretch, - Margin = new Thickness(0, 0, 0, 2), - ToolTip = new ToolTip { Content = LocalizationManager.T("Main.ClearCacheTooltip") } + MinHeight = UiMetrics.ActionButtonMinHeight, + Padding = new Thickness(UiMetrics.ActionButtonPadH, UiMetrics.ActionButtonPadV), + Margin = new Thickness(0) }; + ToolTip.SetTip(main, LocalizationManager.T("Main.ClearCacheTooltip")); main.Bind(Button.CommandProperty, new Binding("ClearCacheCommand")); // Выпадающее меню, привязанное к кнопке-стрелке. @@ -852,18 +1855,21 @@ private static Control BuildClearCacheSplitButton() "BorderColorBrush", new CornerRadius(0, radius, radius, 0)) { - Content = new TextBlock - { - Text = "▾", - FontSize = UiMetrics.Scaled(14), - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Center - }, - Width = 36, + Width = 32, + MinHeight = UiMetrics.ActionButtonMinHeight, Padding = new Thickness(0), - Margin = new Thickness(0, 0, 0, 2), - ToolTip = new ToolTip { Content = LocalizationManager.T("Main.ClearCacheTooltip") } + Margin = new Thickness(0) + }; + var arrowGlyph = new TextBlock + { + Text = "▾", + FontSize = UiMetrics.ScaledFont(12), + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center }; + ThemeBrushes.Bind(arrowGlyph, TextBlock.ForegroundProperty, "ButtonTextBrush"); + arrow.Content = arrowGlyph; + ToolTip.SetTip(arrow, LocalizationManager.T("Main.ClearCacheTooltip")); arrow.ContextMenu = menu; arrow.Click += (_, _) => menu.Open(arrow); @@ -878,19 +1884,101 @@ private static Control BuildClearCacheSplitButton() return grid; } + /// + /// Кнопка запуска со стрелкой и меню дополнительных вариантов, как + /// в WPF-версии. Пункт «от имени администратора» не переносится: + /// на Linux нет повышения прав через оболочку, параметр runAsAdmin + /// в лаунчере не используется, а запуск клиента 1С от root оставил бы + /// в домашнем каталоге пользователя файлы, которые ему не принадлежат. + /// + private static Control BuildLaunchSplitButton( + string iconKey, + string text, + string commandPath, + string tooltip, + bool primary, + IReadOnlyList<(string Header, string Command)> menuItems) + { + var radius = UiMetrics.RadiusLg; + var mainCorner = new CornerRadius(radius, 0, 0, radius); + + var main = primary + ? new PanelButton("AccentBrush", "AccentHoverBrush", "AccentPressedBrush", "AccentBrush", mainCorner) + : new PanelButton("SecondaryButtonBackgroundBrush", "SecondaryButtonHoverBrush", + "SecondaryButtonPressedBrush", "BorderColorBrush", mainCorner); + + var contentBrush = primary ? "TextOnAccentBrush" : "ButtonTextBrush"; + main.Content = ThemedIconAndText(iconKey, text, contentBrush, + primary ? UiMetrics.ScaledFont(16) : UiMetrics.ActionIconSize, centered: primary); + main.HorizontalContentAlignment = primary ? HorizontalAlignment.Center : HorizontalAlignment.Left; + main.HorizontalAlignment = HorizontalAlignment.Stretch; + main.MinHeight = UiMetrics.ActionButtonMinHeight + (primary ? 4 : 0); + main.Padding = new Thickness(UiMetrics.ActionButtonPadH, UiMetrics.ActionButtonPadV + (primary ? 2 : 0)); + main.Margin = new Thickness(0); + ToolTip.SetTip(main, tooltip); + main.Bind(Button.CommandProperty, new Binding(commandPath)); + + var menu = new ContextMenu(); + foreach (var (header, command) in menuItems) + { + if (header.Length == 0) + { + menu.Items.Add(new Separator()); + continue; + } + + var item = new MenuItem { Header = header }; + item.Bind(MenuItem.CommandProperty, new Binding(command)); + menu.Items.Add(item); + } + + var arrowCorner = new CornerRadius(0, radius, radius, 0); + var arrow = primary + ? new PanelButton("AccentBrush", "AccentHoverBrush", "AccentPressedBrush", "AccentBrush", arrowCorner) + : new PanelButton("SecondaryButtonBackgroundBrush", "SecondaryButtonHoverBrush", + "SecondaryButtonPressedBrush", "BorderColorBrush", arrowCorner); + arrow.Width = 32; + arrow.MinHeight = main.MinHeight; + arrow.Padding = new Thickness(0); + arrow.Margin = new Thickness(0); + + var arrowGlyph = new TextBlock + { + Text = "▾", + FontSize = UiMetrics.ScaledFont(12), + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center + }; + ThemeBrushes.Bind(arrowGlyph, TextBlock.ForegroundProperty, contentBrush); + arrow.Content = arrowGlyph; + ToolTip.SetTip(arrow, LocalizationManager.T("Main.MoreLaunchOptions")); + arrow.ContextMenu = menu; + arrow.Click += (_, _) => menu.Open(arrow); + + var grid = new Grid { Margin = new Thickness(0, 0, 0, 0) }; + grid.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(1, GridUnitType.Star))); + grid.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto)); + Grid.SetColumn(main, 0); + Grid.SetColumn(arrow, 1); + grid.Children.Add(main); + grid.Children.Add(arrow); + return grid; + } + /// Содержимое кнопки: иконка + подпись, окрашенные кистью ресурса темы. private static Control ThemedIconAndText(string iconKey, string text, string brushKey, double iconSize, bool centered) { - var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; + var sp = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; if (centered) sp.HorizontalAlignment = HorizontalAlignment.Center; sp.Children.Add(IconHelper.MakeIcon(iconKey, iconSize, brushKey)); var tb = new TextBlock { Text = text, - FontSize = UiMetrics.Scaled(13), + FontSize = UiMetrics.ActionFontSize + (centered ? 0.5 : 0), FontWeight = FontWeight.SemiBold, - VerticalAlignment = VerticalAlignment.Center + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis }; ThemeBrushes.Bind(tb, TextBlock.ForegroundProperty, brushKey); sp.Children.Add(tb); @@ -899,15 +1987,24 @@ private static Control ThemedIconAndText(string iconKey, string text, string bru private Control DetailRow(string label, Binding binding) { - var grid = new Grid { Margin = new Thickness(0, 0, 0, 5) }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); + var grid = new Grid { Margin = new Thickness(0, 0, 0, 3) }; + grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - var labelBlock = new TextBlock { Text = label, FontSize = 12, Opacity = 0.7 }; + var labelBlock = new TextBlock + { + Text = label, + FontSize = UiMetrics.ScaledFont(11.5), + Opacity = 0.7 + }; grid.Children.Add(labelBlock); Grid.SetColumn(labelBlock, 0); - var valueBlock = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap }; + var valueBlock = new TextBlock + { + FontSize = UiMetrics.ScaledFont(11.5), + TextWrapping = TextWrapping.Wrap + }; valueBlock.Bind(TextBlock.TextProperty, binding); grid.Children.Add(valueBlock); Grid.SetColumn(valueBlock, 1); @@ -921,7 +2018,6 @@ private Control DetailRow(string label, Binding binding) /// private sealed class PanelButton : Button { - private readonly List _subs = new(); private IBrush _baseBg = Brushes.Transparent; private IBrush _hoverBg = Brushes.Transparent; private IBrush _pressedBg = Brushes.Transparent; @@ -944,22 +2040,25 @@ public PanelButton(string baseBgKey, string hoverBgKey, string pressedBgKey, str // Кастомный шаблон: скруглённый Border + ContentPresenter (без Fluent-хрома). Theme = new ControlTheme(typeof(Button)) { - Template = new FuncControlTemplate((_, _) => + Setters = + { + new Setter(TemplatedControl.TemplateProperty, new FuncControlTemplate((_, _) => { var border = new Border { CornerRadius = _radius, BorderThickness = new Thickness(1) }; - border.Bind(Border.BackgroundProperty, new TemplateBinding(Control.BackgroundProperty)); - border.Bind(Border.BorderBrushProperty, new TemplateBinding(Control.BorderBrushProperty)); - border.Bind(Border.BorderThicknessProperty, new TemplateBinding(Control.BorderThicknessProperty)); - border.Bind(Border.PaddingProperty, new TemplateBinding(Control.PaddingProperty)); + border[!Border.BackgroundProperty] = new TemplateBinding(TemplatedControl.BackgroundProperty); + border[!Border.BorderBrushProperty] = new TemplateBinding(TemplatedControl.BorderBrushProperty); + border[!Border.BorderThicknessProperty] = new TemplateBinding(TemplatedControl.BorderThicknessProperty); + border[!Border.PaddingProperty] = new TemplateBinding(TemplatedControl.PaddingProperty); UiMetrics.AddBrushTransition(border); var presenter = new ContentPresenter(); - presenter.Bind(ContentPresenter.ContentProperty, new TemplateBinding(ContentControl.ContentProperty)); - presenter.Bind(ContentPresenter.HorizontalContentAlignmentProperty, new TemplateBinding(ContentControl.HorizontalContentAlignmentProperty)); - presenter.Bind(ContentPresenter.VerticalContentAlignmentProperty, new TemplateBinding(ContentControl.VerticalContentAlignmentProperty)); + presenter[!ContentPresenter.ContentProperty] = new TemplateBinding(ContentControl.ContentProperty); + presenter[!ContentPresenter.HorizontalContentAlignmentProperty] = new TemplateBinding(ContentControl.HorizontalContentAlignmentProperty); + presenter[!ContentPresenter.VerticalContentAlignmentProperty] = new TemplateBinding(ContentControl.VerticalContentAlignmentProperty); border.Child = presenter; return border; - }) + })) + } }; Subscribe(baseBgKey, v => _baseBg = v); @@ -974,17 +2073,16 @@ public PanelButton(string baseBgKey, string hoverBgKey, string pressedBgKey, str PointerReleased += (_, _) => { _pressed = false; ApplyState(); }; PointerCaptureLost += (_, _) => { _pressed = false; ApplyState(); }; - GetObservable(IsEnabledProperty).Subscribe(new BoolObserver(_ => ApplyState())); - GetObservable(IsKeyboardFocusWithinProperty).Subscribe(new BoolObserver(v => { _focused = v; ApplyState(); })); + this.GetObservable(IsEnabledProperty).Subscribe(new BoolObserver(_ => ApplyState())); + this.GetObservable(IsKeyboardFocusWithinProperty).Subscribe(new BoolObserver(v => { _focused = v; ApplyState(); })); ApplyState(); } private void Subscribe(string key, Action setter) - { - if (Application.Current is not { } app) - return; - _subs.Add(app.GetResourceObservable(key).Subscribe(new BrushSlot(setter, ApplyState))); - } + // Подписка снимается вместе с уходом кнопки из дерева: список + // _subs не освобождался нигде, и каждая пересборка правой панели + // оставляла кнопку и всё её дерево укоренёнными. + => ThemeBrushes.Observe(this, key, brush => { setter(brush); ApplyState(); }); /// Применяет состояние к фону/границе/прозрачности кнопки. private void ApplyState() @@ -1013,27 +2111,6 @@ private void ApplyState() } } - /// Передаёт текущее значение ресурса-кисти в слот и перерисовывает состояние. - private sealed class BrushSlot : IObserver - { - private readonly Action _setter; - private readonly Action _onChanged; - - public BrushSlot(Action setter, Action onChanged) - { - _setter = setter; - _onChanged = onChanged; - } - - public void OnCompleted() { } - public void OnError(Exception error) { } - public void OnNext(object? value) - { - if (value is IBrush brush) - _setter(brush); - _onChanged(); - } - } } @@ -1081,7 +2158,6 @@ public void OnError(Exception error) { } /// private sealed class SegmentButton : ToggleButton { - private readonly List _subs = new(); private readonly string _iconKey; private readonly string _text; private readonly double _iconSize; @@ -1109,25 +2185,31 @@ public SegmentButton(string iconKey, string text, string hoverBgKey, string pres Cursor = new Cursor(StandardCursorType.Hand); MinHeight = 30; Padding = new Thickness(12, 5); - BorderThickness = new Thickness(0); + BorderThickness = new Thickness(2); + BorderBrush = Brushes.Transparent; // Кастомный шаблон: скруглённый Border + ContentPresenter (без Fluent-хрома). Theme = new ControlTheme(typeof(ToggleButton)) { - Template = new FuncControlTemplate((_, _) => + Setters = + { + new Setter(TemplatedControl.TemplateProperty, new FuncControlTemplate((_, _) => { var border = new Border { CornerRadius = new CornerRadius(UiMetrics.RadiusSm), BorderThickness = new Thickness(0) }; - border.Bind(Border.BackgroundProperty, new TemplateBinding(Control.BackgroundProperty)); - border.Bind(Border.BorderBrushProperty, new TemplateBinding(Control.BorderBrushProperty)); - border.Bind(Border.BorderThicknessProperty, new TemplateBinding(Control.BorderThicknessProperty)); + border[!Border.BackgroundProperty] = new TemplateBinding(TemplatedControl.BackgroundProperty); + border[!Border.BorderBrushProperty] = new TemplateBinding(TemplatedControl.BorderBrushProperty); + border[!Border.BorderThicknessProperty] = new TemplateBinding(TemplatedControl.BorderThicknessProperty); + // Без этого фон измеряется ровно по содержимому и обрезает текст. + border[!Border.PaddingProperty] = new TemplateBinding(TemplatedControl.PaddingProperty); UiMetrics.AddBrushTransition(border); var presenter = new ContentPresenter(); - presenter.Bind(ContentPresenter.ContentProperty, new TemplateBinding(ContentControl.ContentProperty)); - presenter.Bind(ContentPresenter.HorizontalContentAlignmentProperty, new TemplateBinding(ContentControl.HorizontalContentAlignmentProperty)); - presenter.Bind(ContentPresenter.VerticalContentAlignmentProperty, new TemplateBinding(ContentControl.VerticalContentAlignmentProperty)); + presenter[!ContentPresenter.ContentProperty] = new TemplateBinding(ContentControl.ContentProperty); + presenter[!ContentPresenter.HorizontalContentAlignmentProperty] = new TemplateBinding(ContentControl.HorizontalContentAlignmentProperty); + presenter[!ContentPresenter.VerticalContentAlignmentProperty] = new TemplateBinding(ContentControl.VerticalContentAlignmentProperty); border.Child = presenter; return border; - }) + })) + } }; Subscribe(hoverBgKey, v => _hoverBg = v); @@ -1142,28 +2224,28 @@ public SegmentButton(string iconKey, string text, string hoverBgKey, string pres PointerReleased += (_, _) => { _pressed = false; ApplyState(); }; PointerCaptureLost += (_, _) => { _pressed = false; ApplyState(); }; - GetObservable(IsCheckedProperty).Subscribe(new BoolObserver(_ => { UpdateContent(); ApplyState(); })); - GetObservable(IsEnabledProperty).Subscribe(new BoolObserver(_ => ApplyState())); - GetObservable(IsKeyboardFocusWithinProperty).Subscribe(new BoolObserver(v => { _focused = v; ApplyState(); })); + this.GetObservable(IsCheckedProperty, v => v == true).Subscribe(new BoolObserver(_ => { UpdateContent(); ApplyState(); })); + this.GetObservable(IsEnabledProperty).Subscribe(new BoolObserver(_ => ApplyState())); + this.GetObservable(IsKeyboardFocusWithinProperty).Subscribe(new BoolObserver(v => { _focused = v; ApplyState(); })); UpdateContent(); ApplyState(); } /// Не даём снимать уже активный сегмент (как RadioButton), когда это требуется. - protected override void OnToggle() + protected override void Toggle() { if (_lockOn && IsChecked == true) return; - base.OnToggle(); + base.Toggle(); } private void Subscribe(string key, Action setter) - { - if (Application.Current is not { } app) - return; - _subs.Add(app.GetResourceObservable(key).Subscribe(new BrushObserver(setter, ApplyState))); - } + // Подписка снимается вместе с уходом кнопки из дерева: список + // освобождался только у кнопок тегов, поэтому пять сегментов + // верхней панели держали прежнее дерево окна после каждой + // пересборки содержимого. + => ThemeBrushes.Observe(this, key, brush => { setter(brush); ApplyState(); }); /// Собирает содержимое «иконка + текст» с цветом по состоянию выбора. private void UpdateContent() @@ -1175,7 +2257,10 @@ private void UpdateContent() Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; - sp.Children.Add(IconHelper.MakeIcon(_iconKey, _iconSize, brushKey)); + // Пустой ключ означает кнопку без иконки: IconHelper на пустой ключ + // подставляет запасную папку, и она выглядела бы как настоящая иконка. + if (!string.IsNullOrEmpty(_iconKey)) + sp.Children.Add(IconHelper.MakeIcon(_iconKey, _iconSize, brushKey)); if (!string.IsNullOrEmpty(_text)) { var tb = new TextBlock @@ -1208,181 +2293,1543 @@ private void ApplyState() else Background = _pressed ? _pressedBg : (_hovered ? _hoverBg : Brushes.Transparent); - if (_focused) - { - BorderBrush = _accent; - BorderThickness = new Thickness(2); - } - else - { - BorderBrush = Brushes.Transparent; - BorderThickness = new Thickness(0); - } + // Толщина постоянна, меняется только цвет: иначе фокус + // расширял бы кнопку на четыре пикселя, а по её правому краю + // выравнивается подпись «Название» в шапке списка. + BorderBrush = _focused ? _accent : Brushes.Transparent; } } - private Control BuildStatusBar() - { - var grid = new Grid { Padding = new Thickness(UiMetrics.TopBarH, 6) }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + /// Описание колонки списка баз: ключ, заголовок, ширина. + private readonly record struct ListColumn(string Key, string Header, double Width); - _statusInfo = new TextBlock { FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; - _statusInfo.Bind(TextBlock.TextProperty, new Binding("StatusBarInfo")); - grid.Children.Add(_statusInfo); - Grid.SetColumn(_statusInfo, 0); + /// Минимум под имя базы: колонка звёздная, но схлопываться ей нельзя. + private const double NameColumnMinWidth = 220; - _syncMessage = new TextBlock { FontSize = 12, Margin = new Thickness(16, 0, 8, 0), VerticalAlignment = VerticalAlignment.Center }; - _syncMessage.Bind(TextBlock.TextProperty, new Binding("SyncMessage")); - grid.Children.Add(_syncMessage); - Grid.SetColumn(_syncMessage, 1); + /// Ширина колонки звезды «избранное» в заголовке и в строке базы. + private static double FavoriteColumnWidth => UiMetrics.Scaled(26); - var toggleBtn = new Button { Content = IconHelper.MakeIcon("IconPanel", 16), ToolTip = new ToolTip { Content = LocalizationManager.T("Main.RightPanel") }, Margin = new Thickness(4, 0, 0, 0) }; - toggleBtn.Bind(Button.CommandProperty, new Binding("ToggleRightPanelDetailsCommand")); - grid.Children.Add(toggleBtn); - Grid.SetColumn(toggleBtn, 2); + /// Ширина колонки булавки «закреплено» в заголовке и в строке базы. + private static double PinColumnWidth => UiMetrics.Scaled(24); - return new Border { Child = grid, Name = "StatusBarBorder" }; - } + /// Ширина фиксированной колонки «Действия» в заголовке и в строке базы. + private static double ActionsColumnWidth => UiMetrics.Scaled(170); - // ======================= Обработчики ======================= + /// + /// Ширина колонки иконки базы: сама иконка и её правый отступ. В заголовке + /// эта колонка пустая, но она есть, иначе подпись «Название» стояла бы + /// левее имён строк на ширину иконки. + /// + private static double IconColumnWidth => UiMetrics.RowIconBox + 10; - private void OnWindowLoaded(object? sender, RoutedEventArgs e) - { - _vm?.Initialize(); - SetupTray(); - } + /// Ширина одной кнопки панели инструментов над списком. + private static double ToolbarButtonWidth => UiMetrics.Scaled(24); + + /// Ширина блока кнопок групп: четыре кнопки с промежутками. + private static double GroupToolbarWidth => ToolbarButtonWidth * 4 + 6 + UiMetrics.Scaled(6); /// - /// Применяет компактный режим интерфейса: пересобирает главное окно с уменьшенными - /// отступами, иконками и расстояниями. Вызывается из окна настроек при переключении. + /// Расчётная ширина переключателя тегов: он сделан сегментной кнопкой + /// с горизонтальным отступом 12, рамкой 2 и иконкой 15, то есть + /// заметно шире обычной кнопки панели. /// - public void ApplyCompactMode(bool compact) - { - UiMetrics.Compact = compact; - Content = BuildRoot(); - } - - private void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (_vm is null) - return; - var selected = _tree.SelectedItem; - switch (selected) - { - case Infobase ib: - _vm.SelectedInfobase = ib; - _vm.SelectedGroupNode = null; - break; - case GroupNodeViewModel g: - _vm.SelectedGroupNode = g; - _vm.SelectedInfobase = null; - break; - default: - break; - } - } + private const double TagsToggleWidth = 12 * 2 + 2 * 2 + 15; - // ======================= Трей ======================= + /// Зазор между блоком кнопок и подписью «Название». + private const double HeaderToolbarGap = 2; - private void SetupTray() - { - try - { - var menu = new NativeMenu(); + /// + /// Номер колонки заголовка с именем базы: компенсатор отступа дерева, + /// звезда, булавка, иконка. + /// + private const int NameHeaderColumn = 4; - var showItem = new NativeMenuItem(LocalizationManager.T("Main.ShowWindow")); - showItem.Click += (_, _) => ShowAndActivate(); - menu.Add(showItem); - menu.Add(new NativeMenuItemSeparator()); + /// Номер колонки заголовка с пометкой закрепления. + private const int PinHeaderColumn = NameHeaderColumn - 2; - // Недавние базы (быстрый запуск прямо из трея). - var recent = _vm?.RecentInfobases; - if (recent is { Count: > 0 }) - { - var recentMenu = new NativeMenu(); - foreach (var ib in recent) - { - var item = new NativeMenuItem($"{ib.Name} ({ib.ServerDatabaseDisplay})"); - var baseRef = ib; - item.Click += (_, _) => LaunchInfobase(baseRef); - recentMenu.Add(item); - } - menu.Add(new NativeMenuItem(LocalizationManager.T("Main.RecentBases")) { Menu = recentMenu }); - menu.Add(new NativeMenuItemSeparator()); - } + /// Номер колонки строки с именем базы: звезда, булавка, иконка. + private const int NameRowColumn = 3; - // Запуск выбранной базы: Предприятие / Конфигуратор. - if (_vm?.SelectedInfobase is { } sel) - { - var ent = new NativeMenuItem($"{LocalizationManager.T("Main.LaunchEnterprise")}: {sel.Name}"); - ent.Click += (_, _) => _vm.LaunchEnterpriseCommand.Execute(null); - menu.Add(ent); - - var cfg = new NativeMenuItem($"{LocalizationManager.T("Main.LaunchConfigurator")}: {sel.Name}"); - cfg.Click += (_, _) => _vm.LaunchConfiguratorCommand.Execute(null); - menu.Add(cfg); - menu.Add(new NativeMenuItemSeparator()); - } + /// Минимальная ширина колонки при перетаскивании разделителя. + private const double MinColumnWidth = 40; - // Синхронизация и настройки. - var sync = new NativeMenuItem(LocalizationManager.T("Main.SyncWithIbases")); - sync.Click += (_, _) => _vm?.SynchronizeWithIbasesCommand.Execute(null); - menu.Add(sync); + /// Ширина зоны захвата разделителя колонок. + private const double ResizeGripWidth = 8; - var settings = new NativeMenuItem(LocalizationManager.T("Main.Settings")); - settings.Click += (_, _) => _vm?.OpenSettingsCommand.Execute(null); - menu.Add(settings); - menu.Add(new NativeMenuItemSeparator()); + /// + /// Ширина колонки имени: пока её не тянули за разделитель, колонка + /// звёздная и занимает остаток, после перетаскивания становится заданной. + /// + private GridLength NameColumnLength() + { + var width = _vm?.NameColumnWidth ?? 0; + return width > 0 ? new GridLength(width) : new GridLength(1, GridUnitType.Star); + } - // Выход: разрешаем реальное закрытие и завершаем приложение. - var exitItem = new NativeMenuItem(LocalizationManager.T("Main.Exit")); - exitItem.Click += (_, _) => - { - _allowCloseToTray = false; - _vm?.ExitCommand.Execute(null); - }; - menu.Add(exitItem); + /// + /// Колонки списка в порядке отображения, кроме первой (имя базы), + /// которая занимает оставшееся место. Состав и ширины берутся + /// из настроек, поэтому заголовок и строки всегда согласованы. + /// + private List ListColumns() + { + var columns = new List(); + if (_vm is null) + return columns; - var tray = new TrayIcon - { - Icon = LoadTrayIcon(), - ToolTipText = LocalizationManager.T("App.Title"), - Menu = menu - }; - TrayIcon.SetIcons(this, new TrayIcons { tray }); + // Ширина из настроек, а при нуле (настройка ещё не трогалась) свой + // разумный размер под содержимое колонки. + void Add(bool visible, string key, string header, double width, double fallback) + { + if (visible) + columns.Add(new ListColumn(key, LocalizationManager.T(header), width > 0 ? width : fallback)); } - catch + + // Порядок колонок берётся из настроек; неизвестные ключи пропускаются, + // поэтому пользовательский список не ломает сборку при изменении состава. + foreach (var key in _vm.ColumnOrderKeys) { - // Трей не обязателен для работы окна; игнорируем ошибки инициализации. - // Примечание: на GNOME Shell без AppIndicator трей Avalonia может не отображаться — - // это ограничение DE, окно продолжает работать обычным образом. + switch (key) + { + case "Version": + Add(_vm.ShowVersionColumn, "Version", "Column.Version", _vm.VersionColumnWidth, 95); + break; + case "Configuration": + Add(_vm.ShowConfigurationColumn, "Configuration", "Column.Configuration", _vm.ConfigurationColumnWidth, 140); + break; + case "LaunchMode": + Add(_vm.ShowLaunchModeColumn, "LaunchMode", "Column.LaunchMode", _vm.LaunchModeColumnWidth, 115); + break; + case "ServerBase": + Add(_vm.ShowServerColumn, "ServerBase", "Column.ServerBase", _vm.ServerColumnWidth, 140); + break; + case "LastLaunch": + Add(_vm.ShowLastLaunchColumn, "LastLaunch", "Column.LastLaunch", _vm.LastLaunchColumnWidth, 115); + break; + case "Size": + Add(_vm.ShowSizeColumn, "Size", "Column.Size", _vm.SizeColumnWidth, 65); + break; + } } + return columns; } - /// Запускает базу из меню трея (Предприятие). - private void LaunchInfobase(Infobase ib) + /// + /// Сколько колонок данных стоит до колонки «Действия»: она встаёт сразу + /// после колонки «Режим запуска» (или в самый конец, если та скрыта или + /// отсутствует в текущем порядке). + /// + private static int ActionsOffsetInColumns(List columns) { - if (_vm is null) - return; - _vm.SelectedInfobase = ib; - _vm.LaunchEnterpriseCommand.Execute(null); + for (var i = 0; i < columns.Count; i++) + if (columns[i].Key == "LaunchMode") + return i + 1; + return columns.Count; } + /// Значение колонки для конкретной базы. + private static string ColumnValue(Infobase ib, string key) => key switch + { + "Version" => ib.PlatformVersion ?? string.Empty, + "Configuration" => ib.ConfigurationDisplay ?? string.Empty, + "LaunchMode" => ib.LaunchMode ?? string.Empty, + "ServerBase" => ib.Connection.Type == ConnectionType.WebServer + ? (ib.Connection.WebUrl ?? string.Empty) + : (ib.ServerDatabaseDisplay ?? string.Empty), + "LastLaunch" => ib.LastLaunchDisplay ?? string.Empty, + "Size" => ib.FileSizeDisplay ?? string.Empty, + _ => string.Empty + }; + /// - /// Загружает иконку трея без System.Drawing — из PNG/ICO на диске либо из - /// встроенного ресурса (tray_icon_preview.png), через Avalonia WindowIcon. + /// Строка заголовков колонок над списком. Пересобирается вместе + /// со списком, чтобы состав колонок совпадал со строками. /// - private static WindowIcon? LoadTrayIcon() + private Control BuildColumnHeader() { - try + _columnHeaderRow = new Grid(); + _columnHeader = new Border { - foreach (var name in new[] { "tray_icon_preview.png", "app_icon_preview.png", "app.ico", "tray.ico" }) + // Отступы совпадают с карточкой строки: колонки в обеих сетках + // прижаты вправо, поэтому заголовки встают над значениями только + // при одинаковом правом отступе. + Padding = new Thickness(UiMetrics.PaddingControl, 4), + BorderThickness = new Thickness(0, 0, 0, 1), + Child = _columnHeaderRow + }; + ThemeBrushes.Bind(_columnHeader, Border.BorderBrushProperty, "BorderColorBrush"); + return _columnHeader; + } + + /// + /// Ставит пересборку заголовка в очередь диспетчера. Настройки колонок + /// уведомляют о шестнадцати свойствах подряд, и без склейки заголовок + /// пересобирался бы на каждое из них. + /// + private void QueueColumnHeaderRefresh() + { + if (_columnHeaderRefreshQueued) + return; + _columnHeaderRefreshQueued = true; + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + _columnHeaderRefreshQueued = false; + RefreshColumnHeader(); + }); + } + + /// + /// Пересобирает панель инструментов и заголовки колонок по текущим настройкам. + /// Слева направо: кнопки групп, компенсатор отступа дерева, звезда, булавка, + /// имя базы, дальше колонки значений. Первые колонки повторяются в строке + /// базы теми же ширинами, поэтому заголовок и значения стоят друг под другом. + /// + private void RefreshColumnHeader() + { + if (_vm is null || _columnHeaderRow is null || _columnHeader is null) + return; + + _columnHeaderRow.Children.Clear(); + _columnHeaderRow.ColumnDefinitions.Clear(); + + var columns = ListColumns(); + // Ширина блока кнопок нужна, чтобы подпись «Название» встала правее + // него. Здесь она только расчётная: панель ещё не создана, а Bounds + // прежней панели относятся к прежнему составу кнопок. Измеренную + // ширину подставляет AlignHeaderToRows, когда панель разложена. + _headerToolbarWidth = (_vm.ShowExpandCollapseButtons ? GroupToolbarWidth : 0) + + TagsToggleWidth + HeaderToolbarGap; + var favoriteWidth = _vm.ShowFavoritesButton ? FavoriteColumnWidth : 0; + var pinWidth = _vm.ShowPinnedButton ? PinColumnWidth : 0; + + _headerOffsetColumn = new ColumnDefinition { Width = new GridLength(0) }; + _columnHeaderRow.ColumnDefinitions.Add(_headerOffsetColumn); + _columnHeaderRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(favoriteWidth) }); + _columnHeaderRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(pinWidth) }); + _columnHeaderRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(IconColumnWidth) }); + _columnHeaderRow.ColumnDefinitions.Add( + new ColumnDefinition { Width = NameColumnLength(), MinWidth = MinColumnWidth }); + // Колонка «Действия» встаёт сразу после колонки «Режим запуска», поэтому + // её определение встраивается в последовательность, а не добавляется в конец. + var actionsOffset = ActionsOffsetInColumns(columns); + for (var i = 0; i < columns.Count; i++) + { + if (i == actionsOffset) + _columnHeaderRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(ActionsColumnWidth) }); + _columnHeaderRow.ColumnDefinitions.Add( + new ColumnDefinition { Width = new GridLength(columns[i].Width), MinWidth = MinColumnWidth }); + } + // «Режим запуска» скрыт или стоит последним — действия уходят в самый конец. + if (actionsOffset >= columns.Count) + _columnHeaderRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(ActionsColumnWidth) }); + + _headerPinMark = null; + if (_vm.ShowPinnedButton) + { + // Значок закрепления в заголовке — только пометка колонки, как в WPF. + var pinMark = IconHelper.MakeIcon("IconPin", UiMetrics.Scaled(13), "TextSecondaryBrush"); + ToolTip.SetTip(pinMark, LocalizationManager.T("Main.Pinned")); + _columnHeaderRow.Children.Add(pinMark); + Grid.SetColumn(pinMark, PinHeaderColumn); + _headerPinMark = pinMark; + } + + // Кнопки лежат поверх компенсатора и пустых колонок звезды, + // булавки и иконки: своя колонка сдвинула бы подписи вправо + // от значений, а колонки заголовка тут ничего не показывают. + var tools = BuildGroupToolbar(); + _columnHeaderRow.Children.Add(tools); + Grid.SetColumn(tools, 0); + Grid.SetColumnSpan(tools, NameHeaderColumn); + tools.ZIndex = 1; + + var nameHeader = HeaderText(LocalizationManager.T("Column.Name")); + MakeSortableHeader(nameHeader, "Name", LocalizationManager.T("Main.ColumnNameSortTooltip")); + _columnHeaderRow.Children.Add(nameHeader); + Grid.SetColumn(nameHeader, NameHeaderColumn); + + _headerColumnIndex.Clear(); + _headerColumnIndex["Name"] = NameHeaderColumn; + + var nameGrip = BuildResizeGrip("Name", NameHeaderColumn); + _columnHeaderRow.Children.Add(nameGrip); + + // Заголовки идут по порядку колонок данных, перескакивая колонку + // «Действия», встроенную после «Режима запуска». + var dataColumn = NameHeaderColumn + 1; + for (var i = 0; i < columns.Count; i++) + { + if (i == actionsOffset) + dataColumn++; + _headerColumnIndex[columns[i].Key] = dataColumn; + + var text = HeaderText(columns[i].Header); + if (columns[i].Key == "LastLaunch") + MakeSortableHeader(text, "LastLaunchDate", LocalizationManager.T("Main.ColumnLastLaunchSortTooltip")); + _columnHeaderRow.Children.Add(text); + Grid.SetColumn(text, dataColumn); + + var grip = BuildResizeGrip(columns[i].Key, dataColumn); + _columnHeaderRow.Children.Add(grip); + dataColumn++; + } + + // Подпись колонки «Действия» — сразу после колонки «Режим запуска», + // без разделителя. + var actionsHeader = HeaderText(LocalizationManager.T("Column.Actions")); + _columnHeaderRow.Children.Add(actionsHeader); + Grid.SetColumn(actionsHeader, NameHeaderColumn + 1 + actionsOffset); + + UpdateListMinWidth(); + + QueueHeaderAlign(); + } + + /// + /// Измеренная ширина блока кнопок; null, пока он не разложен. + /// Панель зажата в узкие колонки заголовка, поэтому её собственный + /// Bounds обрезан по ним, а кнопки рисуются дальше: ширину берём + /// по самому правому краю содержимого. + /// + private double? MeasuredToolbarWidth + { + get + { + if (_groupToolbar is null) + return null; + var right = 0d; + foreach (var child in _groupToolbar.Children) + right = Math.Max(right, child.Bounds.Right); + return right > 0 ? right : null; + } + } + + /// + /// Блок кнопок над списком: развернуть и свернуть все группы и две + /// сортировки групп (только при группировке), а также переключатель + /// тегов в строках, который нужен всегда. + /// + private Control BuildGroupToolbar() + { + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 2, + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Left + }; + + if (_vm?.ShowExpandCollapseButtons == true) + { + panel.Children.Add(HeaderIconButton("IconExpandAll", + LocalizationManager.T("Main.ExpandAllGroups"), "ExpandAllGroupsCommand")); + panel.Children.Add(HeaderIconButton("IconCollapseAll", + LocalizationManager.T("Main.CollapseAllGroups"), "CollapseAllGroupsCommand")); + panel.Children.Add(HeaderIconButton("IconSortAscending", + LocalizationManager.T("Main.SortGroupsAscending"), "SortGroupsAscendingCommand")); + panel.Children.Add(HeaderIconButton("IconSortDescending", + LocalizationManager.T("Main.SortGroupsDescending"), "SortGroupsDescendingCommand")); + } + + var tagsToggle = BuildTagsInListToggle(); + panel.Children.Add(tagsToggle); + + _groupToolbar = panel; + // Ширина известна только после раскладки, а подпись выравнивается + // по ней. Следим за переключателем: он всегда крайний справа, + // поэтому именно он задаёт правый край блока. Bounds самой панели + // обрезаны колонками заголовка и на измерение не влияют. + _toolbarWidthLink?.Dispose(); + _toolbarWidthLink = tagsToggle.GetObservable(Visual.BoundsProperty) + .Subscribe(new PropertyObserver(_ => QueueHeaderAlign())); + return panel; + } + + /// + /// Переключатель показа тегов в строках списка. Сделан тем же + /// сегментным контролом, что и переключатели верхней панели: у Fluent + /// в нажатом состоянии свой синий фон, чужой для этой темы. + /// + private Control BuildTagsInListToggle() + { + var toggle = MakeSegmentToggle("IconTag", LocalizationManager.T("Main.ToggleListTags")); + toggle.IsChecked = _vm?.ShowTags ?? false; + toggle.VerticalAlignment = VerticalAlignment.Center; + toggle.Click += (_, _) => + { + if (_vm is not null) + _vm.ShowTags = toggle.IsChecked == true; + }; + return toggle; + } + + /// Компактная иконко-кнопка панели инструментов над списком. + private Button HeaderIconButton(string iconKey, string tooltip, string commandPath) + { + var button = new Button + { + Content = IconHelper.MakeIcon(iconKey, UiMetrics.Scaled(14), "TextSecondaryBrush"), + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(2, 0), + MinWidth = 0, + MinHeight = 0, + Width = ToolbarButtonWidth, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand) + }; + ToolTip.SetTip(button, tooltip); + button.Bind(Button.CommandProperty, new Binding(commandPath)); + return button; + } + + /// + /// Зона захвата у правого края колонки заголовка: тонкая линия по центру + /// и широкая невидимая полоса вокруг неё, иначе в разделитель трудно попасть. + /// + private Border BuildResizeGrip(string key, int column) + { + var line = new Border + { + Width = 1, + HorizontalAlignment = HorizontalAlignment.Center, + Margin = new Thickness(0, 2, 0, 4), + Opacity = 0.55 + }; + ThemeBrushes.Bind(line, Border.BackgroundProperty, "BorderColorBrush"); + + var grip = new Border + { + Width = ResizeGripWidth, + HorizontalAlignment = HorizontalAlignment.Right, + VerticalAlignment = VerticalAlignment.Stretch, + Background = Brushes.Transparent, + ZIndex = 2, + Cursor = new Cursor(StandardCursorType.SizeWestEast), + Tag = key, + Child = line + }; + ToolTip.SetTip(grip, LocalizationManager.T("Main.ResizeColumnTooltip")); + Grid.SetColumn(grip, column); + grip.PointerPressed += OnColumnResizePressed; + grip.PointerMoved += OnColumnResizeMoved; + grip.PointerReleased += OnColumnResizeReleased; + // Захват теряется не только отпусканием кнопки: его снимает и оконная + // система, и пересборка окна в компактном режиме. Без этого обработчика + // перетаскивание осталось бы незавершённым, а ширина несохранённой. + grip.PointerCaptureLost += OnColumnResizeCaptureLost; + return grip; + } + + /// Формат содержимого перетаскивания: сама нагрузка живёт в поле окна. + private const string DragPayloadMarker = "ConfigurationManagement.Row"; + + /// + /// Фиксация того, что поедет: как в WPF, нагрузка берётся в нажатии, + /// а не в движении. Иначе при сдвиге курсора на дочернюю строку под ним + /// оказывается другой узел и вместо группы уезжает база. + /// + private void OnTreeDragPointerPressed(object? sender, PointerPressedEventArgs e) + { + _dragPayload = null; + var point = e.GetCurrentPoint(this); + if (!point.Properties.IsLeftButtonPressed) + return; + + _dragStartPoint = point.Position; + + // Клик по кнопке или полю ввода внутри строки перетаскивание не начинает: + // звезда, булавка, чип тега и раскрытие группы должны работать как обычно. + if (e.Source is not Visual source + || source.FindAncestorOfType + + @@ -290,10 +301,11 @@ ToolTip="{loc:Loc Main.SyncDetailedTooltip}"> - + + @@ -393,7 +408,7 @@ Command="{Binding DataContext.SearchByTagCommand, RelativeSource={RelativeSource AncestorType=Window}}" CommandParameter="{Binding Name}"> - + @@ -491,14 +506,6 @@ - - - - - - - - @@ -507,6 +514,16 @@ + + + + + + + @@ -531,57 +548,73 @@ + + + + + + + + + - - + + + + + + + + - - - - - - - - + @@ -609,6 +642,10 @@ Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" HorizontalAlignment="Stretch" TextTrimming="CharacterEllipsis" Margin="6,0,6,4" ToolTip="{loc:Loc Main.ColumnSizeTooltip}"/> + - @@ -646,11 +683,10 @@ Background="{DynamicResource BorderBrushColor}" Opacity="0.55" Margin="0,2,0,4"/> - @@ -694,6 +730,18 @@ Background="{DynamicResource BorderBrushColor}" Opacity="0.55" Margin="0,2,0,4"/> + + + @@ -741,23 +789,29 @@ - + + поэтому GroupOffset добавляет такой же отступ, чтобы иерархия сохранялась. + Третье значение MultiBinding — флаг компактного режима: в нём вертикальный + зазор между группами убирается (см. GroupOffsetConverter). --> + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -803,11 +989,15 @@ - + - + + @@ -826,18 +1016,16 @@ - - - + + + + - - - - + @@ -864,6 +1052,15 @@ + + + + + + + + + @@ -872,7 +1069,7 @@ - @@ -969,14 +1166,14 @@ TextTrimming="CharacterEllipsis"/> - - @@ -985,20 +1182,60 @@ Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" HorizontalAlignment="Left" Margin="6,0,6,0" TextTrimming="CharacterEllipsis"/> - - - + + + + + + + + + - @@ -1272,9 +1509,22 @@ - - - + + + + + + + + + @@ -1319,15 +1569,17 @@ HorizontalAlignment="Left"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/Configuration Management/Themes/Icons.axaml b/Configuration Management/Themes/Icons.axaml index 56141385..497ca6e5 100644 --- a/Configuration Management/Themes/Icons.axaml +++ b/Configuration Management/Themes/Icons.axaml @@ -23,6 +23,9 @@ M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.73C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z + + M12,2 A10,10 0 1 1 12,22 A10,10 0 1 1 12,2 Z M12,7.5 A4.5,4.5 0 1 0 12,16.5 A4.5,4.5 0 1 0 12,7.5 Z M12,12 L18.8,6 L19.6,7.4 Z M17,6.5 A1.5,1.5 0 1 1 17,9.5 A1.5,1.5 0 1 1 17,6.5 Z + M8,5.14V19.14L19,12.14L8,5.14Z @@ -179,4 +182,17 @@ M19,3H14V5H19V18L14,12V21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10,18H5L10,12M10,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H10V23H12V1H10V3Z - \ No newline at end of file + + + M7.41,5.59L12,10.17L16.59,5.59L18,7L12,13L6,7L7.41,5.59ZM7.41,11.59L12,16.17L16.59,11.59L18,13L12,19L6,13L7.41,11.59Z + + + M7.41,18.41L12,13.83L16.59,18.41L18,17L12,11L6,17L7.41,18.41ZM7.41,12.41L12,7.83L16.59,12.41L18,11L12,5L6,11L7.41,12.41Z + + + M19,17H22L18,21L14,17H17V3H19M2,17H12V19H2M6,5V7H2V5M2,11H9V13H2V11Z + + + M19,7H22L18,3L14,7H17V21H19M2,17H12V19H2M6,5V7H2V5M2,11H9V13H2V11Z + + diff --git a/Configuration Management/Themes/Icons.xaml b/Configuration Management/Themes/Icons.xaml index e6ab89c3..85ae8d64 100644 --- a/Configuration Management/Themes/Icons.xaml +++ b/Configuration Management/Themes/Icons.xaml @@ -30,6 +30,9 @@ M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.73C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z + + M12,2 A10,10 0 1 1 12,22 A10,10 0 1 1 12,2 Z M12,7.5 A4.5,4.5 0 1 0 12,16.5 A4.5,4.5 0 1 0 12,7.5 Z M12,12 L18.8,6 L19.6,7.4 Z M17,6.5 A1.5,1.5 0 1 1 17,9.5 A1.5,1.5 0 1 1 17,6.5 Z + M8,5.14V19.14L19,12.14L8,5.14Z diff --git a/Configuration Management/Themes/LightTheme.xaml b/Configuration Management/Themes/LightTheme.xaml index 5547a326..329f8fda 100644 --- a/Configuration Management/Themes/LightTheme.xaml +++ b/Configuration Management/Themes/LightTheme.xaml @@ -668,8 +668,9 @@ diff --git a/Configuration Management/Themes/ModernTheme.xaml b/Configuration Management/Themes/ModernTheme.xaml deleted file mode 100644 index cac031f9..00000000 --- a/Configuration Management/Themes/ModernTheme.xaml +++ /dev/null @@ -1,692 +0,0 @@ - - - - #FDBF00 - #E0A800 - #C49400 - #1E293B - #273549 - #FDBF00 - #F8FAFC - #FFFFFF - #E2E8F0 - #000000 - #64748B - #FFFFFF - #000000 - #F59E0B - - - #F1F5F9 - #E2E8F0 - #F1F5F9 - #E2E8F0 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/Configuration Management/Themes/ThemeBrushes.Avalonia.cs b/Configuration Management/Themes/ThemeBrushes.Avalonia.cs index 4eaaf78c..0af4d4ec 100644 --- a/Configuration Management/Themes/ThemeBrushes.Avalonia.cs +++ b/Configuration Management/Themes/ThemeBrushes.Avalonia.cs @@ -1,54 +1,92 @@ #if LINUX -using System; using Avalonia; -using Avalonia.Media; +using Avalonia.Controls; +using Avalonia.VisualTree; namespace Configuration_Management.Themes { /// - /// Вспомогательный класс для привязки кистей-ресурсов темы к свойствам элементов, - /// собираемых в коде (без XAML). Ресурсы-кисти создаются ThemeManager.ApplyScheme как - /// Application.Resources (ключи вида TextPrimaryColorBrush, CardBackgroundColorBrush - /// и т.п.). Подписка через GetResourceObservable обеспечивает автоматическую перекраску - /// элемента при переключении светлой/тёмной схемы (аналогично IconHelper). + /// Привязка кистей-ресурсов темы к свойствам элементов, собираемых в коде + /// (без XAML). Сами ресурсы кладёт ThemeManager.ApplyScheme в + /// Application.Resources под ключами вида TextPrimaryColorBrush + /// и CardBackgroundColorBrush. /// public static class ThemeBrushes { /// - /// Подписывает свойство-кисть целевого элемента на ресурс-кисть темы. - /// При каждом обновлении ресурса (смена темы/схемы) свойство обновляется. + /// Привязывает свойство-кисть элемента к ресурсу темы. При смене темы или + /// цветовой схемы значение обновляется само. /// /// Элемент, у которого меняется кисть. - /// Свойство типа IBrush (Background/Foreground/BorderBrush и т.п.). - /// Ключ ресурса-кисти темы (например "CardBackgroundColorBrush"). - public static void Bind(AvaloniaObject target, AvaloniaProperty property, string brushKey) - { - var app = Application.Current; - if (app is null) - return; - app.GetResourceObservable(brushKey).Subscribe(new ResourceBrushObserver(target, property)); - } + /// Свойство типа IBrush (Background, Foreground, BorderBrush). + /// Ключ ресурса-кисти темы, например "CardBackgroundColorBrush". + /// + /// Привязку отслеживает сам элемент, освобождать её не нужно и нельзя: + /// освобождение снимает окраску, а не подписку. Ресурс ищется по цепочке + /// логических родителей до окна, поэтому элемент, который так и не попал + /// в содержимое окна, останется неокрашенным молча. Ловушки такого рода: + /// ToolTip.Tip и MenuItem.Icon у неоткрытого меню. + /// + public static void Bind(StyledElement target, AvaloniaProperty property, string brushKey) + => target.Bind(property, new Avalonia.Markup.Xaml.MarkupExtensions.DynamicResourceExtension(brushKey)); - /// Наблюдатель, переносящий текущее значение ресурса-кисти в свойство элемента. - private sealed class ResourceBrushObserver : IObserver + /// + /// Отдаёт кисть темы в код и обновляет её при смене темы или схемы. + /// Нужно там, где кисть не ложится в свойство напрямую: цвет зависит + /// от состояния элемента (наведение, фокус) и пересчитывается вручную. + /// + /// Элемент, к жизни которого привязана подписка. + /// Ключ ресурса-кисти темы. + /// Что делать с полученной кистью. + /// + /// Подписка живёт ровно столько, сколько элемент находится в визуальном + /// дереве: создаётся при присоединении, снимается при откреплении и + /// создаётся заново, если элемент вернули. Иначе наблюдатель жил бы + /// у приложения и держал элемент сильной ссылкой, а пересборка + /// содержимого окна оставляла бы прежнее дерево укоренённым навсегда. + /// Элемент, который так и не попал в дерево, подписки не создаёт вовсе. + /// + public static void Observe(Avalonia.Controls.Control target, string brushKey, System.Action apply) { - private readonly AvaloniaObject _target; - private readonly AvaloniaProperty _property; + System.IDisposable? subscription = null; + + void Start() + { + subscription?.Dispose(); + subscription = Avalonia.Application.Current? + .GetResourceObservable(brushKey) + .Subscribe(new BrushCallback(apply)); + } - public ResourceBrushObserver(AvaloniaObject target, AvaloniaProperty property) + void Stop() { - _target = target; - _property = property; + subscription?.Dispose(); + subscription = null; } + target.AttachedToVisualTree += (_, _) => Start(); + target.DetachedFromVisualTree += (_, _) => Stop(); + + // Элемент может быть уже в дереве: тогда события присоединения + // не будет, а кисть нужна сразу. + if (target.GetVisualRoot() is not null) + Start(); + } + + private sealed class BrushCallback : System.IObserver + { + private readonly System.Action _apply; + + public BrushCallback(System.Action apply) => _apply = apply; + public void OnCompleted() { } - public void OnError(Exception error) { } + public void OnError(System.Exception error) { } public void OnNext(object? value) { - if (value is IBrush brush && !Equals(_target.GetValue(_property), brush)) - _target.SetValue(_property, brush); + if (value is Avalonia.Media.IBrush brush) + _apply(brush); } } } } -#endif \ No newline at end of file +#endif diff --git a/Configuration Management/Themes/ThemeManager.Avalonia.cs b/Configuration Management/Themes/ThemeManager.Avalonia.cs index 5fc8af61..7a52294d 100644 --- a/Configuration Management/Themes/ThemeManager.Avalonia.cs +++ b/Configuration Management/Themes/ThemeManager.Avalonia.cs @@ -5,8 +5,12 @@ using System.Collections.Generic; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Controls.Documents; using Avalonia.Media; +using Avalonia.Styling; using Avalonia.Themes; +using Avalonia.VisualTree; using Configuration_Management.Localization; using Configuration_Management.Models; @@ -92,12 +96,12 @@ public static void ApplyFont(Control? target, { var family = string.IsNullOrWhiteSpace(fontFamily) ? DefaultFontFamily : fontFamily; var size = fontSize > 0 ? fontSize : DefaultFontSize; - target.FontFamily = new FontFamily(family); - target.FontSize = size; - target.FontWeight = string.Equals(fontWeight, "Bold", StringComparison.OrdinalIgnoreCase) - ? FontWeight.Bold : FontWeight.Normal; - target.FontStyle = string.Equals(fontStyle, "Italic", StringComparison.OrdinalIgnoreCase) - ? FontStyle.Italic : FontStyle.Normal; + TextElement.SetFontFamily(target, new FontFamily(family)); + TextElement.SetFontSize(target, size); + TextElement.SetFontWeight(target, string.Equals(fontWeight, "Bold", StringComparison.OrdinalIgnoreCase) + ? FontWeight.Bold : FontWeight.Normal); + TextElement.SetFontStyle(target, string.Equals(fontStyle, "Italic", StringComparison.OrdinalIgnoreCase) + ? FontStyle.Italic : FontStyle.Normal); } catch { @@ -111,9 +115,9 @@ public static void ApplyFont(Control? target, public static void ApplyFontToAllWindows( string fontFamily, double fontSize, string fontWeight, string fontStyle) { - if (Application.Current is null) + if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) return; - foreach (Window window in Application.Current.Windows) + foreach (Window window in desktop.Windows) ApplyFont(window, fontFamily, fontSize, fontWeight, fontStyle); } @@ -221,6 +225,52 @@ private static void ApplyColors(Application app, ColorScheme scheme) app.Resources[kvp.Key + "Brush"] = new SolidColorBrush(color); } } + + ApplyFluentAccent(app, scheme); + } + + /// + /// Отдаёт акцент схемы стандартным контролам Fluent (переключатели, + /// флажки, ползунки): свой цвет они берут из SystemAccentColor, и без + /// этого в янтарном интерфейсе они оставались синими. Светлые и тёмные + /// оттенки Fluent использует для состояний, поэтому считаются, а не + /// подменяются одним цветом. + /// + private static void ApplyFluentAccent(Application app, ColorScheme scheme) + { + var accent = ResolveAccent(scheme); + + app.Resources["SystemAccentColor"] = accent; + app.Resources["SystemAccentColorLight1"] = Shade(accent, 0.10); + app.Resources["SystemAccentColorLight2"] = Shade(accent, 0.20); + app.Resources["SystemAccentColorLight3"] = Shade(accent, 0.30); + app.Resources["SystemAccentColorDark1"] = Shade(accent, -0.10); + app.Resources["SystemAccentColorDark2"] = Shade(accent, -0.20); + app.Resources["SystemAccentColorDark3"] = Shade(accent, -0.30); + } + + /// + /// Акцент схемы, а если его в ней нет, то акцент встроенной светлой: + /// без запасного значения у стандартных контролов остался бы акцент + /// предыдущей схемы. + /// + private static Color ResolveAccent(ColorScheme scheme) + { + if (scheme.Colors.TryGetValue("AccentColor", out var hex) && TryParseColor(hex, out var accent)) + return accent; + + return ColorScheme.CreateLight().Colors.TryGetValue("AccentColor", out var fallbackHex) + && TryParseColor(fallbackHex, out var fallback) + ? fallback + : Colors.DodgerBlue; + } + + /// Осветлённый или затемнённый оттенок цвета по светлоте HSL. + private static Color Shade(Color color, double delta) + { + var hsl = color.ToHsl(); + var lightness = Math.Clamp(hsl.L + delta, 0, 1); + return new HslColor(hsl.A, hsl.H, hsl.S, lightness).ToRgb(); } private static bool TryParseColor(string hex, out Color color) @@ -250,7 +300,19 @@ public static List LoadCustomSchemes() if (!Directory.Exists(CustomSchemesDirectory)) return result; - foreach (var file in Directory.GetFiles(CustomSchemesDirectory, "*.json")) + string[] files; + try + { + files = Directory.GetFiles(CustomSchemesDirectory, "*.json"); + } + catch + { + // Каталог схем может быть недоступен для чтения: список тем тогда пустой, + // но окно настроек всё равно открывается. + return result; + } + + foreach (var file in files) { try { @@ -285,6 +347,23 @@ public static void SaveCustomScheme(ColorScheme scheme) File.WriteAllText(file, scheme.ToJson()); } + /// + /// Переименовывает пользовательскую схему: сначала пишет файл под новым именем, + /// затем удаляет старый и только если это другой файл. Порядок важен: при + /// обратном схема пропадала бы при любой ошибке записи. + /// + public static void RenameCustomScheme(ColorScheme scheme, string oldName) + { + if (scheme is null || string.IsNullOrWhiteSpace(scheme.Name) || string.IsNullOrWhiteSpace(oldName)) + return; + + var oldFile = Path.Combine(CustomSchemesDirectory, SafeFileName(oldName) + ".json"); + var newFile = Path.Combine(CustomSchemesDirectory, SafeFileName(scheme.Name) + ".json"); + SaveCustomScheme(scheme); + if (!string.Equals(oldFile, newFile, StringComparison.Ordinal) && File.Exists(oldFile)) + File.Delete(oldFile); + } + /// Удаляет пользовательскую схему по имени. Возвращает true, если файл удалён. public static bool DeleteCustomScheme(string name) { diff --git a/Configuration Management/Themes/ThemeManager.Linux.cs b/Configuration Management/Themes/ThemeManager.Linux.cs deleted file mode 100644 index 757affdd..00000000 --- a/Configuration Management/Themes/ThemeManager.Linux.cs +++ /dev/null @@ -1,20 +0,0 @@ -#if LINUX -using Configuration_Management.Models; - -namespace Configuration_Management.Themes -{ - /// - /// Минимальный ThemeManager для Linux (Этап 2). - /// Применение цветовых схем на Avalonia (Styles/Resources) — Этап 3. - /// Здесь — заглушка, чтобы инфраструктура приложения компилировалась. - /// - public static class ThemeManager - { - public const string LightThemeName = "Light"; - public const string DarkThemeName = "Dark"; - - public static void ApplyScheme(ColorScheme scheme) { } - public static void ApplyTheme(string themeName) { } - } -} -#endif \ No newline at end of file diff --git a/Configuration Management/Themes/ThemeManager.cs b/Configuration Management/Themes/ThemeManager.cs index 63e7fcd0..56dde4ba 100644 --- a/Configuration Management/Themes/ThemeManager.cs +++ b/Configuration Management/Themes/ThemeManager.cs @@ -1,3 +1,4 @@ +#if WINDOWS using System.IO; using System.Windows; using System.Windows.Controls; @@ -268,9 +269,10 @@ private static void ApplyFontToType(DependencyObject root, Type type, ElementFon private static readonly Dictionary _compactColumn = new(); /// - /// Применяет компактный режим к главному окну: уменьшает отступы, внутренние поля, - /// шрифты (в т.ч. унаследованные — заголовки колонок, строки списка) и ширины - /// фиксированных колонок на коэффициент 0.7 (восстанавливает при выключении). + /// Применяет компактный режим к главному окну: уменьшает отступы, внутренние поля + /// и ширины фиксированных колонок на коэффициент 0.7, а шрифты (в т.ч. унаследованные — + /// заголовки колонок, строки списка) — на более мягкий коэффициент 0.9, чтобы надписи + /// оставались читаемыми (восстанавливает при выключении). /// Высоты и минимальные размеры не трогаются, чтобы не создавать артефакты-разделители. /// Вызывается из настроек при переключении и при запуске приложения. /// @@ -279,11 +281,14 @@ public static void ApplyCompact(bool compact) var window = Application.Current?.MainWindow; if (window is null) return; + // Геометрия (отступы, поля, ширины колонок) сжимается сильнее (0.7), + // а шрифты — мягче (0.9), чтобы текст оставался читаемым. var factor = compact ? 0.7 : 1.0; - ApplyCompactElement(window, factor); + var fontFactor = compact ? 0.9 : 1.0; + ApplyCompactElement(window, factor, fontFactor); } - private static void ApplyCompactElement(DependencyObject d, double factor) + private static void ApplyCompactElement(DependencyObject d, double factor, double fontFactor) { if (d is null) return; @@ -319,7 +324,7 @@ private static void ApplyCompactElement(DependencyObject d, double factor) origFont = fontVal; _compactFont[d] = origFont; } - d.SetValue(TextElement.FontSizeProperty, Math.Max(origFont * factor, 8)); + d.SetValue(TextElement.FontSizeProperty, Math.Max(origFont * fontFactor, 8)); } // Уменьшаем фиксированные ширины колонок Grid (заголовки колонок идут за содержимым). @@ -346,7 +351,7 @@ private static void ApplyCompactElement(DependencyObject d, double factor) int count = VisualTreeHelper.GetChildrenCount(d); for (int i = 0; i < count; i++) - ApplyCompactElement(VisualTreeHelper.GetChild(d, i), factor); + ApplyCompactElement(VisualTreeHelper.GetChild(d, i), factor, fontFactor); } private static Thickness ScaleThickness(Thickness t, double factor) @@ -500,3 +505,4 @@ private static string SafeFileName(string name) } } } +#endif diff --git a/Configuration Management/ViewModels/LaunchViewModel.Avalonia.cs b/Configuration Management/ViewModels/LaunchViewModel.Avalonia.cs index f3da879f..b685e686 100644 --- a/Configuration Management/ViewModels/LaunchViewModel.Avalonia.cs +++ b/Configuration Management/ViewModels/LaunchViewModel.Avalonia.cs @@ -31,6 +31,23 @@ public LaunchViewModel( public ICommand LaunchCommand { get; } + /// + /// Переопределения запуска Предприятия из блока «Текущая сессия». + /// Возвращает null, когда переопределять нечего, и тогда запуск идёт + /// по настройкам самой базы. + /// + public Func? EnterpriseOverrides { get; set; } + + /// Запуск Предприятия с учётом переопределений текущей сессии. + private bool LaunchEnterprise(Infobase infobase) + { + var overrides = EnterpriseOverrides?.Invoke(infobase); + return overrides is null + ? _launcher.Launch(infobase, OneCLaunchMode.Enterprise) + : _launcher.Launch(infobase, OneCLaunchMode.Enterprise, + overrides.Client, overrides.RunMode, overrides.Architecture); + } + public void Launch(object? parameter) { var selected = _getSelected(); @@ -56,7 +73,7 @@ string s when Enum.TryParse(s, true, out var parsed) => parsed, _launcher.Launch(selected, OneCLaunchMode.Enterprise, OneCClientType.Thin, OneCArchitecture.x64), LaunchKind.Thick64 => _launcher.Launch(selected, OneCLaunchMode.Enterprise, OneCClientType.Thick, OneCArchitecture.x64), - _ => _launcher.Launch(selected, OneCLaunchMode.Enterprise) + _ => LaunchEnterprise(selected) }; if (ok) @@ -70,4 +87,10 @@ string s when Enum.TryParse(s, true, out var parsed) => parsed, } } } -#endif \ No newline at end of file + +/// +/// Переопределения очередного запуска Предприятия: тип клиента, режим форм +/// и разрядность. Пустое значение поля означает «как у базы». +/// +public sealed record LaunchOverrides(OneCClientType? Client, OneCRunMode? RunMode, OneCArchitecture Architecture); +#endif diff --git a/Configuration Management/ViewModels/MainViewModel.Avalonia.cs b/Configuration Management/ViewModels/MainViewModel.Avalonia.cs index 8bc8796f..c4e992ca 100644 --- a/Configuration Management/ViewModels/MainViewModel.Avalonia.cs +++ b/Configuration Management/ViewModels/MainViewModel.Avalonia.cs @@ -1,5 +1,6 @@ #if LINUX using System.Collections.ObjectModel; +using System.Text.Json; using System.Windows.Input; using Avalonia.Threading; using Configuration_Management.Localization; @@ -27,6 +28,13 @@ public class MainViewModel : ViewModelBase private List _allInfobases = new(); private List _groups = new(); + private bool _groupSortAscending = true; + private string _sortField = "Name"; + private Avalonia.Threading.DispatcherTimer? _syncTimer; + private DateTime? _nextScheduleRun; + private bool _sortAscending = true; + private readonly HashSet _collapsedGroups = new(StringComparer.OrdinalIgnoreCase); + private bool _deferCollapsedSave; private AppSettings _settings = new(); @@ -47,6 +55,7 @@ public class MainViewModel : ViewModelBase // ---- Строка состояния ---- private string _statusBarInfo = LocalizationManager.T("Main.Ready"); private string _syncMessage = string.Empty; + private System.Threading.CancellationTokenSource? _statusMessageCts; // ---- Тема ---- private string _themeName = ThemeManager.LightThemeName; @@ -71,7 +80,163 @@ public bool CompactMode /// Событие изменения компактного режима (для перестроения главного окна). public event Action? OnCompactModeChanged; + // ---- Действие после запуска базы или конфигуратора ---- + private string _afterLaunchAction = "None"; + + /// + /// Что делать с окном после успешного запуска: "None", "MinimizeToTray" или "Close". + /// Хранится строкой, как в WPF-версии и в файле настроек. + /// + public string AfterLaunchAction + { + get => _afterLaunchAction; + set + { + if (SetProperty(ref _afterLaunchAction, value)) + { + _settings.AfterLaunchAction = value; + SaveSettingsSilently(); + } + } + } + + /// + /// Разрешено ли несколько экземпляров: от этого зависит, вернётся ли + /// спрятанное окно повторным запуском приложения. + /// + public bool AllowMultipleInstances => _settings.AllowMultipleInstances; + + /// Показывать ли значок в области уведомлений. + public bool ShowTrayIcon => _settings.ShowTrayIcon; + + /// Уводить ли окно в трей вместо выхода при закрытии. + public bool CloseToTray => _settings.CloseToTray; + + /// Уводить ли окно в трей по клавише Esc. + public bool EscapeToTray => _settings.EscapeToTray; + + /// Настройки трея изменились: окну нужно обновить значок. + public event Action? TraySettingsChanged; + + /// Применяет настройки поведения трея из окна настроек. + public void ApplyTraySettings(bool showTrayIcon, bool closeToTray, bool escapeToTray) + { + _settings.ShowTrayIcon = showTrayIcon; + _settings.CloseToTray = closeToTray; + _settings.EscapeToTray = escapeToTray; + if (!SaveSettingsSafe()) + _dialog.ShowError(LocalizationManager.T("Main.SaveFailedHint"), + LocalizationManager.T("Settings.Title")); + // Значок показывается и прячется сразу, как в версии для Windows, + // иначе настройка действовала бы только после перезапуска. + TraySettingsChanged?.Invoke(); + } + + /// Запрос к главному окну выполнить действие после успешного запуска. + public event Action? AfterLaunchRequested; + + /// Оповещает главное окно, если настройка требует действия. + public void NotifyAfterLaunch() + { + var action = Models.AfterLaunchActionHelper.Parse(_afterLaunchAction); + if (action != Models.AfterLaunchAction.None) + AfterLaunchRequested?.Invoke(action); + } + // ---- Текущая сессия ---- + + /// Показывать блок «Текущая сессия» в правой панели. + public bool ShowSessionLaunchPanel + { + get => _settings.ShowSessionLaunchPanel; + set + { + if (_settings.ShowSessionLaunchPanel == value) + return; + _settings.ShowSessionLaunchPanel = value; + SaveSettingsSilently(); + OnPropertyChanged(nameof(ShowSessionLaunchPanel)); + } + } + + /// + /// Применяет настройки вкладки «Отображение» и сохраняет их разом, как это + /// делает WPF-версия по кнопке в окне настроек: по одному сохранению + /// на переключатель файл переписывался бы десяток раз. + /// + public void ApplyDisplaySettings( + bool showFavoritesButton, bool showPinnedButton, bool showTags, bool showTagFilterPanel, + bool showVersionColumn, bool showConfigurationColumn, bool showLaunchModeColumn, + bool showServerColumn, bool showLastLaunchColumn, bool showSizeColumn, + bool showRightPanelDetails, bool showSessionLaunchPanel, + bool groupByGroup, bool showEmptyGroups, + List? columnOrder) + { + var previousShowFavoritesButton = _settings.ShowFavoritesButton; + var previousShowPinnedButton = _settings.ShowPinnedButton; + var previousShowTags = _settings.ShowTags; + var previousShowVersionColumn = _settings.ShowVersionColumn; + var previousShowConfigurationColumn = _settings.ShowConfigurationColumn; + var previousShowLaunchModeColumn = _settings.ShowLaunchModeColumn; + var previousShowServerColumn = _settings.ShowServerColumn; + var previousShowLastLaunchColumn = _settings.ShowLastLaunchColumn; + var previousShowSizeColumn = _settings.ShowSizeColumn; + var previousColumnOrder = _settings.ColumnOrder ?? new List(); + var previousGroupByGroup = _groupByGroup; + var previousShowEmptyGroups = _showEmptyGroups; + + _settings.ShowFavoritesButton = showFavoritesButton; + _settings.ShowPinnedButton = showPinnedButton; + _settings.ShowTags = showTags; + _settings.ShowTagFilterPanel = showTagFilterPanel; + _settings.ShowVersionColumn = showVersionColumn; + _settings.ShowConfigurationColumn = showConfigurationColumn; + _settings.ShowLaunchModeColumn = showLaunchModeColumn; + _settings.ShowServerColumn = showServerColumn; + _settings.ShowLastLaunchColumn = showLastLaunchColumn; + _settings.ShowSizeColumn = showSizeColumn; + _settings.ColumnOrder = columnOrder ?? new List(); + _settings.ShowRightPanelDetails = showRightPanelDetails; + _settings.ShowSessionLaunchPanel = showSessionLaunchPanel; + _settings.GroupByGroup = groupByGroup; + _settings.ShowEmptyGroups = showEmptyGroups; + + _showTagFilterPanel = showTagFilterPanel; + _showRightPanelDetails = showRightPanelDetails; + _groupByGroup = groupByGroup; + _showEmptyGroups = showEmptyGroups; + + // Дерево трогаем, только если изменилось то, что на него влияет: + // иначе переключатель правой панели сбрасывал бы выделение и прокрутку. + var treeAffected = showTags != previousShowTags + || groupByGroup != previousGroupByGroup + || showEmptyGroups != previousShowEmptyGroups + || showFavoritesButton != previousShowFavoritesButton + || showPinnedButton != previousShowPinnedButton + || showVersionColumn != previousShowVersionColumn + || showConfigurationColumn != previousShowConfigurationColumn + || showLaunchModeColumn != previousShowLaunchModeColumn + || showServerColumn != previousShowServerColumn + || showLastLaunchColumn != previousShowLastLaunchColumn + || showSizeColumn != previousShowSizeColumn + || !previousColumnOrder.SequenceEqual(_settings.ColumnOrder); + + SaveSettingsSilently(); + + NotifyColumnSettings(); + NotifySessionSettings(); + OnPropertyChanged(nameof(ShowTagFilterPanel)); + OnPropertyChanged(nameof(ShowRightPanelDetails)); + OnPropertyChanged(nameof(ShowConnectionInfo)); + OnPropertyChanged(nameof(GroupByGroup)); + OnPropertyChanged(nameof(ShowEmptyGroups)); + OnPropertyChanged(nameof(ShowExpandCollapseButtons)); + + // Состав колонок и группировка меняют и строки, и заголовок. + if (treeAffected) + RebuildTree(); + } + private string _sessionClient = "Авто"; private string _sessionArch = "Авто"; @@ -101,11 +266,15 @@ public MainViewModel( FlatItems = new ObservableCollection(); TagFilterItems = new ObservableCollection(); + // Блок «Текущая сессия» действует на очередной запуск Предприятия. _launchVm = new LaunchViewModel( () => SelectedInfobase, launcher, logger, - OnLaunched); + OnLaunched) + { + EnterpriseOverrides = ResolveSessionOverrides + }; InitializeCommands(); } @@ -128,14 +297,30 @@ public MainViewModel( public ICommand ClearSearchCommand { get; private set; } = null!; public ICommand SearchByTagCommand { get; private set; } = null!; + public ICommand AddTagCommand { get; private set; } = null!; + public ICommand RemoveTagCommand { get; private set; } = null!; public ICommand ClearTagFiltersCommand { get; private set; } = null!; public ICommand LaunchEnterpriseCommand { get; private set; } = null!; public ICommand LaunchConfiguratorCommand { get; private set; } = null!; + + /// Запуск Предприятия с разовыми параметрами командной строки. + public ICommand LaunchEnterpriseWithParamsCommand { get; private set; } = null!; + + /// Запуск Предприятия с запросом имени и пароля вместо сохранённых. + public ICommand LaunchEnterpriseWithAuthCommand { get; private set; } = null!; + + /// Запуск Конфигуратора с разовыми параметрами командной строки. + public ICommand LaunchConfiguratorWithParamsCommand { get; private set; } = null!; public ICommand EditInfobaseCommand { get; private set; } = null!; public ICommand AddInfobaseCommand { get; private set; } = null!; public ICommand DeleteInfobaseCommand { get; private set; } = null!; + public ICommand EditGroupCommand { get; private set; } = null!; + public ICommand DeleteGroupCommand { get; private set; } = null!; + public ICommand OpenInfobaseByLinkCommand { get; private set; } = null!; public ICommand ToggleFavoriteCommand { get; private set; } = null!; public ICommand TogglePinCommand { get; private set; } = null!; + public ICommand ToggleFavoriteForCommand { get; private set; } = null!; + public ICommand TogglePinForCommand { get; private set; } = null!; public ICommand OpenSettingsCommand { get; private set; } = null!; public ICommand ExpandAllGroupsCommand { get; private set; } = null!; public ICommand CollapseAllGroupsCommand { get; private set; } = null!; @@ -144,9 +329,10 @@ public MainViewModel( public ICommand SynchronizeWithIbasesCommand { get; private set; } = null!; public ICommand ToggleThemeCommand { get; private set; } = null!; public ICommand ToggleRightPanelDetailsCommand { get; private set; } = null!; + public ICommand ToggleSessionLaunchPanelCommand { get; private set; } = null!; public ICommand ExitCommand { get; private set; } = null!; public ICommand CopyConnectionStringCommand { get; private set; } = null!; - public ICommand RefreshAllConfigurationInfoCommand { get; private set; } = null!; + public ICommand CheckAvailabilityCommand { get; private set; } = null!; public ICommand OpenInfobaseFolderCommand { get; private set; } = null!; public ICommand CreateDesktopShortcutCommand { get; private set; } = null!; public ICommand OpenNativeStarterCommand { get; private set; } = null!; @@ -160,14 +346,36 @@ private void InitializeCommands() { ClearSearchCommand = new RelayCommand(() => SearchText = string.Empty); SearchByTagCommand = new RelayCommand(SearchByTag); + AddTagCommand = new RelayCommand(AddTag); + RemoveTagCommand = new RelayCommand(RemoveTag); ClearTagFiltersCommand = new RelayCommand(ClearTagFilters); LaunchEnterpriseCommand = new RelayCommand(_ => Launch(_launchVm.LaunchCommand, LaunchKind.Enterprise), _ => SelectedInfobase is not null); LaunchConfiguratorCommand = new RelayCommand(_ => Launch(_launchVm.LaunchCommand, LaunchKind.Configurator), _ => SelectedInfobase is not null); - EditInfobaseCommand = new RelayCommand(EditInfobase, _ => SelectedInfobase is not null); + LaunchEnterpriseWithParamsCommand = new RelayCommand(_ => LaunchWithParams(LaunchKind.Enterprise), _ => SelectedInfobase is not null); + LaunchEnterpriseWithAuthCommand = new RelayCommand(_ => LaunchWithAuth(), _ => SelectedInfobase is not null); + LaunchConfiguratorWithParamsCommand = new RelayCommand(_ => LaunchWithParams(LaunchKind.Configurator), _ => SelectedInfobase is not null); + EditInfobaseCommand = new RelayCommand(_ => EditInfobase(), _ => SelectedInfobase is not null); AddInfobaseCommand = new RelayCommand(AddInfobase); - DeleteInfobaseCommand = new RelayCommand(DeleteInfobase, _ => SelectedInfobase is not null); - ToggleFavoriteCommand = new RelayCommand(ToggleFavorite, _ => SelectedInfobase is not null); - TogglePinCommand = new RelayCommand(TogglePin, _ => SelectedInfobase is not null); + DeleteInfobaseCommand = new RelayCommand(_ => DeleteInfobase(), + _ => SelectedInfobase is not null || SelectedGroupNode?.Group is not null); + // Команды группы: параметр — узел группы или сама группа из строки дерева. + EditGroupCommand = new RelayCommand(p => + { + var group = ResolveGroup(p); + if (group is not null) + EditGroup(group); + }); + DeleteGroupCommand = new RelayCommand(p => + { + var group = ResolveGroup(p); + if (group is not null) + DeleteGroup(group); + }, p => ResolveGroup(p) != null); + OpenInfobaseByLinkCommand = new RelayCommand(OpenInfobaseByLink); + ToggleFavoriteCommand = new RelayCommand(_ => ToggleFavorite(), _ => SelectedInfobase is not null); + TogglePinCommand = new RelayCommand(_ => TogglePin(), _ => SelectedInfobase is not null); + ToggleFavoriteForCommand = new RelayCommand(p => ToggleFavoriteFor(p as Infobase), p => p is Infobase); + TogglePinForCommand = new RelayCommand(p => TogglePinFor(p as Infobase), p => p is Infobase); OpenSettingsCommand = new RelayCommand(OpenSettings); ExpandAllGroupsCommand = new RelayCommand(ExpandAllGroups); CollapseAllGroupsCommand = new RelayCommand(CollapseAllGroups); @@ -176,17 +384,20 @@ private void InitializeCommands() SynchronizeWithIbasesCommand = new RelayCommand(SynchronizeWithIbases); ToggleThemeCommand = new RelayCommand(ToggleTheme); ToggleRightPanelDetailsCommand = new RelayCommand(() => ShowRightPanelDetails = !ShowRightPanelDetails); + ToggleSessionLaunchPanelCommand = new RelayCommand(() => ShowSessionLaunchPanel = !ShowSessionLaunchPanel); ExitCommand = new RelayCommand(ExitApplication); - CopyConnectionStringCommand = new RelayCommand(CopyConnectionString, _ => SelectedInfobase is not null); - RefreshAllConfigurationInfoCommand = new RelayCommand(RefreshAllConfigurationInfo); - OpenInfobaseFolderCommand = new RelayCommand(OpenInfobaseFolder, + CopyConnectionStringCommand = new RelayCommand(_ => CopyConnectionString(), _ => SelectedInfobase is not null); + CheckAvailabilityCommand = new RelayCommand(CheckAvailability); + OpenInfobaseFolderCommand = new RelayCommand(_ => OpenInfobaseFolder(), _ => SelectedInfobase?.Connection.Type == ConnectionType.File); - CreateDesktopShortcutCommand = new RelayCommand(CreateDesktopShortcut, _ => SelectedInfobase is not null); + CreateDesktopShortcutCommand = new RelayCommand(_ => CreateDesktopShortcut(), _ => SelectedInfobase is not null); OpenNativeStarterCommand = new RelayCommand(OpenNativeStarter); QuickClearCacheCommand = new RelayCommand(QuickClearCache, _ => SelectedInfobase is not null); - // Команды очистки кеша доступны всегда (в т.ч. при выбранной группе): если база не - // выделена, окно очистки открывается без выбранной базы (defaultSelected = null). - ClearCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.All)); + // Кнопка «Очистить кеш» верхней панели действует на выбранную базу: если база не + // выделена — недоступна (CanExecute=false). В колонке «Действия» строка передаёт + // свою базу параметром, поэтому там кнопка включена независимо от глобального выбора. + ClearCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.All), + p => p is Infobase ? true : SelectedInfobase is not null); ClearProgramCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.Program)); ClearUserCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.User)); ClearCacheBothCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.All)); @@ -209,7 +420,13 @@ public string SearchText public bool ShowTagFilterPanel { get => _showTagFilterPanel; - set => SetProperty(ref _showTagFilterPanel, value); + set + { + if (!SetProperty(ref _showTagFilterPanel, value)) + return; + _settings.ShowTagFilterPanel = value; + SaveSettingsSilently(); + } } public bool GroupByGroup @@ -218,7 +435,12 @@ public bool GroupByGroup set { if (SetProperty(ref _groupByGroup, value)) + { + // Кнопки «развернуть/свернуть/сортировать группы» видны только + // при группировке, поэтому их видимость идёт следом. + OnPropertyChanged(nameof(ShowExpandCollapseButtons)); ApplyFilter(); + } } } @@ -255,6 +477,8 @@ private void SetListMode(string mode) if (_listMode == mode) return; _listMode = mode; + _settings.ShowFavoritesOnly = mode == "Favorites"; + SaveSettingsSilently(); OnPropertyChanged(nameof(IsListModeAll)); OnPropertyChanged(nameof(IsListModeFavorites)); OnPropertyChanged(nameof(IsListModeRecent)); @@ -266,7 +490,9 @@ public Infobase? SelectedInfobase get => _selectedInfobase; set { - if (SetProperty(ref _selectedInfobase, value)) + if (SetPropertyWithRelated(ref _selectedInfobase, value, nameof(SelectedInfobase), nameof(RightPanelTitle), nameof(RightPanelSubtitle), + nameof(IsInfobaseSelected), nameof(ShowConnectionInfo), + nameof(RightPanelIconKey), nameof(HasRightPanelIcon))) { if (value is not null) SelectedGroupNode = null; @@ -281,11 +507,16 @@ public GroupNodeViewModel? SelectedGroupNode get => _selectedGroupNode; set { - if (SetProperty(ref _selectedGroupNode, value)) + if (SetPropertyWithRelated(ref _selectedGroupNode, value, nameof(SelectedGroupNode), nameof(RightPanelTitle), nameof(RightPanelSubtitle), + nameof(IsInfobaseSelected), nameof(ShowConnectionInfo), + nameof(RightPanelIconKey), nameof(HasRightPanelIcon))) { if (value is not null) SelectedInfobase = null; UpdateStatus(); + // Удаление доступно и при выбранной группе, а без этого + // события кнопка и клавиша остались бы неактивными. + RaiseCommandCanExecuteChanged(); } } } @@ -293,9 +524,46 @@ public GroupNodeViewModel? SelectedGroupNode public bool ShowRightPanelDetails { get => _showRightPanelDetails; - set => SetProperty(ref _showRightPanelDetails, value, nameof(RightPanelToggleTooltip)); + set => SetPropertyWithRelated(ref _showRightPanelDetails, value, nameof(ShowRightPanelDetails), nameof(RightPanelToggleTooltip), nameof(ShowConnectionInfo)); } + /// + /// Заголовок правой панели: имя базы, имя группы или «Нет выбора». + /// Без него при пустом выборе от заголовка оставался один значок. + /// + public string RightPanelTitle => + SelectedInfobase?.Name + ?? SelectedGroupNode?.DisplayName + ?? LocalizationManager.T("Main.NoSelection"); + + /// + /// Подзаголовок правой панели: группа выбранной базы или полный путь + /// выбранной группы, как в WPF-версии. + /// + public string RightPanelSubtitle => + SelectedInfobase is { } infobase + ? infobase.GroupDisplay + : SelectedGroupNode?.FullPath ?? string.Empty; + + /// Подсказка «выберите базу» под заголовком, пока база не выбрана. + public string RightPanelHint => LocalizationManager.T("Main.NoSelectionHint"); + + /// Значок заголовка: база, значок выбранной группы или ничего. + public string? RightPanelIconKey => + SelectedInfobase is not null ? "IconDatabase" : SelectedGroupNode?.Icon; + + /// Показывать значок заголовка: для базы и для группы, но не при пустом выборе. + public bool HasRightPanelIcon => RightPanelIconKey is not null; + + /// Выбрана база, а не группа и не пустота. + public bool IsInfobaseSelected => SelectedInfobase is not null; + + /// + /// Показывать таблицу сведений о подключении: только когда выбрана база + /// и включён показ подробностей. + /// + public bool ShowConnectionInfo => IsInfobaseSelected && ShowRightPanelDetails; + public string RightPanelToggleTooltip => _showRightPanelDetails ? LocalizationManager.T("Main.CollapseRightPanel") : LocalizationManager.T("Main.ExpandRightPanel"); @@ -330,17 +598,68 @@ public string ExportIndicatorTooltip set => SetProperty(ref _exportIndicatorTooltip, value); } - // ---- Горячие клавиши (для подсказок) ---- - public string HotkeyEnterprise => "F3"; - public string HotkeyConfigurator => "F4"; - public string HotkeyEdit => "Ctrl+E"; - public string HotkeyAdd => "Ctrl+N"; - public string HotkeyFavorite => "Alt+F"; - public string HotkeyPin => "Ctrl+P"; - public string HotkeyDelete => "Del"; - public string HotkeyClearCache => "Ctrl+Shift+C"; + // ---- Горячие клавиши ---- + // Раньше здесь стояли зашитые сочетания, и настройки пользователя + // Linux-версия игнорировала: файл настроек общий с WPF-версией, а клавиши + // в нём другие. Теперь читаются оттуда. + public string HotkeyEnterprise => _settings.HotkeyEnterprise; + public string HotkeyConfigurator => _settings.HotkeyConfigurator; + public string HotkeyEdit => _settings.HotkeyEdit; + public string HotkeyAdd => _settings.HotkeyAdd; + public string HotkeyFavorite => _settings.HotkeyFavorite; + public string HotkeyPin => _settings.HotkeyPin; + public string HotkeyDelete => _settings.HotkeyDelete; + public string HotkeyClearCache => _settings.HotkeyClearCache; + + /// + /// Сохраняет назначенные сочетания и сообщает окну, что их надо + /// перерегистрировать: подписи в меню и сами привязки берутся отсюда. + /// + public void ApplyHotkeys(string enterprise, string configurator, string edit, string add, + string favorite, string pin, string delete, string clearCache) + { + _settings.HotkeyEnterprise = enterprise ?? string.Empty; + _settings.HotkeyConfigurator = configurator ?? string.Empty; + _settings.HotkeyEdit = edit ?? string.Empty; + _settings.HotkeyAdd = add ?? string.Empty; + _settings.HotkeyFavorite = favorite ?? string.Empty; + _settings.HotkeyPin = pin ?? string.Empty; + _settings.HotkeyDelete = delete ?? string.Empty; + _settings.HotkeyClearCache = clearCache ?? string.Empty; + + SaveSettingsSilently(); + + OnPropertyChanged(nameof(HotkeyEnterprise)); + OnPropertyChanged(nameof(HotkeyConfigurator)); + OnPropertyChanged(nameof(HotkeyEdit)); + OnPropertyChanged(nameof(HotkeyAdd)); + OnPropertyChanged(nameof(HotkeyFavorite)); + OnPropertyChanged(nameof(HotkeyPin)); + OnPropertyChanged(nameof(HotkeyDelete)); + OnPropertyChanged(nameof(HotkeyClearCache)); + HotkeysChanged?.Invoke(this, EventArgs.Empty); + } + + /// Сочетания переназначены: окну надо перерегистрировать привязки и меню. + public event EventHandler? HotkeysChanged; // ---- Видимость колонок ---- + + /// + /// Порядок колонок списка баз по умолчанию (колонка «Конфигурация» в самом + /// конце). Используется, пока пользователь не задал собственный порядок. + /// + private static readonly string[] DefaultColumnOrder = + { "Version", "LaunchMode", "ServerBase", "LastLaunch", "Size", "Configuration" }; + + /// + /// Порядок колонок списка баз слева направо (кроме фиксированных колонок + /// «Название» и «Действия»). Если порядок не задан или пуст — возвращается + /// порядок по умолчанию с колонкой «Конфигурация» в конце. + /// + public IReadOnlyList ColumnOrderKeys => + _settings.ColumnOrder is { Count: > 0 } ? _settings.ColumnOrder : DefaultColumnOrder; + public bool ShowExpandCollapseButtons => GroupByGroup; public bool ShowFavoritesButton => _settings.ShowFavoritesButton; public bool ShowPinnedButton => _settings.ShowPinnedButton; @@ -350,7 +669,25 @@ public string ExportIndicatorTooltip public bool ShowServerColumn => _settings.ShowServerColumn; public bool ShowLastLaunchColumn => _settings.ShowLastLaunchColumn; public bool ShowSizeColumn => _settings.ShowSizeColumn; - public bool ShowTags => _settings.ShowTags; + /// + /// Показывать теги в строках списка. Переключатель живёт в панели + /// инструментов над списком, состояние хранится в настройках. + /// + public bool ShowTags + { + get => _settings.ShowTags; + set + { + if (_settings.ShowTags == value) + return; + + _settings.ShowTags = value; + SaveSettingsSilently(); + OnPropertyChanged(nameof(ShowTags)); + // Строки строятся с чипами или без них, поэтому пересобираются. + ApplyFilter(); + } + } public double NameColumnWidth => _settings.NameColumnWidth; public double VersionColumnWidth => _settings.VersionColumnWidth; @@ -360,12 +697,100 @@ public string ExportIndicatorTooltip public double LastLaunchColumnWidth => _settings.LastLaunchColumnWidth; public double SizeColumnWidth => _settings.SizeColumnWidth; + /// + /// Запоминает ширину колонки списка по её ключу. Уведомления намеренно нет: + /// во время перетаскивания разделителя ширину уже применили и заголовку, + /// и строкам, а уведомление пересобрало бы заголовок на каждое движение мыши. + /// + public void UpdateColumnWidth(string key, double width, bool save) + { + switch (key) + { + case "Name": _settings.NameColumnWidth = width; break; + case "Version": _settings.VersionColumnWidth = width; break; + case "Configuration": _settings.ConfigurationColumnWidth = width; break; + case "LaunchMode": _settings.LaunchModeColumnWidth = width; break; + case "ServerBase": _settings.ServerColumnWidth = width; break; + case "LastLaunch": _settings.LastLaunchColumnWidth = width; break; + case "Size": _settings.SizeColumnWidth = width; break; + default: return; + } + + if (save) + SaveSettingsSilently(); + } + + // ---- Сортировка списка ---- + public string SortField => _sortField; + public bool SortAscending => _sortAscending; + + /// + /// Меняет поле сортировки списка баз. Повторный клик по тому же полю + /// разворачивает направление. + /// + public void SetSortField(string field) + { + if (string.IsNullOrWhiteSpace(field)) + return; + + if (string.Equals(_sortField, field, StringComparison.OrdinalIgnoreCase)) + _sortAscending = !_sortAscending; + else + { + _sortField = field; + _sortAscending = field != "LastLaunchDate"; // дату удобнее сначала по убыванию + } + + _settings.SortField = _sortField; + _settings.SortAscending = _sortAscending; + SaveSettingsSilently(); + OnPropertyChanged(nameof(SortField)); + OnPropertyChanged(nameof(SortAscending)); + RebuildTree(); + } + + /// + /// Упорядочивает базы по выбранному полю. Закреплённые всегда идут первыми, + /// имя служит вторым ключом, чтобы порядок не зависел от порядка в файле. + /// + private IEnumerable ApplyCurrentSort(IEnumerable source) + { + var query = source.OrderBy(i => i.GroupSortOrder); + return _sortField switch + { + "LastLaunchDate" when _sortAscending => + query.ThenBy(i => i.LastLaunchDate ?? DateTime.MinValue) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase), + "LastLaunchDate" => + query.ThenByDescending(i => i.LastLaunchDate ?? DateTime.MinValue) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase), + "SortOrder" when _sortAscending => + query.ThenBy(i => i.SortOrder) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase), + "SortOrder" => + query.ThenByDescending(i => i.SortOrder) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase), + _ when _sortAscending => + query.ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase), + _ => + query.ThenByDescending(i => i.Name, StringComparer.OrdinalIgnoreCase) + }; + } + // ---- Текущая сессия ---- public string SessionClient { get => _sessionClient; - set => SetProperty(ref _sessionClient, value, - nameof(IsSessionClientAuto), nameof(IsSessionClientOrdinary), nameof(IsSessionClientThick), nameof(IsSessionClientThickOrdinary), nameof(IsSessionClientThin)); + set + { + if (!SetPropertyWithRelated(ref _sessionClient, value, nameof(SessionClient), + nameof(IsSessionClientAuto), nameof(IsSessionClientOrdinary), nameof(IsSessionClientThick), + nameof(IsSessionClientThickOrdinary), nameof(IsSessionClientThin))) + return; + + _settings.SessionClientMode = SessionClientMode().ToString(); + SaveSettingsSilently(); + } } public bool IsSessionClientAuto { get => SessionClient == "Авто"; set { if (value) SessionClient = "Авто"; } } public bool IsSessionClientOrdinary { get => SessionClient == "Обычный"; set { if (value) SessionClient = "Обычный"; } } @@ -376,7 +801,107 @@ public string SessionClient public string SessionArch { get => _sessionArch; - set => SetProperty(ref _sessionArch, value, nameof(IsSessionArchAuto), nameof(IsSessionArch32), nameof(IsSessionArch64)); + set + { + if (!SetPropertyWithRelated(ref _sessionArch, value, nameof(SessionArch), nameof(IsSessionArchAuto), nameof(IsSessionArch32), nameof(IsSessionArch64))) + return; + + _settings.SessionArchitecture = SessionArchitectureMode().ToString(); + SaveSettingsSilently(); + } + } + + /// Режим клиента текущей сессии в терминах модели. + private SessionClientMode SessionClientMode() => _sessionClient switch + { + "Обычный" => Models.SessionClientMode.Ordinary, + "Толстый" => Models.SessionClientMode.Thick, + "ТолстыйОбычные" => Models.SessionClientMode.ThickOrdinary, + "Тонкий" => Models.SessionClientMode.Thin, + _ => Models.SessionClientMode.Auto + }; + + /// Разрядность текущей сессии в терминах модели. + private SessionArchitectureMode SessionArchitectureMode() => _sessionArch switch + { + "32" => Models.SessionArchitectureMode.X86, + "64" => Models.SessionArchitectureMode.X64, + _ => Models.SessionArchitectureMode.Auto + }; + + private static string SessionClientFromSetting(string? saved) => + Enum.TryParse(saved, true, out var parsed) + ? parsed switch + { + Models.SessionClientMode.Ordinary => "Обычный", + Models.SessionClientMode.Thick => "Толстый", + Models.SessionClientMode.ThickOrdinary => "ТолстыйОбычные", + Models.SessionClientMode.Thin => "Тонкий", + _ => "Авто" + } + : "Авто"; + + private static string SessionArchFromSetting(string? saved) => + Enum.TryParse(saved, true, out var parsed) + ? parsed switch + { + Models.SessionArchitectureMode.X86 => "32", + Models.SessionArchitectureMode.X64 => "64", + _ => "Авто" + } + : "Авто"; + + /// + /// Переопределения очередного запуска Предприятия по блоку «Текущая сессия». + /// Возвращает null, когда оба переключателя в «Авто»: тогда запуск идёт + /// по настройкам самой базы, как в WPF-версии. + /// + private LaunchOverrides? ResolveSessionOverrides(Infobase infobase) + { + var client = SessionClientMode(); + var arch = SessionArchitectureMode(); + if (client == Models.SessionClientMode.Auto && arch == Models.SessionArchitectureMode.Auto) + return null; + + OneCClientType? clientType = client switch + { + Models.SessionClientMode.Thin => OneCClientType.Thin, + Models.SessionClientMode.Thick => OneCClientType.Thick, + Models.SessionClientMode.ThickOrdinary => OneCClientType.Thick, + Models.SessionClientMode.Ordinary => OneCClientType.Thick, + _ => ClientFromInfobase(infobase) + }; + + var architecture = arch switch + { + Models.SessionArchitectureMode.X86 => OneCArchitecture.x86, + Models.SessionArchitectureMode.X64 => OneCArchitecture.x64, + _ => OneCLauncher.ResolveArchitecture(infobase.Architecture, infobase.PlatformVersion) + }; + + OneCRunMode? runMode = client switch + { + Models.SessionClientMode.Thick => OneCRunMode.Managed, + Models.SessionClientMode.ThickOrdinary => OneCRunMode.Ordinary, + Models.SessionClientMode.Auto => OneCLauncher.GetRunModeFromLaunchMode(infobase.LaunchMode), + _ => null + }; + + return new LaunchOverrides(clientType, runMode, architecture); + } + + /// Тип клиента из настройки базы, как в WPF-версии. + private static OneCClientType? ClientFromInfobase(Infobase infobase) + { + if (string.Equals(infobase.LaunchMode, "Автоматический", StringComparison.OrdinalIgnoreCase)) + return null; + if (string.Equals(infobase.LaunchMode, "Толстый клиент (обычные формы)", StringComparison.OrdinalIgnoreCase)) + return OneCClientType.Thick; + if (string.Equals(infobase.LaunchMode, "Толстый клиент", StringComparison.OrdinalIgnoreCase)) + return OneCClientType.Thick; + if (string.Equals(infobase.LaunchMode, "Тонкий клиент", StringComparison.OrdinalIgnoreCase)) + return OneCClientType.Thin; + return null; } public bool IsSessionArchAuto { get => SessionArch == "Авто"; set { if (value) SessionArch = "Авто"; } } public bool IsSessionArch32 { get => SessionArch == "32"; set { if (value) SessionArch = "32"; } } @@ -393,26 +918,53 @@ public void Initialize() _allInfobases = _repository.Load(); _groups = _repository.LoadGroups(); + _collapsedGroups.Clear(); + foreach (var key in _settings.CollapsedGroups) + _collapsedGroups.Add(key); + _groupByGroup = _settings.GroupByGroup; _showEmptyGroups = _settings.ShowEmptyGroups; _showTagFilterPanel = _settings.ShowTagFilterPanel; _themeName = _settings.Theme; _compactMode = _settings.CompactMode; + _afterLaunchAction = _settings.AfterLaunchAction ?? "None"; + _sortField = string.IsNullOrWhiteSpace(_settings.SortField) ? "Name" : _settings.SortField; + _sortAscending = _settings.SortAscending; + // Вид списка хранится тем же признаком, что и в WPF: «только избранные». + _listMode = _settings.ShowFavoritesOnly ? "Favorites" : "All"; + _sessionClient = SessionClientFromSetting(_settings.SessionClientMode); + _sessionArch = SessionArchFromSetting(_settings.SessionArchitecture); + ApplyDefaultArchitecture(); + ApplyAdditionalSearchPaths(); + ApplyTemplateCatalogPaths(); OnPropertyChanged(nameof(GroupByGroup)); OnPropertyChanged(nameof(ShowEmptyGroups)); OnPropertyChanged(nameof(ShowTagFilterPanel)); + // Сегменты «Все / Избранное / Недавние» привязаны к производным + // признакам, а вид списка восстановлен полем, поэтому уведомляем. + OnPropertyChanged(nameof(IsListModeAll)); + OnPropertyChanged(nameof(IsListModeFavorites)); + OnPropertyChanged(nameof(IsListModeRecent)); NotifyColumnSettings(); NotifySessionSettings(); RebuildTree(); UpdateStatus(string.Format(LocalizationManager.T("Main.LoadedBases"), _allInfobases.Count)); - // Применяем сохранённую тему, если активная схема не задана. - if (_settings.ActiveColorScheme is { Colors.Count: > 0 }) - ThemeManager.ApplyScheme(_settings.ActiveColorScheme); - else - ThemeManager.ApplyTheme(string.IsNullOrWhiteSpace(_themeName) ? ThemeManager.LightThemeName : _themeName); + // При запуске синхронизируемся при любом триггере, как в WPF: + // «при старте» это сразу и только, интервал и расписание это сразу + // и дальше по таймеру. + if (_settings.IbasesSyncMode != IbasesSyncMode.None) + SynchronizeSilently(); + RestartAutoSync(); + + // Применяем сохранённую схему активной базовой темы (раздельные схемы + // для светлой/тёмной темы), иначе — встроенные цвета. + var activeTheme = string.IsNullOrWhiteSpace(_themeName) + ? (_settings.ActiveColorScheme?.IsDark == true ? ThemeManager.DarkThemeName : ThemeManager.LightThemeName) + : _themeName; + ThemeManager.ApplyScheme(SchemeForTheme(IsDarkTheme(activeTheme))); } catch (Exception ex) { @@ -424,11 +976,21 @@ public void Initialize() /// Перестраивает дерево групп из моделей. public void RebuildTree() { + // Узлы пересоздаются, поэтому прежний выбранный узел больше не тот, + // что показан в дереве: правая панель иначе показывала бы старую группу. + // Ключ запоминается, чтобы выбор вернулся на равнозначный новый узел. + // Для настоящей группы главным остаётся её идентификатор: перенос + // подветки меняет путь, а вместе с ним и ключ, но группа та же. + var selectedKey = SelectedGroupNode?.NodeKey; + var selectedGroupId = SelectedGroupNode?.Group?.Id; + SelectedGroupNode = null; AllGroupNodes.Clear(); GroupNodes.Clear(); FlatItems.Clear(); var roots = GroupNodeViewModel.BuildTree(_groups); + DistributeInfobases(roots); + foreach (var root in roots) AllGroupNodes.Add(root); @@ -441,22 +1003,182 @@ public void RebuildTree() RebuildTagFilters(); ApplyFilter(); + + // Пустой идентификатор группы штатно возможен (значение по умолчанию + // в модели), и сравнение по нему совпало бы с первой попавшейся группой. + var hasGroupId = !string.IsNullOrEmpty(selectedGroupId); + if (hasGroupId || selectedKey is not null) + SelectedGroupNode = FindNode(node => + hasGroupId + && string.Equals(node.Group?.Id, selectedGroupId, StringComparison.OrdinalIgnoreCase)) + ?? (selectedKey is null ? null : FindNode(node => + string.Equals(node.NodeKey, selectedKey, StringComparison.OrdinalIgnoreCase))); + + // Состав списка мог смениться импортом, удалением или очисткой, + // а его показывает меню трея. + OnPropertyChanged(nameof(RecentInfobases)); + } + + /// Состав списка вот-вот сменится: окну нужно запомнить прокрутку. + public event Action? TreeRebuilding; + + /// Состав списка обновлён: окну нужно вернуть выделение строки и прокрутку. + public event Action? TreeRebuilt; + + /// Ищет узел дерева по признаку, включая служебные узлы и подгруппы. + private GroupNodeViewModel? FindNode(Func match) + { + GroupNodeViewModel? Search(GroupNodeViewModel node) + { + if (match(node)) + return node; + foreach (var child in node.Children) + { + var found = Search(child); + if (found is not null) + return found; + } + return null; + } + + foreach (var root in GroupNodes) + { + var found = Search(root); + if (found is not null) + return found; + } + return null; + } + + /// + /// Раскладывает базы по узлам дерева: по полному пути группы, закреплённые + /// дополнительно в отдельный узел, остальные в узел «без группы». Узлы + /// «закреплённые» и «без группы» добавляются к корням, если непусты. + /// + private void DistributeInfobases(List roots) + { + var pinnedNode = new GroupNodeViewModel(null, marker: GroupNodeViewModel.PinnedMarker); + var noGroupNode = new GroupNodeViewModel(null, marker: GroupNodeViewModel.NoGroupMarker); + + // Индексация по полному пути узла: база хранит путь группы строкой. + var pathToNode = new Dictionary(StringComparer.OrdinalIgnoreCase); + void Index(GroupNodeViewModel node) + { + if (node.Group is not null && !string.IsNullOrEmpty(node.FullPath)) + { + pathToNode[node.FullPath] = node; + var normalized = NormalizeGroupPath(node.FullPath); + if (!string.IsNullOrEmpty(normalized)) + pathToNode[normalized] = node; + } + foreach (var child in node.Children) + Index(child); + } + foreach (var root in roots) + Index(root); + + foreach (var node in pathToNode.Values) + node.SetNotificationsSuppressed(true); + pinnedNode.SetNotificationsSuppressed(true); + noGroupNode.SetNotificationsSuppressed(true); + + foreach (var infobase in ApplyCurrentSort(_allInfobases)) + { + if (infobase.IsPinned) + pinnedNode.Infobases.Add(infobase); + + var groupPath = infobase.Group?.Trim() ?? string.Empty; + if (!string.IsNullOrEmpty(groupPath) + && (pathToNode.TryGetValue(groupPath, out var node) + || pathToNode.TryGetValue(NormalizeGroupPath(groupPath), out node))) + node.Infobases.Add(infobase); + else + noGroupNode.Infobases.Add(infobase); + } + + foreach (var node in pathToNode.Values) + node.SetNotificationsSuppressed(false); + pinnedNode.SetNotificationsSuppressed(false); + noGroupNode.SetNotificationsSuppressed(false); + + // Порядок как в WPF-версии: закреплённые, без группы, затем группы + // по алфавиту в выбранном направлении. + var comparer = StringComparer.OrdinalIgnoreCase; + roots.Sort(_groupSortAscending + ? (a, b) => comparer.Compare(a.DisplayName, b.DisplayName) + : (a, b) => comparer.Compare(b.DisplayName, a.DisplayName)); + foreach (var root in roots) + root.SortChildrenRecursive(_groupSortAscending); + + if (noGroupNode.Infobases.Count > 0) + roots.Insert(0, noGroupNode); + if (pinnedNode.Infobases.Count > 0) + roots.Insert(0, pinnedNode); + + foreach (var root in roots) + { + root.PopulateItems(_showEmptyGroups); + ApplyExpandedState(root); + SubscribeExpandedTracking(root); + } } + /// + /// Временный фильтр: поиск, отбор по тегам или вид списка кроме «Все». + /// В этом режиме дерево показывает плоский найденный список, а не группы. + /// + private bool IsFilterModeActive() => + !string.IsNullOrWhiteSpace(SearchText) || HasActiveTagFilter || _listMode != "All"; + /// Применяет фильтр по виду списка и поиску. private void ApplyFilter() { - var hasSearch = !string.IsNullOrWhiteSpace(SearchText); - var hasActiveTags = TagFilterItems.Any(t => t.IsSelected); - - if (hasSearch || hasActiveTags || _listMode != "All") + // События подняты здесь, а не в RebuildTree: список меняет состав + // и мимо полной пересборки, через поиск, вкладки, отбор по тегам, + // переключатель тегов и группировки. Все эти пути идут сюда. + TreeRebuilding?.Invoke(); + + var filterActive = IsFilterModeActive(); + + // Плоский список нужен в двух случаях: активен фильтр (поиск, теги, + // «Избранное», «Недавние») либо пользователь отключил группировку. + // Дерево привязано только к GroupNodes, поэтому результат кладётся + // одним узлом туда же, иначе список остался бы пустым. + if (filterActive || !_groupByGroup) { - // Плоский список результатов. + // В режиме «Недавние» порядок задаёт дата запуска, в остальных — + // выбранное поле сортировки. + var matched = filterActive ? _allInfobases.Where(MatchesFilter) : _allInfobases; + var visible = (_listMode == "Recent" + ? matched.OrderByDescending(i => i.LastLaunchDate ?? DateTime.MinValue) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + : ApplyCurrentSort(matched)).ToList(); + FlatItems.Clear(); - var filtered = _allInfobases.Where(MatchesFilter); - foreach (var ib in filtered.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)) + foreach (var ib in visible) FlatItems.Add(ib); + + var flatNode = new GroupNodeViewModel( + null, + displayName: filterActive ? LocalizationManager.T("Main.FlatFound") : null, + marker: filterActive ? null : GroupNodeViewModel.AllBasesMarker); + + flatNode.SetNotificationsSuppressed(true); + try + { + foreach (var ib in visible) + flatNode.Infobases.Add(ib); + } + finally + { + flatNode.SetNotificationsSuppressed(false); + } + + flatNode.PopulateItems(); + flatNode.SetExpandedSilent(true); + GroupNodes.Clear(); + GroupNodes.Add(flatNode); } else { @@ -468,6 +1190,14 @@ private void ApplyFilter() GroupNodes.Add(root); } } + + // Выбранный узел мог исчезнуть из дерева: поиск и отключение группировки + // подменяют его плоским списком. Иначе правая панель продолжила бы + // показывать группу, которой в дереве уже нет. + if (SelectedGroupNode is { } selected && FindNode(node => ReferenceEquals(node, selected)) is null) + SelectedGroupNode = null; + + TreeRebuilt?.Invoke(); } private bool MatchesFilter(Infobase ib) @@ -502,6 +1232,12 @@ private static bool ContainsIgnoreCase(string? source, string value) => private void RebuildTagFilters() { + // Выбор сохраняется: пересборка идёт при каждом перестроении дерева, + // и без этого действующий отбор сбрасывался бы при любой правке базы. + var selected = TagFilterItems.Where(t => t.IsSelected) + .Select(t => t.Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + TagFilterItems.Clear(); foreach (var tag in _allInfobases .SelectMany(ib => ib.Tags) @@ -510,8 +1246,73 @@ private void RebuildTagFilters() .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(t => t, StringComparer.OrdinalIgnoreCase)) { - TagFilterItems.Add(new TagFilterItem(tag)); + TagFilterItems.Add(new TagFilterItem(tag) { IsSelected = selected.Contains(tag) }); } + + // HasActiveTagFilter отдельно не поднимается: интерфейс слушает и его, + // и это событие, и панель пересобиралась бы дважды на одно перестроение. + TagFiltersRebuilt?.Invoke(this, EventArgs.Empty); + } + + /// + /// Набор тегов пересобран целиком. Отдельное событие нужно, чтобы + /// интерфейс перестраивал панель один раз, а не на каждый добавленный + /// элемент коллекции. + /// + public event EventHandler? TagFiltersRebuilt; + + /// + /// Добавляет тег базе через диалог ввода. Параметр это база строки, + /// без параметра берётся выбранная. + /// + private void AddTag(object? parameter) + { + var infobase = parameter as Infobase ?? SelectedInfobase; + if (infobase is null) + return; + + var dialog = new Configuration_Management.TagInputWindow(); + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + var tag = dialog.Result?.Trim() ?? string.Empty; + if (tag.Length == 0 || infobase.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + return; + + var wasFiltering = IsFilterModeActive(); + infobase.Tags.Add(tag); + infobase.NotifyTagsChanged(); + SaveSilently(); + RebuildTagFilters(); + + // Пересобираем список, только если состав видимых баз мог измениться: + // без фильтра строка сама показывает новый чип по уведомлению модели. + if (wasFiltering || IsFilterModeActive()) + ApplyFilter(); + } + + /// + /// Убирает тег у базы. Параметр той же формы, что и в WPF-версии: + /// массив из базы и тега. + /// + private void RemoveTag(object? parameter) + { + if (parameter is not object[] values || values.Length < 2) + return; + if (values[0] is not Infobase infobase || values[1] is not string tag) + return; + + // Признак снимается до пересборки отбора: она убирает из панели тег, + // которого больше нет ни у одной базы, и проверка после неё уже + // не увидела бы, что список показан отобранным. + var wasFiltering = IsFilterModeActive(); + infobase.Tags.RemoveAll(t => string.Equals(t, tag, StringComparison.OrdinalIgnoreCase)); + infobase.NotifyTagsChanged(); + SaveSilently(); + RebuildTagFilters(); + + if (wasFiltering || IsFilterModeActive()) + ApplyFilter(); } private void SearchByTag(object? parameter) @@ -523,6 +1324,7 @@ private void SearchByTag(object? parameter) if (item is null) return; item.IsSelected = !item.IsSelected; + OnPropertyChanged(nameof(HasActiveTagFilter)); ApplyFilter(); } @@ -532,6 +1334,7 @@ private void ClearTagFilters() { foreach (var item in TagFilterItems) item.IsSelected = false; + OnPropertyChanged(nameof(HasActiveTagFilter)); ApplyFilter(); } @@ -565,16 +1368,35 @@ public bool IsGroupCollapsed(string groupName) return null; } - private void ExpandAllGroups() - { - foreach (var root in AllGroupNodes) - SetExpandedRecursive(root, true); - } + private void ExpandAllGroups() => SetExpandedForAll(true); - private void CollapseAllGroups() - { - foreach (var root in AllGroupNodes) - SetExpandedRecursive(root, false); + private void CollapseAllGroups() => SetExpandedForAll(false); + + /// + /// Массово меняет раскрытие всех узлов и сохраняет настройки один раз. + /// Поузловое сохранение записывало бы весь файл настроек столько раз, + /// сколько узлов в дереве, и всё это на потоке интерфейса. + /// + private void SetExpandedForAll(bool expanded) + { + var before = _collapsedGroups.Count; + var snapshot = _collapsedGroups.ToHashSet(StringComparer.OrdinalIgnoreCase); + + _deferCollapsedSave = true; + try + { + foreach (var root in AllGroupNodes) + SetExpandedRecursive(root, expanded); + } + finally + { + _deferCollapsedSave = false; + } + + // Файл настроек пишется только если набор действительно изменился: + // «развернуть все» на уже развёрнутом дереве не должно трогать диск. + if (_collapsedGroups.Count != before || !_collapsedGroups.SetEquals(snapshot)) + PersistCollapsedGroups(); } private static void SetExpandedRecursive(GroupNodeViewModel node, bool expanded) @@ -587,66 +1409,1610 @@ private static void SetExpandedRecursive(GroupNodeViewModel node, bool expanded) private void SortGroups(bool ascending) { - foreach (var root in AllGroupNodes) - root.SortChildrenRecursive(ascending); + // Направление запоминается: RebuildTree пересобирает дерево из _groups + // в исходном порядке, поэтому сортировать сами узлы бесполезно. + _groupSortAscending = ascending; + RebuildTree(); + } + + // ======================= Запуск / действия ======================= + + /// + /// Запуск с разовыми параметрами: диалог правит параметры только на один + /// запуск, сохранённое значение базы возвращается в любом случае. + /// + private void LaunchWithParams(LaunchKind kind) + { + var infobase = SelectedInfobase; + if (infobase is null) + return; + + var dialog = new Configuration_Management.LaunchParametersWindow(infobase.LaunchParameters ?? string.Empty); + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + var saved = infobase.LaunchParameters ?? string.Empty; + try + { + infobase.LaunchParameters = dialog.Result; + Launch(_launchVm.LaunchCommand, kind); + } + finally + { + infobase.LaunchParameters = saved; + // Успешный запуск сохраняет список баз изнутри, то есть подменённое + // значение уже успело уйти на диск. Возвращаем файл к прежнему виду, + // иначе разовые параметры остались бы у базы навсегда. + SaveSilently(); + } + } + + /// + /// Запуск с авторизацией: сохранённые имя и пароль на один раз убираются, + /// чтобы платформа спросила их сама. Прежние значения возвращаются всегда. + /// + private void LaunchWithAuth() + { + var infobase = SelectedInfobase; + if (infobase?.Connection is not { } connection) + return; + + var savedUser = connection.User; + var savedPassword = connection.Password; + var savedMode = connection.AuthenticationMode; + + // У базы может быть отдельная авторизация Предприятия, и лаунчер + // предпочитает именно её: без этого пункт молча запускал бы клиент + // с сохранёнными учётными данными. + var enterpriseAuth = infobase.EnterpriseAuth; + var savedAuthUser = enterpriseAuth?.User; + var savedAuthPassword = enterpriseAuth?.Password; + var savedAuthMode = enterpriseAuth?.AuthenticationMode; + + try + { + connection.User = string.Empty; + connection.Password = string.Empty; + connection.AuthenticationMode = AuthenticationMode.Prompt; + + if (enterpriseAuth is not null) + { + enterpriseAuth.User = string.Empty; + enterpriseAuth.Password = string.Empty; + enterpriseAuth.AuthenticationMode = AuthenticationMode.Prompt; + } + + Launch(_launchVm.LaunchCommand, LaunchKind.Enterprise); + } + finally + { + connection.User = savedUser; + connection.Password = savedPassword; + connection.AuthenticationMode = savedMode; + + if (enterpriseAuth is not null) + { + enterpriseAuth.User = savedAuthUser ?? string.Empty; + enterpriseAuth.Password = savedAuthPassword ?? string.Empty; + enterpriseAuth.AuthenticationMode = savedAuthMode ?? AuthenticationMode.Prompt; + } + + // Причина та же, что и у запуска с параметрами: успешный запуск + // сохраняет базы изнутри, и пустые учётные данные уже на диске. + SaveSilently(); + } + } + + private void OnLaunched() + { + if (SelectedInfobase is not null) + { + SelectedInfobase.AddLaunchHistory(LocalizationManager.T("Main.LaunchAction")); + SaveSilently(); + } + + // Список недавних изменился, и его показывает меню трея. + OnPropertyChanged(nameof(RecentInfobases)); + + // Одна точка на все пути запуска: команды окна, контекстное меню и трей + // приходят сюда же, в отличие от WPF, где уведомление расставлено трижды. + NotifyAfterLaunch(); + } + + private void EditInfobase() + { + var ib = SelectedInfobase; + if (ib is null) + return; + + var dialog = new Configuration_Management.ConnectionSettingsWindow( + ib, _groups, InstalledPlatformVersions(), ib.Group, + AvailableServers(), AvailablePorts()); + + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + // Применяем изменения к существующему объекту, а не заменяем его новым. + // Диалог возвращает свежий Infobase, в который переносятся только + // редактируемые поля, поэтому замена стёрла бы историю запусков, + // порядок сортировки и номер горячей клавиши избранного. + ib.Id = dialog.Result.Id; + ib.Name = dialog.Result.Name; + ib.Group = dialog.Result.Group; + ib.Description = dialog.Result.Description; + ib.PlatformVersion = dialog.Result.PlatformVersion; + ib.Architecture = dialog.Result.Architecture; + ib.LaunchMode = dialog.Result.LaunchMode; + ib.LaunchParameters = dialog.Result.LaunchParameters; + ib.ClientType = dialog.Result.ClientType; + ib.IsFavorite = dialog.Result.IsFavorite; + ib.IsPinned = dialog.Result.IsPinned; + ib.LastLaunchDate = dialog.Result.LastLaunchDate; + ib.Tags = dialog.Result.Tags; + ib.MetadataRoot = dialog.Result.MetadataRoot; + ib.Connection = dialog.Result.Connection; + ib.EnterpriseAuth = dialog.Result.EnterpriseAuth; + ib.ConfiguratorAuth = dialog.Result.ConfiguratorAuth; + ib.Repository = dialog.Result.Repository; + + SaveSilently(); + RebuildTree(); + ExportToIbasesAfterLocalChange(); + SelectedInfobase = ib; + } + + private void AddInfobase() + { + var chooser = new Configuration_Management.AddEditWindow(); + if (!chooser.ShowDialogSync(OwnerWindow())) + return; + + var defaultGroupPath = SelectedGroupNode?.Group is not null + ? SelectedGroupNode.FullPath + : (SelectedInfobase?.Group ?? string.Empty); + + switch (chooser.SelectedType) + { + case "Group": + AddGroup(); + break; + + case "CreateEmpty": + case "CreateFromTemplate": + CreateInfobase(chooser.SelectedType == "CreateFromTemplate", defaultGroupPath); + break; + + default: + RegisterExistingInfobase(defaultGroupPath); + break; + } + } + + /// Создание новой базы: пустой или из шаблона. + private void CreateInfobase(bool fromTemplate, string defaultGroupPath) + { + var dialog = new Configuration_Management.CreateInfobaseWindow( + fromTemplate, + InstalledPlatformVersions(), + defaultGroupPath, + _groups); + + if (!dialog.ShowDialogSync(OwnerWindow()) || dialog.Result is null) + return; + + _allInfobases.Add(dialog.Result); + SaveSilently(); + RebuildTree(); + ExportToIbasesAfterLocalChange(); + SelectedInfobase = dialog.Result; + _dialog.ShowInfo( + string.Format(LocalizationManager.T("Main.DlgBaseCreated"), dialog.Result.Name), + LocalizationManager.T("Main.DlgBaseCreatedTitle")); + } + + /// Регистрация уже существующей базы в списке. + private void RegisterExistingInfobase(string defaultGroupPath) + { + var dialog = new Configuration_Management.ConnectionSettingsWindow( + null, _groups, InstalledPlatformVersions(), defaultGroupPath, + AvailableServers(), AvailablePorts()); + + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + _allInfobases.Add(dialog.Result); + SaveSilently(); + RebuildTree(); + ExportToIbasesAfterLocalChange(); + SelectedInfobase = dialog.Result; + } + + /// Добавление группы: родитель берётся из выделения в дереве. + private void AddGroup() + { + var parent = SelectedGroupNode?.Group; + if (parent is null && !string.IsNullOrWhiteSpace(SelectedInfobase?.Group)) + parent = FindGroupByFullPath(SelectedInfobase!.Group); + var dialog = new Configuration_Management.GroupEditWindow(_groups, parent); + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + _groups.Add(dialog.Result); + SaveGroupsSilently(); + RebuildTree(); + } + + /// Список установленных версий платформы для диалогов. + /// + /// Версии платформы, найденные штатными путями и дополнительными путями + /// из настроек. Дополнительные пути в Linux-ветке до сих пор никуда + /// не передавались, и настройка была бесполезной. + /// + public List FindPlatformVersions(IEnumerable? additionalPaths = null) + { + try { return _platformService.FindInstalledVersions(additionalPaths ?? _settings.AdditionalPlatformSearchPaths); } + catch (Exception ex) + { + _logger.Warn($"Не удалось получить список версий платформы: {ex.Message}"); + return new List(); + } + } + + /// Дополнительные пути поиска платформы из настроек. + public IReadOnlyList AdditionalPlatformSearchPaths => _settings.AdditionalPlatformSearchPaths; + + /// Разрядность запуска по умолчанию: «X64» или «X86». + public string DefaultArchitecture => _settings.DefaultArchitecture; + + // ---- Синхронизация с ibases.v8i ---- + public IbasesSyncMode IbasesSyncMode => _settings.IbasesSyncMode; + public string IbasesSyncFilePath => _settings.IbasesSyncFilePath; + public IbasesSyncTrigger IbasesSyncTrigger => _settings.IbasesSyncTrigger; + public int IbasesSyncIntervalMinutes => _settings.IbasesSyncIntervalMinutes; + public string IbasesSyncScheduleTime => _settings.IbasesSyncScheduleTime; + public bool IbasesBackupEnabled => _settings.IbasesBackupEnabled; + public int IbasesBackupKeepCount => _settings.IbasesBackupKeepCount; + + /// Применяет настройки синхронизации с файлом списка баз платформы. + public void ApplyIbasesSyncSettings(IbasesSyncMode mode, string filePath, IbasesSyncTrigger trigger, + int intervalMinutes, string scheduleTime, bool backupEnabled, int backupKeepCount) + { + _settings.IbasesSyncMode = mode; + _settings.IbasesSyncFilePath = filePath ?? string.Empty; + _settings.IbasesSyncTrigger = trigger; + _settings.IbasesSyncIntervalMinutes = intervalMinutes > 0 ? intervalMinutes : 30; + _settings.IbasesSyncScheduleTime = scheduleTime ?? string.Empty; + _settings.IbasesBackupEnabled = backupEnabled; + _settings.IbasesBackupKeepCount = backupKeepCount > 0 ? backupKeepCount : 5; + + SaveSettingsSilently(); + RestartAutoSync(); + } + + // ---- Профиль: резервное копирование и восстановление ---- + + /// Каталог резервной копии профиля (настройки, базы, пользователи/пароли, ibases.v8i). + public string ProfileBackupDirectory => _settings.ProfileBackupDirectory; + + /// Восстанавливать профиль из каталога резервной копии при каждом запуске. + public bool ProfileRestoreOnStartup => _settings.ProfileRestoreOnStartup; + + /// Применяет настройки резервного копирования профиля из окна настроек. + public void ApplyProfileBackupSettings(string backupDirectory, bool restoreOnStartup) + { + _settings.ProfileBackupDirectory = backupDirectory?.Trim() ?? string.Empty; + _settings.ProfileRestoreOnStartup = restoreOnStartup; + SaveSettingsSilently(); + } + + /// + /// Сохраняет текущий профиль (настройки, список баз с пользователями и паролями, + /// группы, ibases.v8i) в настроенный каталог. Возвращает true при успехе. + /// + public bool BackupProfile() + { + var dir = _settings.ProfileBackupDirectory; + if (string.IsNullOrWhiteSpace(dir)) + { + _dialog.ShowWarning(LocalizationManager.T("Settings.Profile.NoDirectory")); + return false; + } + try + { + var count = ProfileBackupService.Backup(dir, _settings.IbasesSyncFilePath); + _logger.Info($"Резервная копия профиля сохранена в {dir} ({count} файлов)"); + _dialog.ShowInfo(string.Format(LocalizationManager.T("Settings.Profile.BackupDone"), count)); + return true; + } + catch (Exception ex) + { + _logger.Error("Ошибка резервного копирования профиля", ex); + _dialog.ShowError(string.Format(LocalizationManager.T("Settings.Profile.BackupFailed"), ex.Message)); + return false; + } + } + + /// + /// Восстанавливает профиль из настроенного каталога и перезагружает данные, + /// чтобы они применились без перезапуска. Возвращает true при успехе. + /// + public bool RestoreProfile() + { + var dir = _settings.ProfileBackupDirectory; + if (string.IsNullOrWhiteSpace(dir)) + { + _dialog.ShowWarning(LocalizationManager.T("Settings.Profile.NoDirectory")); + return false; + } + if (!ProfileBackupService.HasBackup(dir)) + { + _dialog.ShowWarning(LocalizationManager.T("Settings.Profile.NoBackup")); + return false; + } + try + { + var count = ProfileBackupService.Restore(dir, _settings.IbasesSyncFilePath); + _logger.Info($"Профиль восстановлен из {dir} ({count} файлов)"); + // Перезагружаем настройки, список баз и группы, чтобы они применились сразу. + Initialize(); + _dialog.ShowInfo(string.Format(LocalizationManager.T("Settings.Profile.RestoreDone"), count)); + return true; + } + catch (Exception ex) + { + _logger.Error("Ошибка восстановления профиля", ex); + _dialog.ShowError(string.Format(LocalizationManager.T("Settings.Profile.RestoreFailed"), ex.Message)); + return false; + } + } + + /// + /// Запоминает выбранную цветовую схему как активную и сохраняет её в слот + /// соответствующей базовой темы (светлой/тёмной), чтобы переключение тем + /// не сбрасывало настроенное оформление. + /// + public void ApplyColorScheme(Models.ColorScheme scheme) + { + if (scheme is null) + return; + var clone = scheme.Clone(); + ThemeManager.ApplyScheme(clone); + _settings.ActiveColorScheme = clone; + if (clone.IsDark) + _settings.DarkColorScheme = clone; + else + _settings.LightColorScheme = clone; + _settings.Theme = clone.BaseThemeName; + _themeName = _settings.Theme; + SaveSettingsSilently(); + OnPropertyChanged(nameof(ThemeName)); + } + + /// Сохранённая цветовая схема: окно настроек открывает редактор с неё, + /// а не с той, что применена предпросмотром. + public Models.ColorScheme ActiveColorScheme => _settings.ActiveColorScheme is { Colors.Count: > 0 } + ? _settings.ActiveColorScheme + : ThemeManager.GetBuiltInScheme(_settings.Theme) ?? Models.ColorScheme.CreateLight(); + + /// Предупреждение из окна настроек: диалоги живут в сервисе вьюмодели. + public void ShowWarning(string message) => _dialog.ShowWarning(message); + + /// Сообщение из окна настроек. + public void ShowInfo(string message) => _dialog.ShowInfo(message); + + /// Сообщение об ошибке из окна настроек. + public void ShowError(string message) => _dialog.ShowError(message); + + /// Запрос подтверждения из окна настроек. + public bool Confirm(string message) => _dialog.Confirm(message); + + /// Диалог выбора файла для окна настроек. + public string? PickFile(string title, string filter) => _dialog.OpenFileDialog(title, filter); + + /// Диалог сохранения файла для окна настроек. + public string? PickSaveFile(string title, string defaultFileName) => + _dialog.SaveFileDialog(title, defaultFileName); + + /// Диалог выбора каталога для окна настроек. + public string? PickFolder(string title) => _dialog.OpenFolderDialog(title); + + /// + /// Применяет настройки вкладки «Платформы»: дополнительные пути поиска + /// и разрядность по умолчанию. + /// + public void ApplyPlatformSettings(IEnumerable additionalPaths, string architecture) + { + _settings.AdditionalPlatformSearchPaths = additionalPaths + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + _settings.DefaultArchitecture = + string.Equals(architecture, "X86", StringComparison.OrdinalIgnoreCase) ? "X86" : "X64"; + + ApplyDefaultArchitecture(); + ApplyAdditionalSearchPaths(); + SaveSettingsSilently(); + } + + /// + /// Отдаёт дополнительные пути поиска самой платформе: вкладка настроек + /// передаёт их аргументом, а запуск читает статический список сервиса, + /// и без этой передачи платформа из нестандартного каталога была видна + /// в списке, но не находилась при запуске. + /// + private void ApplyAdditionalSearchPaths() => + PlatformVersionService.SetAdditionalSearchPaths(_settings.AdditionalPlatformSearchPaths); + + /// + /// Убирает из списка файловые базы, у которых нет каталога или файла базы. + /// Перед удалением показывается список того, что будет убрано. + /// + public void RemoveMissingFileBases() + { + var states = _allInfobases + .Select(ib => (Infobase: ib, State: InfobaseMaintenanceService.GetFileBaseState(ib))) + .ToList(); + + var missing = states + .Where(x => x.State == InfobaseMaintenanceService.FileBaseState.Missing) + .Select(x => x.Infobase) + .ToList(); + + // Базы с недоступного диска в удаление не идут: «проверить не удалось» + // это не «нет». О них сказано отдельно. + var unknown = states.Count(x => x.State == InfobaseMaintenanceService.FileBaseState.Unknown); + + if (missing.Count == 0) + { + _dialog.ShowInfo( + unknown == 0 + ? LocalizationManager.T("Main.MissingNone") + : string.Format(LocalizationManager.T("Main.MissingOnlyUnchecked"), unknown), + LocalizationManager.T("Main.CheckFileBasesTitle")); + return; + } + + var preview = string.Join("\n", missing.Take(15).Select(ib => "• " + ib.Name)); + if (missing.Count > 15) + preview += string.Format(LocalizationManager.T("Main.MissingMore"), missing.Count - 15); + if (unknown > 0) + preview += "\n\n" + string.Format(LocalizationManager.T("Main.MissingUnchecked"), unknown); + + if (!_dialog.Confirm( + string.Format(LocalizationManager.T("Main.MissingConfirm"), missing.Count, preview), + LocalizationManager.T("Main.RemoveMissingTitle"))) + return; + + // Сначала запись, потом замена списка в памяти: при ошибке диска + // пользователь остался бы с урезанным списком в окне и полным на диске, + // а следующее сохранение записало бы урезанный поверх. + var removing = new HashSet(missing, ReferenceEqualityComparer.Instance as IEqualityComparer); + var remaining = _allInfobases.Where(ib => !removing.Contains(ib)).ToList(); + if (!SaveList(remaining)) + { + _dialog.ShowError(LocalizationManager.T("Main.SaveFailedHint"), + LocalizationManager.T("Main.RemoveMissingTitle")); + return; + } + + _allInfobases.Clear(); + _allInfobases.AddRange(remaining); + + if (SelectedInfobase is { } selected && !_allInfobases.Contains(selected)) + SelectedInfobase = null; + + RebuildTree(); + _logger.Info($"Удалено отсутствующих файловых баз: {missing.Count}"); + + _dialog.ShowInfo( + string.Format(LocalizationManager.T("Main.MissingRemoved"), missing.Count), + LocalizationManager.T("Main.RemoveMissingTitle")); + } + + /// Завершает запущенные процессы платформы 1С. + public void KillOneCProcesses() + { + // Один снимок на вопрос и на действие: между ними процессы приходят + // и уходят, и завершать пришлось бы не то, что показано. + var snapshot = InfobaseMaintenanceService.SnapshotOneCProcesses(); + if (snapshot.Count == 0) + { + _dialog.ShowInfo(LocalizationManager.T("Main.NoProcesses"), + LocalizationManager.T("Main.OneCProcessesTitle")); + return; + } + + // Разбивка и предупреждение идут перед вопросом: ключ подтверждения + // общий с версией для Windows и заканчивается словом «Продолжить?», + // поэтому дописывать предупреждение после него нельзя. + var breakdown = InfobaseMaintenanceService.DescribeProcesses(snapshot); + var details = string.Format(LocalizationManager.T("Main.KillProcessesBreakdown"), + string.Join(", ", breakdown.Select(p => $"{p.Name}: {p.Count}"))); + + // Про остановку кластера предупреждаем только когда серверные процессы + // действительно в списке. + if (breakdown.Any(p => ServerProcessNames.Contains(p.Name, StringComparer.OrdinalIgnoreCase))) + details += " " + LocalizationManager.T("Main.KillProcessesServerNote"); + + var question = details + "\n\n" + + string.Format(LocalizationManager.T("Main.KillProcessesConfirm"), snapshot.Count); + + if (!_dialog.Confirm(question, LocalizationManager.T("Main.KillProcessesTitle"))) + return; + + var (killed, failed) = InfobaseMaintenanceService.KillOneCProcesses(snapshot); + _logger.Info($"Завершено процессов 1С: {killed}, не удалось: {failed}"); + + var message = string.Format(LocalizationManager.T("Main.ProcessesKilled"), killed); + if (failed > 0) + message += "\n" + string.Format(LocalizationManager.T("Main.ProcessesKillFailed"), failed); + + _dialog.ShowInfo(message, LocalizationManager.T("Main.OneCProcessesTitle")); + } + + /// Очищает список баз и групп целиком. Сами базы на диске не трогает. + public void ClearAllInfobases() + { + if (_allInfobases.Count == 0 && _groups.Count == 0) + { + _dialog.ShowInfo(LocalizationManager.T("Main.ClearAllAlreadyEmpty"), + LocalizationManager.T("Main.ClearAllTitle")); + return; + } + + if (!_dialog.Confirm( + string.Format(LocalizationManager.T("Main.ClearAllConfirm"), _allInfobases.Count, _groups.Count), + LocalizationManager.T("Main.ClearAllTitle"))) + return; + + // Пустые списки пишутся на диск до того, как очищается память: + // иначе при отказе записи в окне пусто, а на диске прежнее, и первое + // же следующее сохранение затирает уцелевшее. Файла два, поэтому при + // отказе на втором первый возвращается обратно: иначе на диске + // оставался бы пустой список баз при живых группах. + var previousInfobases = _allInfobases.ToList(); + if (!SaveList(new List())) + { + _dialog.ShowError(LocalizationManager.T("Main.SaveFailedHint"), + LocalizationManager.T("Main.ClearAllTitle")); + return; + } + + if (!SaveGroupList(new List())) + { + // Список баз уже записан пустым, возвращаем прежний. Если и это + // не удалось, на диске пусто, а в памяти нет: чтобы состояния + // сошлись, память тоже очищается, и об этом сказано отдельно. + if (!SaveList(previousInfobases)) + { + _allInfobases.Clear(); + _groups.Clear(); + SelectedInfobase = null; + RebuildTree(); + _dialog.ShowError(LocalizationManager.T("Main.ClearAllPartial"), + LocalizationManager.T("Main.ClearAllTitle")); + return; + } + + _dialog.ShowError(LocalizationManager.T("Main.SaveFailedHint"), + LocalizationManager.T("Main.ClearAllTitle")); + return; + } + + _allInfobases.Clear(); + _groups.Clear(); + SelectedInfobase = null; + + // Выгрузка в ibases.v8i намеренно не вызывается, как и в версии + // для Windows: экспорт убирает из файла записи, которых нет + // в приложении, и очистка списка вынесла бы пусковой список платформы. RebuildTree(); + _logger.Info("Список баз и групп очищен"); + + _dialog.ShowInfo(LocalizationManager.T("Main.ClearAllDone"), + LocalizationManager.T("Main.ClearAllTitle")); + } + + /// Добавлять ли метку времени к имени файла выгрузки. + public bool AddTimestampToExportFileName => _settings.AddTimestampToExportFileName; + + /// Формат метки времени в имени файла выгрузки. + public string ExportTimestampFormat => _settings.ExportTimestampFormat; + + /// Применяет настройки имени файла выгрузки со вкладки «Базы». + public void ApplyExportFileNameSettings(bool addTimestamp, string timestampFormat) + { + _settings.AddTimestampToExportFileName = addTimestamp; + _settings.ExportTimestampFormat = string.IsNullOrWhiteSpace(timestampFormat) + ? "yyyyMMdd_HHmmss" + : timestampFormat.Trim(); + SaveSettingsSilently(); + } + + /// Выгружает список баз и групп в JSON-файл. + public void ExportInfobases(bool addTimestamp, string timestampFormat) + { + if (_allInfobases.Count == 0) + { + _dialog.ShowInfo(LocalizationManager.T("Main.ExportEmpty"), + LocalizationManager.T("Main.ExportBasesTitle")); + return; + } + + var path = _dialog.SaveFileDialog( + LocalizationManager.T("Main.ExportBasesDialogTitle"), + BuildExportFileName("infobases_export", ".json", addTimestamp, timestampFormat), + LocalizationManager.T("Main.JsonFileFilter")); + if (string.IsNullOrWhiteSpace(path)) + return; + + try + { + var json = JsonSerializer.Serialize( + new InfobaseExportData + { + Infobases = _allInfobases.ToList(), + Groups = _groups.ToList() + }, + new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + + _dialog.ShowInfo( + string.Format(LocalizationManager.T("Main.ExportDone"), _allInfobases.Count, _groups.Count, path), + LocalizationManager.T("Main.ExportBasesTitle")); + _logger.Info($"Список баз выгружен в {path}"); + } + catch (Exception ex) + { + _logger.Error("Ошибка выгрузки списка баз", ex); + _dialog.ShowError( + string.Format(LocalizationManager.T("Main.ErrExportFailed"), ex.Message), + LocalizationManager.T("Main.ExportErrorTitle")); + } + } + + /// + /// Загружает список баз и групп из JSON-файла, заменяя текущий. + /// Понимает и старый формат, где в файле лежит только список баз. + /// + public void ImportInfobases() + { + var path = _dialog.OpenFileDialog( + LocalizationManager.T("Main.ImportBasesDialogTitle"), + LocalizationManager.T("Main.JsonFileFilter")); + if (string.IsNullOrWhiteSpace(path)) + return; + + try + { + var json = File.ReadAllText(path); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + + InfobaseExportData? exportData = null; + try { exportData = JsonSerializer.Deserialize(json, options); } + catch (JsonException) { } + + List loaded; + List loadedGroups; + if (exportData is not null && exportData.Infobases.Count > 0) + { + loaded = exportData.Infobases; + loadedGroups = exportData.Groups; + } + else + { + loaded = JsonSerializer.Deserialize>(json, options) ?? new List(); + loadedGroups = new List(); + } + + if (loaded.Count == 0) + { + _dialog.ShowWarning(LocalizationManager.T("Main.ImportNoBases"), + LocalizationManager.T("Main.LoadBasesTitle")); + return; + } + + if (!_dialog.Confirm( + string.Format(LocalizationManager.T("Main.ImportConfirm"), loaded.Count, loadedGroups.Count), + LocalizationManager.T("Main.LoadBasesTitle"))) + return; + + _allInfobases.Clear(); + _allInfobases.AddRange(loaded); + _groups.Clear(); + _groups.AddRange(loadedGroups); + SelectedInfobase = null; + + var saved = SaveSilently(); + saved &= SaveGroupsSilently(); + RebuildTree(); + + if (!saved) + { + // Список в памяти уже заменён, а на диске осталось прежнее + // состояние: сообщать об успехе нельзя. + _dialog.ShowError( + string.Format(LocalizationManager.T("Main.ErrLoadFailed"), + LocalizationManager.T("Main.SaveFailedHint")), + LocalizationManager.T("Main.LoadErrorTitle")); + return; + } + + _dialog.ShowInfo( + string.Format(LocalizationManager.T("Main.ImportDoneMsg"), loaded.Count, loadedGroups.Count), + LocalizationManager.T("Main.LoadBasesTitle")); + _logger.Info($"Список баз загружен из {path}"); + } + catch (Exception ex) + { + _logger.Error("Ошибка загрузки списка баз", ex); + _dialog.ShowError( + string.Format(LocalizationManager.T("Main.ErrLoadFailed"), ex.Message), + LocalizationManager.T("Main.LoadErrorTitle")); + } + } + + /// + /// Читает список баз из ibases.v8i и добавляет или обновляет базы. + /// Путь ищется сам, и только если файла нет, спрашивается у пользователя. + /// + public void ImportFromIbasesV8i() + { + var path = IbasesV8iImporter.FindDefaultPath(); + if (string.IsNullOrWhiteSpace(path)) + { + path = _dialog.OpenFileDialog( + LocalizationManager.T("Settings.Ibases.FileDialogTitle"), + LocalizationManager.T("Main.IbasesFileFilter")); + if (string.IsNullOrWhiteSpace(path)) + return; + } + + ImportFromIbasesFileInteractive(path); + } + + /// + /// Разовый импорт из ibases.v8i по требованию пользователя. Импортёр не + /// только добавляет и обновляет базы, но и удаляет те, которых в файле нет, + /// поэтому пустой или испорченный файл вычистил бы список. Ровно та же + /// защита стоит в автоматической синхронизации. + /// + private void ImportFromIbasesFileInteractive(string path) + { + var before = _allInfobases.Count; + + try + { + // Импорт идёт в копии списков: он удаляет базы, которых нет в файле, + // и до подтверждения пользователем рабочие списки трогать нельзя. + // Проверено исполнением: без этого разовый импорт молча заменил + // тридцать баз восемью из файла. + var candidateInfobases = _allInfobases.ToList(); + var candidateGroups = _groups.ToList(); + var result = _sync.Import(path, candidateInfobases, candidateGroups); + + if (before > 0 && candidateInfobases.Count == 0) + { + _logger.Warn($"Импорт из {path} не дал ни одной базы, список приложения не меняем"); + _dialog.ShowWarning(LocalizationManager.T("Main.ImportNoBases"), + LocalizationManager.T("Main.ImportIbasesTitle")); + return; + } + + if (result.Removed > 0 && !_dialog.Confirm( + string.Format(LocalizationManager.T("Main.ImportRemovesConfirm"), + result.Removed, result.Added, result.Updated), + LocalizationManager.T("Main.ImportIbasesTitle"))) + return; + + _allInfobases.Clear(); + _allInfobases.AddRange(candidateInfobases); + _groups.Clear(); + _groups.AddRange(candidateGroups); + + var saved = SaveSilently(); + // Импорт создаёт недостающие группы, и без их записи они пропадают + // при следующем запуске. + saved &= SaveGroupsSilently(); + RebuildTree(); + + // Выбранную базу мог удалить сам импорт. + if (SelectedInfobase is { } selected && !_allInfobases.Contains(selected)) + SelectedInfobase = null; + + StatusBarInfo = string.Format(LocalizationManager.T("Sync.ImportedCount"), _allInfobases.Count, _groups.Count); + _logger.Info($"Импорт из ibases.v8i: {path}, добавлено {result.Added}, обновлено {result.Updated}, удалено {result.Removed}"); + + if (!saved) + { + _dialog.ShowError( + string.Format(LocalizationManager.T("Main.ErrImportFailed"), + LocalizationManager.T("Main.SaveFailedHint")), + LocalizationManager.T("Main.ImportErrorTitle")); + return; + } + + _dialog.ShowInfo( + string.Format(LocalizationManager.T("Main.ImportDone"), + result.Added, result.Updated, result.Skipped, result.GroupsCreated), + LocalizationManager.T("Main.ImportIbasesTitle")); + } + catch (Exception ex) + { + _logger.Error("Ошибка импорта из ibases.v8i", ex); + _dialog.ShowError( + string.Format(LocalizationManager.T("Main.ErrImportFailed"), ex.Message), + LocalizationManager.T("Main.ImportErrorTitle")); + } + } + + /// Имя файла выгрузки с меткой времени, если она включена. + private static string BuildExportFileName(string baseName, string extension, bool addTimestamp, string timestampFormat) + { + if (!addTimestamp) + return $"{baseName}{extension}"; + + var format = string.IsNullOrWhiteSpace(timestampFormat) ? "yyyyMMdd_HHmmss" : timestampFormat; + try + { + return $"{baseName}_{DateTime.Now.ToString(format)}{extension}"; + } + catch (FormatException) + { + // Шаблон мог прийти из файла настроек, правленного руками. + return $"{baseName}_{DateTime.Now:yyyyMMdd_HHmmss}{extension}"; + } + } + + /// + /// Процессы, завершение которых бьёт не по клиентскому сеансу: кластер + /// серверов, сервер отладки, сервер данных и утилиты, держащие базу. + /// + private static readonly string[] ServerProcessNames = + { + "ragent", "rmngr", "rphost", "ras", "rac", "dbgs", "dbda", "ibsrv", "ibcmd", "crserver" + }; + + private bool SaveList(List infobases) + { + try { _repository.Save(infobases); return true; } + catch (Exception ex) { _logger.Error("Не удалось сохранить список баз", ex); return false; } + } + + private bool SaveGroupList(List groups) + { + try { _repository.SaveGroups(groups); return true; } + catch (Exception ex) { _logger.Error("Не удалось сохранить группы", ex); return false; } + } + + /// Каталоги шаблонов конфигураций, заданные пользователем. + public IReadOnlyList TemplateCatalogPaths => _settings.TemplateCatalogPaths; + + /// + /// Применяет настройки каталогов шаблонов со вкладки «Базы». Поиск шаблонов + /// читает статический список сервиса, поэтому без этой передачи заданные + /// каталоги оставались только в файле настроек. + /// + public void ApplyTemplateCatalogPaths(IEnumerable paths) + { + _settings.TemplateCatalogPaths = (paths ?? Enumerable.Empty()) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + ApplyTemplateCatalogPaths(); + SaveSettingsSilently(); + } + + private void ApplyTemplateCatalogPaths() => + OneCTemplateService.SetUserTemplatePaths(_settings.TemplateCatalogPaths); + + /// + /// Каталоги шаблонов, известные самой платформе: из её настроек и + /// стандартный tmplts. Кнопка «Из 1С» на вкладке «Базы» заполняет + /// список ими. + /// + public List DiscoverTemplateCatalogPaths() + { + var found = new List(); + try + { + var configured = OneCTemplateService.GetConfiguredOrDefaultTemplatePath(); + if (!string.IsNullOrWhiteSpace(configured)) + found.Add(configured); + found.AddRange(OneCTemplateService.GetTemplateRootFolders()); + } + catch (Exception ex) + { + _logger.Warn($"Не удалось прочитать каталоги шаблонов: {ex.Message}"); + } + + return found + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Отдаёт разрядность по умолчанию запуску платформы. Без этого настройка + /// в Linux-ветке не действовала: запуск всегда считал её x64. + /// + private void ApplyDefaultArchitecture() => + OneCLauncher.DefaultArchitecture = + string.Equals(_settings.DefaultArchitecture, "X86", StringComparison.OrdinalIgnoreCase) + ? OneCArchitecture.x86 + : OneCArchitecture.x64; + + private List InstalledPlatformVersions() + { + try { return _platformService.FindInstalledVersions(_settings.AdditionalPlatformSearchPaths); } + catch (Exception ex) + { + _logger.Warn($"Не удалось получить список версий платформы: {ex.Message}"); + return new List(); + } + } + + /// Приводит путь группы к каноническому виду: и «/», и «\\» как разделители. + private static string NormalizeGroupPath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + var parts = path + .Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => s.Length > 0); + return string.Join(GroupHierarchyHelper.PathSeparator, parts); + } + + /// Находит группу по полному пути с учётом нормализации разделителей. + private Group? FindGroupByFullPath(string? fullPath) + { + var target = NormalizeGroupPath(fullPath); + if (string.IsNullOrEmpty(target)) + return null; + return _groups.FirstOrDefault(g => + string.Equals(NormalizeGroupPath(GroupHierarchyHelper.GetFullPath(g, _groups)), target, + StringComparison.OrdinalIgnoreCase)); + } + + /// Серверы из уже зарегистрированных клиент-серверных баз (для автодополнения). + private IEnumerable AvailableServers() => _allInfobases + .Where(b => b?.Connection?.Type == ConnectionType.ClientServer) + .Select(b => b.Connection!.Server?.Trim() ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s, StringComparer.OrdinalIgnoreCase); + + /// Порты из уже зарегистрированных клиент-серверных баз. + private IEnumerable AvailablePorts() => _allInfobases + .Where(b => b?.Connection?.Type == ConnectionType.ClientServer) + .Select(b => b.Connection!.Port) + .Where(p => p > 0) + .Distinct() + .OrderBy(p => p); + + /// + /// Выгружает список баз в ibases.v8i после локального изменения. + /// Работает только если пользователь включил режим экспорта: по умолчанию + /// IbasesSyncMode.None, то есть файл платформы не трогается вовсе. + /// Перед записью снимается резервная копия, если она включена в настройках. + /// + private void ExportToIbasesAfterLocalChange() + { + if (_settings.IbasesSyncMode is not (IbasesSyncMode.Export or IbasesSyncMode.Both)) + return; + + var filePath = string.IsNullOrWhiteSpace(_settings.IbasesSyncFilePath) + ? IbasesV8iImporter.FindDefaultPath() + : _settings.IbasesSyncFilePath; + if (string.IsNullOrWhiteSpace(filePath)) + return; + + try + { + if (_settings.IbasesBackupEnabled && System.IO.File.Exists(filePath)) + { + try { IbasesBackupService.CreateBackup(filePath, _settings.IbasesBackupKeepCount); } + catch (Exception ex) { _logger.Warn($"Не удалось создать резервную копию ibases.v8i: {ex.Message}"); } + } + + _sync.Export(filePath, _allInfobases, _groups); + _logger.Info($"Список баз выгружен в {filePath}"); + } + catch (Exception ex) + { + _logger.Error("Ошибка выгрузки списка баз в ibases.v8i", ex); + SyncMessage = string.Format(LocalizationManager.T("Sync.ExportError"), ex.Message); + } + } + + /// + /// Ключ узла, пригодный для хранения в настройках, либо null. + /// Годятся только полный путь группы и внутренний маркер служебного узла: + /// они не зависят от языка интерфейса. У узла без группы и без маркера + /// NodeKey это отображаемое имя, то есть локализованная строка, которая + /// после смены языка перестанет совпадать, а с реальной группой такого же + /// имени ещё и столкнётся. + /// + private static string? PersistableNodeKey(GroupNodeViewModel node) + { + if (node.Group is not null && !string.IsNullOrEmpty(node.FullPath)) + return node.FullPath; + return string.IsNullOrEmpty(node.Marker) ? null : node.Marker; + } + + /// + /// Восстанавливает свёрнутость узла и его потомков из сохранённого набора. + /// + private void ApplyExpandedState(GroupNodeViewModel node) + { + var key = PersistableNodeKey(node); + if (key is not null) + node.SetExpandedSilent(!_collapsedGroups.Contains(key)); + foreach (var child in node.Children) + ApplyExpandedState(child); + } + + /// + /// Подписывает узел на запоминание свёрнутости. Раскрытие меняется и мышью + /// через привязку контейнера, и командами «развернуть все» / «свернуть все», + /// поэтому отслеживается само свойство, а не места его изменения. + /// + private void SubscribeExpandedTracking(GroupNodeViewModel node) + { + node.PropertyChanged -= OnNodeExpandedChanged; + node.PropertyChanged += OnNodeExpandedChanged; + foreach (var child in node.Children) + SubscribeExpandedTracking(child); + } + + private void OnNodeExpandedChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(GroupNodeViewModel.IsExpanded) || sender is not GroupNodeViewModel node) + return; + + var key = PersistableNodeKey(node); + if (key is null) + return; + + var changed = node.IsExpanded ? _collapsedGroups.Remove(key) : _collapsedGroups.Add(key); + if (!changed || _deferCollapsedSave) + return; + + PersistCollapsedGroups(); + } + + private void PersistCollapsedGroups() + { + _settings.CollapsedGroups = _collapsedGroups.ToList(); + SaveSettingsSilently(); + } + + /// Сохраняет группы, возвращая признак успеха: ошибка идёт в журнал. + private bool SaveGroupsSilently() => SaveGroupList(_groups); + + /// + /// Окно-владелец для модальных окон. Спрятанное в трей окно владельцем + /// быть не может: показ поверх невидимого окна роняет приложение. + /// + private static Avalonia.Controls.Window? OwnerWindow() + { + if (Avalonia.Application.Current?.ApplicationLifetime + is not Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + return null; + + foreach (var window in desktop.Windows) + { + if (window.IsActive && window.IsVisible) + return window; + } + + return desktop.MainWindow is { IsVisible: true } main ? main : null; + } + + private void DeleteInfobase() + { + // Выбран узел группы: удаляется группа, как в WPF-версии. + if (SelectedGroupNode?.Group is Group group) + { + DeleteGroup(group); + return; + } + + var ib = SelectedInfobase; + if (ib is null) + return; + + var dialog = new Configuration_Management.DeleteInfobaseWindow(ib); + if (!dialog.ShowDialogSync(OwnerWindow()) || !dialog.Confirmed) + return; + + if (dialog.DeletePhysically) + { + var error = InfobaseMaintenanceService.TryDeleteFileBasePhysically(ib); + if (error is not null) + { + _dialog.ShowError(error); + // Даже при ошибке на диске из списка удаляем, если пользователь подтвердит. + if (!_dialog.Confirm(LocalizationManager.T("Main.ConfirmDeleteFromList"))) + return; + } + } + + _allInfobases.Remove(ib); + SaveSilently(); + RebuildTree(); + SelectedInfobase = null; + ExportToIbasesAfterLocalChange(); + } + + /// + /// Открывает диалог редактирования конкретной группы (кнопка в колонке + /// «Действия» строки группы). Сохраняет изменения в существующем объекте, + /// чтобы иерархия по ParentId и все привязки остались валидными. + /// + private void EditGroup(Group group) + { + var dialog = new Configuration_Management.GroupEditWindow(_groups, group.ParentId, group); + if (!dialog.ShowDialogSync(OwnerWindow())) + return; + + group.Name = dialog.Result.Name; + group.Description = dialog.Result.Description; + group.Color = dialog.Result.Color; + group.IconColor = dialog.Result.IconColor ?? string.Empty; + group.Icon = dialog.Result.Icon ?? string.Empty; + group.ParentId = dialog.Result.ParentId; + + SaveGroupsSilently(); + RebuildTree(); + } + + /// + /// Возвращает группу из параметра команды (кнопка в колонке «Действия» строки группы): + /// параметром служит либо сам узел группы, либо модель группы. + /// + private static Group? ResolveGroup(object? parameter) => + parameter is Group g ? g : (parameter as GroupNodeViewModel)?.Group; + + /// + /// Удаляет группу. Группа с подгруппами или базами не удаляется: сначала + /// её надо освободить, как и в WPF-версии. + /// + private void DeleteGroup(Group group) + { + var subgroupCount = _groups.Count(g => + string.Equals(g.ParentId, group.Id, StringComparison.OrdinalIgnoreCase)); + + var groupPaths = CollectGroupPaths(group.Id); + var infobaseCount = _allInfobases.Count(ib => + !string.IsNullOrWhiteSpace(ib.Group) && groupPaths.Contains(ib.Group.Trim())); + + if (subgroupCount > 0 || infobaseCount > 0) + { + var reasons = new List(); + if (subgroupCount > 0) + reasons.Add(string.Format(LocalizationManager.T("Main.SubgroupsCount"), subgroupCount)); + if (infobaseCount > 0) + reasons.Add(string.Format(LocalizationManager.T("Main.InfobasesCount"), infobaseCount)); + + _dialog.ShowWarning( + string.Format(LocalizationManager.T("Main.DeleteGroupImpossible"), group.Name) + "\n\n" + + LocalizationManager.T("Main.DeleteGroupContains") + "\n" + + string.Join("\n", reasons.Select(r => "• " + r)) + ".\n\n" + + LocalizationManager.T("Main.DeleteGroupFirstMove")); + return; + } + + if (!_dialog.Confirm(string.Format(LocalizationManager.T("Main.DeleteGroupConfirm"), group.Name))) + return; + + _groups.Remove(group); + SelectedGroupNode = null; + SaveGroupsSilently(); + RebuildTree(); + } + + /// Предупреждение в журнал из окна: журнал живёт во вьюмодели. + public void LogWarning(string message) => _logger.Warn(message); + + /// Группы для окна: путь узла считается по этому же списку. + public IReadOnlyList Groups => _groups; + + /// + /// Перемещает базу в указанную группу (полный путь). + /// — база, перед которой вставить (null = в конец группы). + /// + public void MoveInfobaseToGroup(Infobase infobase, string groupFullPath, Infobase? insertBefore = null) + { + var targetPath = groupFullPath ?? string.Empty; + var targetNorm = NormalizeGroupPath(targetPath); + infobase.Group = string.IsNullOrEmpty(targetNorm) ? targetPath : targetNorm; + + var siblings = _allInfobases + .Where(i => !ReferenceEquals(i, infobase) + && string.Equals(NormalizeGroupPath(i.Group), targetNorm, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(i => i.SortOrder) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (insertBefore is not null + && siblings.Any(s => ReferenceEquals(s, insertBefore) + || string.Equals(s.Id, insertBefore.Id, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(insertBefore.Id))) + { + var index = siblings.FindIndex(s => + ReferenceEquals(s, insertBefore) + || (string.Equals(s.Id, insertBefore.Id, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(insertBefore.Id))); + siblings.Insert(Math.Max(0, index), infobase); + } + else + { + siblings.Add(infobase); + } + + for (var i = 0; i < siblings.Count; i++) + siblings[i].SortOrder = (i + 1) * 10; + + // Экспорт только после удачной записи: иначе ibases.v8i получит новую + // группу, а свой файл останется со старой, и перезапуск их разведёт. + if (SaveSilently()) + ExportToIbasesAfterLocalChange(); + RebuildTree(); + + // Выбор не менялся, а группа базы изменилась: подзаголовок правой панели + // считается от неё и сам об этом не узнает. + OnPropertyChanged(nameof(RightPanelSubtitle)); + } + + /// + /// Перемещает группу под другую группу (или в корень при пустом newParentId) + /// вместе со всеми вложенными подгруппами и информационными базами. + /// Обновляет ParentId и полные пути Infobase.Group у всей подветки. + /// + public void MoveGroupUnder(Group group, string newParentId) + { + newParentId ??= string.Empty; + if (string.Equals(group.Id, newParentId, StringComparison.OrdinalIgnoreCase)) + return; + + // Родитель тот же: сброс группы на её текущего родителя не должен + // переписывать три файла ради нулевого изменения. + if (string.Equals(group.ParentId, newParentId, StringComparison.OrdinalIgnoreCase)) + return; + + // Нельзя сделать родителем потомка этой группы (иначе цикл в иерархии). + if (!string.IsNullOrEmpty(newParentId) + && GroupHierarchyHelper.IsAncestorOrSelf(newParentId, group.Id, _groups)) + return; + + // Старые полные пути: сама группа + все потомки (до смены ParentId). + var oldPathsById = new Dictionary(StringComparer.OrdinalIgnoreCase); + var subtreeIds = new HashSet(StringComparer.OrdinalIgnoreCase) { group.Id }; + CollectGroupDescendants(group.Id, subtreeIds); + foreach (var id in subtreeIds) + { + var g = _groups.FirstOrDefault(x => + string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (g is not null) + oldPathsById[id] = GroupHierarchyHelper.GetFullPath(g, _groups); + } + + var oldRootPath = oldPathsById.TryGetValue(group.Id, out var orp) ? orp : string.Empty; + var oldRootNorm = NormalizeGroupPath(oldRootPath); + + // Меняем родителя только у перемещаемой группы; вложенные группы + // остаются её потомками через свои ParentId и переезжают вместе с ней. + group.ParentId = newParentId; + + var newRootPath = GroupHierarchyHelper.GetFullPath(group, _groups); + + // pathRemap: старый путь (и нормализованный) → новый канонический. + var pathRemap = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrEmpty(oldRootPath) && !string.IsNullOrEmpty(newRootPath)) + { + pathRemap[oldRootPath] = newRootPath; + pathRemap[oldRootNorm] = newRootPath; + } + + foreach (var id in subtreeIds) + { + var g = _groups.FirstOrDefault(x => + string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); + if (g is null || !oldPathsById.TryGetValue(id, out var oldPath)) + continue; + var newPath = GroupHierarchyHelper.GetFullPath(g, _groups); + if (string.IsNullOrEmpty(oldPath) || string.IsNullOrEmpty(newPath)) + continue; + pathRemap[oldPath] = newPath; + pathRemap[NormalizeGroupPath(oldPath)] = newPath; + } + + if (pathRemap.Count > 0) + { + // Длинные пути первыми — чтобы «A / B» не переписывался как префикс «A». + var remapByLength = pathRemap + .OrderByDescending(kv => kv.Key.Length) + .ToList(); + + foreach (var ib in _allInfobases) + { + var current = ib.Group?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(current)) + continue; + + var currentNorm = NormalizeGroupPath(current); + if (pathRemap.TryGetValue(current, out var mapped) + || pathRemap.TryGetValue(currentNorm, out mapped)) + { + ib.Group = mapped; + continue; + } + + // Префикс: база во вложенном пути, которого не было в pathRemap. + // Путь считается по нормализованному ключу, иначе база не найдёт + // свой узел при перестройке дерева и уедет в «Без группы». + foreach (var (oldKey, newKey) in remapByLength) + { + var oldKeyNorm = NormalizeGroupPath(oldKey); + if (string.IsNullOrEmpty(oldKeyNorm)) + continue; + var prefix = oldKeyNorm + GroupHierarchyHelper.PathSeparator; + if (!currentNorm.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + continue; + + ib.Group = newKey + currentNorm.Substring(oldKeyNorm.Length); + break; + } + + // Фолбэк на случай расхождений в формате пути: путь пересчитывается + // по старому корневому пути подветки. + if (!string.IsNullOrEmpty(oldRootNorm) + && !string.IsNullOrEmpty(newRootPath) + && (string.Equals(currentNorm, oldRootNorm, StringComparison.OrdinalIgnoreCase) + || currentNorm.StartsWith(oldRootNorm + GroupHierarchyHelper.PathSeparator, + StringComparison.OrdinalIgnoreCase))) + { + var suffix = currentNorm.Length > oldRootNorm.Length + ? currentNorm.Substring(oldRootNorm.Length) + : string.Empty; + ib.Group = newRootPath + suffix; + } + } + + if (_collapsedGroups.Count > 0) + { + var updated = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var key in _collapsedGroups) + { + if (pathRemap.TryGetValue(key, out var mapped) + || pathRemap.TryGetValue(NormalizeGroupPath(key), out mapped)) + updated.Add(mapped); + else if (!string.IsNullOrEmpty(oldRootNorm) + && NormalizeGroupPath(key).StartsWith(oldRootNorm + GroupHierarchyHelper.PathSeparator, + StringComparison.OrdinalIgnoreCase) + && pathRemap.TryGetValue(oldRootPath, out var newRoot)) + // Совпадение ищется по нормализованному пути, значит и суффикс + // режется от него же: иначе длины разойдутся и ключ станет битым. + updated.Add(newRoot + NormalizeGroupPath(key).Substring(oldRootNorm.Length)); + else + updated.Add(key); + } + _collapsedGroups.Clear(); + foreach (var k in updated) + _collapsedGroups.Add(k); + + // В WPF свёрнутость после переноса остаётся только в памяти: + // ключи переложены, а настройки не сохраняются. + PersistCollapsedGroups(); + } + } + + // Записываются оба файла, экспорт идёт только когда удались оба: иначе + // пути баз и дерево групп разъедутся, и базы уедут в «Без группы». + var saved = SaveSilently(); + saved &= SaveGroupsSilently(); + if (saved) + ExportToIbasesAfterLocalChange(); + RebuildTree(); + + // Группа выбранной базы могла измениться вместе с путём подветки, + // а подзаголовок правой панели считается от базы и об этом не узнает. + OnPropertyChanged(nameof(RightPanelSubtitle)); + } + + /// Полные пути группы и всех её потомков: по ним ищутся базы внутри. + private HashSet CollectGroupPaths(string groupId) + { + var ids = new HashSet(StringComparer.OrdinalIgnoreCase) { groupId }; + CollectGroupDescendants(groupId, ids); + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var id in ids) + { + var group = _groups.FirstOrDefault(g => string.Equals(g.Id, id, StringComparison.OrdinalIgnoreCase)); + if (group is not null) + paths.Add(GroupHierarchyHelper.GetFullPath(group, _groups)); + } + return paths; } - // ======================= Запуск / действия ======================= + private void CollectGroupDescendants(string parentId, ISet result) + { + foreach (var child in _groups.Where(g => string.Equals(g.ParentId, parentId, StringComparison.OrdinalIgnoreCase))) + { + if (result.Add(child.Id)) + CollectGroupDescendants(child.Id, result); + } + } - private void OnLaunched() + /// + /// Открывает диалог ввода ссылки на информационную базу и запускает её, + /// как «Перейти по ссылке» в стандартном загрузчике 1С. + /// + private void OpenInfobaseByLink() { - if (SelectedInfobase is not null) + var dialog = new Configuration_Management.LinkInputWindow(); + if (!dialog.ShowDialogSync(OwnerWindow()) || string.IsNullOrWhiteSpace(dialog.Result)) + return; + + var link = dialog.Result; + _logger.Info($"Запуск 1С по ссылке: {link}"); + if (!OneCLauncher.LaunchByLink(link)) { - SelectedInfobase.AddLaunchHistory(LocalizationManager.T("Main.LaunchAction")); - SaveSilently(); + _dialog.ShowError(string.Format(LocalizationManager.T("Main.ErrOpenLink"), link)); + return; } + + NotifyAfterLaunch(); } - private void EditInfobase() + private void ToggleFavorite() => ToggleFavoriteFor(SelectedInfobase); + + private void TogglePin() => TogglePinFor(SelectedInfobase); + + private void ToggleFavoriteFor(Infobase? infobase) { - var ib = SelectedInfobase; - if (ib is null) + if (infobase is null) return; - _dialog.ShowInfo(string.Format(LocalizationManager.T("Main.EditBaseInfo"), ib.Name)); + + infobase.IsFavorite = !infobase.IsFavorite; + SaveSilently(); + + // Состав списка меняется только при активном временном фильтре: там база + // может выпасть из выборки. Без фильтра строка перекрашивается сама + // по уведомлению модели, и пересобирать дерево незачем. + if (IsFilterModeActive()) + ApplyFilter(); } - private void AddInfobase() + private void TogglePinFor(Infobase? infobase) { - _dialog.ShowInfo(LocalizationManager.T("Main.AddBaseInfo")); + if (infobase is null) + return; + + infobase.IsPinned = !infobase.IsPinned; + SaveSilently(); + UpdatePinnedSection(infobase); } - private void DeleteInfobase() + /// + /// Точечно обновляет узел «Закреплённые», как это делает WPF-версия + /// (ApplyPinToggle / UpdatePinnedSection): полная пересборка дерева здесь + /// стоила бы пересоздания всех строк и потери выделения. Узел правится + /// всегда, а в видимый список попадает только когда он там уместен: + /// AllGroupNodes переживает и фильтр, и отключение группировки, и после + /// возврата к группам список берётся именно оттуда. + /// + private void UpdatePinnedSection(Infobase infobase) { - var ib = SelectedInfobase; - if (ib is null) + var pinnedVisible = _groupByGroup && !IsFilterModeActive(); + var pinned = AllGroupNodes.FirstOrDefault(node => node.Group is null + && string.Equals(node.Marker, GroupNodeViewModel.PinnedMarker, StringComparison.Ordinal)); + + // Закрепление меняет и порядок внутри своей группы: закреплённые идут + // первыми (GroupSortOrder). Без этого строка осталась бы на прежнем + // месте до следующей полной пересборки. + SortOwningNode(infobase); + + if (infobase.IsPinned) + { + if (pinned is null) + { + pinned = new GroupNodeViewModel(null, marker: GroupNodeViewModel.PinnedMarker); + pinned.Infobases.Add(infobase); + pinned.PopulateItems(_showEmptyGroups); + ApplyExpandedState(pinned); + SubscribeExpandedTracking(pinned); + AllGroupNodes.Insert(0, pinned); + if (pinnedVisible) + GroupNodes.Insert(0, pinned); + return; + } + + if (!pinned.Infobases.Contains(infobase)) + { + pinned.Infobases.Add(infobase); + SortNodeInfobases(pinned); + pinned.PopulateItems(_showEmptyGroups); + } + else + { + pinned.NotifyCountChanged(); + } + + return; + } + + if (pinned is null) return; - if (_dialog.Confirm(string.Format(LocalizationManager.T("Main.ConfirmDeleteBase"), ib.Name))) + + pinned.Infobases.Remove(infobase); + if (pinned.Infobases.Count > 0) { - _allInfobases.Remove(ib); - SaveSilently(); - RebuildTree(); - SelectedInfobase = null; + pinned.PopulateItems(_showEmptyGroups); + return; } + + AllGroupNodes.Remove(pinned); + GroupNodes.Remove(pinned); } - private void ToggleFavorite() + /// + /// Переупорядочивает узел, в котором лежит база, кроме служебного узла + /// «Закреплённые»: его состав меняется отдельно. + /// + private void SortOwningNode(Infobase infobase) { - if (SelectedInfobase is Infobase ib) + foreach (var root in AllGroupNodes) { - ib.IsFavorite = !ib.IsFavorite; - SaveSilently(); - ApplyFilter(); + var owner = FindNodeWith(root, infobase); + if (owner is null + || string.Equals(owner.Marker, GroupNodeViewModel.PinnedMarker, StringComparison.Ordinal)) + continue; + + SortNodeInfobases(owner); + owner.PopulateItems(_showEmptyGroups); + return; + } + } + + /// Ищет узел дерева, в списке баз которого лежит указанная база. + private static GroupNodeViewModel? FindNodeWith(GroupNodeViewModel node, Infobase infobase) + { + if (node.Infobases.Contains(infobase)) + return node; + + foreach (var child in node.Children) + { + var found = FindNodeWith(child, infobase); + if (found is not null) + return found; } + + return null; } - private void TogglePin() + /// Переупорядочивает базы узла по текущему полю сортировки. + private void SortNodeInfobases(GroupNodeViewModel node) { - if (SelectedInfobase is Infobase ib) + var sorted = ApplyCurrentSort(node.Infobases).ToList(); + node.SetNotificationsSuppressed(true); + try { - ib.IsPinned = !ib.IsPinned; - SaveSilently(); - RebuildTree(); + node.Infobases.Clear(); + foreach (var infobase in sorted) + node.Infobases.Add(infobase); + } + finally + { + node.SetNotificationsSuppressed(false); } } @@ -678,19 +3044,13 @@ private void CopyConnectionString() private void OpenSettings() { var settings = new Configuration_Management.SettingsWindow(this); - if (Avalonia.Application.Current?.ApplicationLifetime - is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) - { - var owner = desktop.MainWindow; - if (owner is not null) - settings.ShowDialog(owner); - else - settings.Show(); - } + // Владелец берётся только видимый: окно, спрятанное в трей, остаётся + // в списке окон приложения, и показ поверх него ничего не показывает. + // Настройки открываются из меню трея именно в таком состоянии. + if (OwnerWindow() is { } owner) + settings.ShowDialog(owner); else - { settings.Show(); - } } /// @@ -705,9 +3065,112 @@ is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetim main.ApplyCompactMode(compact); } - private void RefreshAllConfigurationInfo() + /// + /// Проверяет доступность всех баз 1С и помечает недоступные красным крестиком + /// в списке баз. Ручная команда верхней панели команд вместо автопроверки при + /// запуске: старт не блокируется запросами ко всем базам. Доступность + /// определяется по факту: для файловых баз — наличие каталога/файла базы по + /// пути, для клиент-серверных — реальная попытка подключения, для веб-баз — + /// заполненность адреса. Проверки выполняются в фоне, чтобы не блокировать UI. + /// + private void CheckAvailability() + { + var targets = Infobases.ToList(); + if (targets.Count == 0) + { + ShowTemporaryStatusMessage(LocalizationManager.T("Main.ConfigListEmpty")); + return; + } + + _ = Task.Run(() => + { + // Результаты собираем заранее, чтобы не трогать модель из фонового потока. + var results = new List<(Infobase Base, bool Available)>(targets.Count); + foreach (var ib in targets) + results.Add((ib, IsBaseAvailable(ib))); + + Dispatcher.UIThread.Post(() => + { + foreach (var (ib, available) in results) + ib.SetCheckedAvailability(available); + RebuildTree(); + + var total = results.Count; + var unavailable = results.Count(r => !r.Available); + ShowTemporaryStatusMessage(string.Format( + LocalizationManager.T("Main.AvailabilityStatus"), total, unavailable)); + }); + }); + } + + /// + /// Показывает сообщение в нижней строке состояния на 10 секунд, после чего + /// возвращает обычный текст. Повторный вызов сбрасывает предыдущий таймер. + /// + private void ShowTemporaryStatusMessage(string message) + { + _statusMessageCts?.Cancel(); + _statusMessageCts?.Dispose(); + _statusMessageCts = null; + + StatusBarInfo = message; + + var cts = new System.Threading.CancellationTokenSource(); + _statusMessageCts = cts; + var token = cts.Token; + _ = ClearStatusMessageAfterDelayAsync(token); + } + + private async Task ClearStatusMessageAfterDelayAsync(System.Threading.CancellationToken token) + { + try + { + await Task.Delay(TimeSpan.FromSeconds(10), token).ConfigureAwait(true); + if (!token.IsCancellationRequested) + UpdateStatus(); + } + catch (TaskCanceledException) + { + // новое сообщение заменило предыдущее + } + } + + /// + /// Доступность отдельной базы. Файловая — есть ли каталог/файл по пути; + /// клиент-серверная — удалось ли подключиться; веб-база — заполнен ли адрес. + /// Для клиент-серверных баз выполняется реальная попытка подключения через + /// COM-коннектор (на Linux недоступна, поэтому такие базы считаются недоступными). + /// + private static bool IsBaseAvailable(Infobase ib) { - _dialog.ShowInfo(LocalizationManager.T("Main.RefreshConfigInfoMsg")); + try + { + switch (ib.Connection?.Type) + { + case ConnectionType.File: + // Наличие каталога или файла базы по пути. + return InfobaseMaintenanceService.FileBaseExists(ib); + + case ConnectionType.ClientServer: + { + // На Linux COM-коннектор отсутствует (Connect возвращает null), поэтому + // проверить доступность клиент-серверной базы по сети нельзя. Считать её + // полным DumpCfg конфигуратора для каждой базы слишком дорого — не пробуем, + // база считается недоступной (как и документировано выше). + return false; + } + + case ConnectionType.WebServer: + return !string.IsNullOrWhiteSpace(ib.Connection.WebUrl); + + default: + return false; + } + } + catch + { + return false; + } } // ======================= Этап 6: папки / ярлыки / стартер ======================= @@ -723,7 +3186,7 @@ private void RefreshAllConfigurationInfo() /// Все информационные базы (для диалога выбора при очистке кеша). public IReadOnlyList Infobases => _allInfobases; - /// Открыть каталог файловой базы в файловом менеджере (xdg-open/nautilus). + /// Открыть каталог файловой базы в файловом менеджере рабочего стола. private void OpenInfobaseFolder() { var ib = SelectedInfobase; @@ -752,38 +3215,272 @@ private void OpenNativeStarter() _dialog.ShowError(LocalizationManager.T("Main.ErrStartStarter")); } - private void SynchronizeWithIbases() + /// + /// Перезапускает автоматическую синхронизацию по настройкам. При выключенной + /// синхронизации и при режиме «при старте» таймер не нужен. + /// + private void RestartAutoSync() { - var path = _dialog.OpenFileDialog( - LocalizationManager.T("Sync.ChooseIbasesFile"), - LocalizationManager.T("Sync.IbasesFilter")); - if (string.IsNullOrWhiteSpace(path)) + StopAutoSync(); + + if (_settings.IbasesSyncMode == IbasesSyncMode.None + || _settings.IbasesSyncTrigger == IbasesSyncTrigger.OnStartup) + return; + + if (!ComputeNextRunTime(out var nextRun)) + return; + + _nextScheduleRun = nextRun; + _syncTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromMinutes(1) }; + _syncTimer.Tick += OnSyncTimerTick; + _syncTimer.Start(); + _logger.Info($"Автосинхронизация с ibases.v8i включена, следующий запуск {nextRun:HH:mm}"); + } + + private void StopAutoSync() + { + if (_syncTimer is null) + return; + + _syncTimer.Stop(); + _syncTimer.Tick -= OnSyncTimerTick; + _syncTimer = null; + _nextScheduleRun = null; + } + + private void OnSyncTimerTick(object? sender, EventArgs e) + { + if (_settings.IbasesSyncMode == IbasesSyncMode.None) + { + RestartAutoSync(); + return; + } + + if (_nextScheduleRun is null) + { + if (ComputeNextRunTime(out var next)) + _nextScheduleRun = next; + return; + } + + if (DateTime.Now < _nextScheduleRun.Value) + return; + + SynchronizeSilently(); + if (ComputeNextRunTime(out var following)) + _nextScheduleRun = following; + } + + /// + /// Время следующего запуска: для интервала это «сейчас плюс интервал», + /// для расписания ближайшее заданное время, завтра если сегодня прошло. + /// + private bool ComputeNextRunTime(out DateTime nextRun) + { + nextRun = default; + + if (_settings.IbasesSyncTrigger == IbasesSyncTrigger.Interval) + { + nextRun = DateTime.Now.AddMinutes(Math.Max(1, _settings.IbasesSyncIntervalMinutes)); + return true; + } + + if (_settings.IbasesSyncTrigger == IbasesSyncTrigger.Schedule) + { + // Строгий разбор ЧЧ:ММ: обычный TryParse принимает и локальные + // форматы, и длительности вроде 25:00, а это время суток. + if (!TimeSpan.TryParseExact(_settings.IbasesSyncScheduleTime?.Trim(), @"hh\:mm", + System.Globalization.CultureInfo.InvariantCulture, out var time) + || time < TimeSpan.Zero || time >= TimeSpan.FromDays(1)) + { + _logger.Warn($"Автосинхронизация выключена: время расписания «{_settings.IbasesSyncScheduleTime}» не распознано, ожидается ЧЧ:ММ"); + return false; + } + + var now = DateTime.Now; + var run = now.Date + time; + if (run <= now) + run = run.AddDays(1); + + nextRun = run; + return true; + } + + return false; + } + + /// Путь к файлу списка баз: заданный в настройках или стандартный. + private string? ResolveIbasesFilePath() => + string.IsNullOrWhiteSpace(_settings.IbasesSyncFilePath) + ? IbasesV8iImporter.FindDefaultPath() + : _settings.IbasesSyncFilePath; + + /// + /// Синхронизация без диалогов: для запуска по расписанию и при старте. + /// В двустороннем режиме сначала выгрузка, затем загрузка, как в WPF-версии. + /// + private void SynchronizeSilently() + { + if (_settings.IbasesSyncMode == IbasesSyncMode.None) + return; + + var filePath = ResolveIbasesFilePath(); + if (filePath is null) + { + _logger.Warn("Автосинхронизация: файл ibases.v8i не найден"); return; + } + + var done = false; + + // Выгрузка и загрузка разделены: отказ одной не должен отменять другую, + // как это устроено в WPF-версии. + if (_settings.IbasesSyncMode is IbasesSyncMode.Export or IbasesSyncMode.Both) + done |= ExportToIbases(filePath); + + if (_settings.IbasesSyncMode is IbasesSyncMode.Import or IbasesSyncMode.Both) + done |= ImportFromIbases(filePath); + + SyncMessage = done + ? LocalizationManager.T("Sync.Completed") + : LocalizationManager.T("Sync.Failed"); + } + + /// Выгрузка списка баз в файл платформы с резервной копией. + private bool ExportToIbases(string filePath) + { + try + { + if (_settings.IbasesBackupEnabled && System.IO.File.Exists(filePath)) + { + try { IbasesBackupService.CreateBackup(filePath, _settings.IbasesBackupKeepCount); } + catch (Exception ex) { _logger.Error("Не удалось создать резервную копию ibases.v8i", ex); } + } + + _sync.Export(filePath, _allInfobases, _groups); + _logger.Info($"Автосинхронизация: выгрузка в {filePath}"); + return true; + } + catch (Exception ex) + { + _logger.Error("Ошибка выгрузки в ibases.v8i", ex); + return false; + } + } + + /// + /// Загрузка списка баз из файла платформы. Импорт удаляет из приложения базы, + /// которых нет в файле, поэтому отсутствующий и подозрительно пустой файл + /// пропускаются: иначе повреждённый файл вычистил бы список без спроса. + /// + private bool ImportFromIbases(string filePath) + { + if (!System.IO.File.Exists(filePath)) + { + _logger.Warn($"Автосинхронизация: файла {filePath} нет, загрузка пропущена"); + return false; + } + + var before = _allInfobases.Count; try { - var importResult = _sync.Import(path, _allInfobases, _groups); + var result = _sync.Import(filePath, _allInfobases, _groups); + + if (before > 0 && _allInfobases.Count == 0) + { + _logger.Warn($"Автосинхронизация: файл {filePath} не дал ни одной базы, список приложения не меняем"); + _allInfobases = _repository.Load(); + RebuildTree(); + return false; + } + SaveSilently(); + // Импорт создаёт недостающие группы, и без этого они пропадали + // при следующем запуске: сохраняется только список баз. + SaveGroupsSilently(); RebuildTree(); - SyncMessage = LocalizationManager.T("Sync.Completed"); - StatusBarInfo = string.Format(LocalizationManager.T("Sync.ImportedCount"), _allInfobases.Count, _groups.Count); - _logger.Info($"Синхронизация с ibases.v8i: {path}"); + + // Выбранная база могла быть удалена импортом. + if (SelectedInfobase is { } selected && !_allInfobases.Contains(selected)) + SelectedInfobase = null; + + _logger.Info($"Автосинхронизация: загрузка из {filePath}, добавлено {result.Added}, обновлено {result.Updated}, удалено {result.Removed}"); + return true; } catch (Exception ex) { - _logger.Error("Ошибка синхронизации с ibases.v8i", ex); - _dialog.ShowError(string.Format(LocalizationManager.T("Sync.ErrSyncFailed"), ex.Message)); - SyncMessage = LocalizationManager.T("Sync.Failed"); + _logger.Error("Ошибка загрузки из ibases.v8i", ex); + return false; } } + private void SynchronizeWithIbases() + { + var path = _dialog.OpenFileDialog( + LocalizationManager.T("Sync.ChooseIbasesFile"), + LocalizationManager.T("Sync.IbasesFilter")); + if (string.IsNullOrWhiteSpace(path)) + return; + + // Тот же путь, что и у кнопки на вкладке «Базы»: раньше здесь терялись + // созданные импортом группы и оставался выбор на удалённой базе. + ImportFromIbasesFileInteractive(path); + SyncMessage = LocalizationManager.T("Sync.Completed"); + } + private void ToggleTheme() { - ThemeName = ThemeManager.ToggleTheme(); - _settings.Theme = ThemeName; + var targetDark = !ThemeManager.CurrentScheme.IsDark; + ApplySchemeForTheme(targetDark); + } + + /// Применяет выбранную базовую тему (светлую/тёмную), сохраняя раздельные схемы. + public void ApplyTheme(string theme) + { + ApplySchemeForTheme(IsDarkTheme(theme)); + } + + /// + /// Возвращает схему для базовой темы («Light»/«Dark»): сохранённую пользовательскую + /// (если есть) или встроенную по умолчанию. Не изменяет настройки. + /// + public Models.ColorScheme GetSchemeForTheme(string theme) + => SchemeForTheme(IsDarkTheme(theme)); + + /// + /// Применяет схему указанной базовой темы, обновляя активную схему и настройки. + /// Каждая базовая тема (светлая/тёмная) имеет собственную схему, поэтому правки + /// одной темы не влияют на другую. + /// + private void ApplySchemeForTheme(bool dark) + { + var scheme = SchemeForTheme(dark); + ThemeManager.ApplyScheme(scheme); + _settings.ActiveColorScheme = scheme; + _settings.Theme = scheme.BaseThemeName; + _themeName = _settings.Theme; SaveSettingsSilently(); + OnPropertyChanged(nameof(ThemeName)); + } + + /// + /// Возвращает схему для базовой темы: сохранённую пользовательскую (если есть), + /// иначе — встроенную по умолчанию (миграция со старого одиночного ActiveColorScheme). + /// + private Models.ColorScheme SchemeForTheme(bool dark) + { + var slot = dark ? _settings.DarkColorScheme : _settings.LightColorScheme; + if (slot is { Colors.Count: > 0 }) + return slot; + if (_settings.ActiveColorScheme is { Colors.Count: > 0 } && _settings.ActiveColorScheme.IsDark == dark) + return _settings.ActiveColorScheme; + return dark ? Models.ColorScheme.CreateDark() : Models.ColorScheme.CreateLight(); } + private static bool IsDarkTheme(string? theme) + => string.Equals(theme, ThemeManager.DarkThemeName, StringComparison.OrdinalIgnoreCase); + /// /// Применяет выбранный язык интерфейса и сохраняет его в настройках. /// Локализация применяется сразу (обновляются окна с привязками Loc) и @@ -795,6 +3492,7 @@ public void ApplyLanguage(string code) if (string.IsNullOrWhiteSpace(code)) return; + Console.Error.WriteLine("[l10n-debug] MainViewModel.ApplyLanguage(" + code + ")"); _settings.Language = code; SaveSettingsSilently(); @@ -808,19 +3506,38 @@ public void ApplyLanguage(string code) } } - private void ExitApplication() + /// + /// Сохраняет текущие настройки (включая язык интерфейса) на диск. + /// Вызывается при закрытии окна в трей и при полном выходе, чтобы + /// выбранный язык не терялся между запусками. + /// + public void PersistSettings() { + _settings.Language = Configuration_Management.Localization.LocalizationManager.Instance.CurrentLanguage; + Console.Error.WriteLine("[l10n-debug] PersistSettings language=" + _settings.Language); SaveSettingsSilently(); + } + + private void ExitApplication() + { + // Гарантируем сохранение выбранного языка при завершении программы: + // если язык определился автоматически (по системе) и не сохранялся через + // ApplyLanguage, записываем текущий код, чтобы он не потерялся между запусками. + PersistSettings(); if (Avalonia.Application.Current?.ApplicationLifetime is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) desktop.Shutdown(); } // ======================= Сохранение ======================= - private void SaveSilently() + /// Сохраняет список баз, возвращая признак успеха: ошибка идёт в журнал. + private bool SaveSilently() => SaveList(_allInfobases); + + /// Сохраняет настройки и сообщает, удалось ли. + private bool SaveSettingsSafe() { - try { _repository.Save(_allInfobases); } - catch (Exception ex) { _logger.Error("Не удалось сохранить список баз", ex); } + try { _repository.SaveSettings(_settings); return true; } + catch (Exception ex) { _logger.Error("Не удалось сохранить настройки", ex); return false; } } private void SaveSettingsSilently() @@ -878,12 +3595,28 @@ private void NotifyColumnSettings() OnPropertyChanged(nameof(ServerColumnWidth)); OnPropertyChanged(nameof(LastLaunchColumnWidth)); OnPropertyChanged(nameof(SizeColumnWidth)); + OnPropertyChanged(nameof(ColumnOrderKeys)); } private void NotifySessionSettings() + { + OnPropertyChanged(nameof(ShowSessionLaunchPanel)); + NotifySessionValues(); + } + + private void NotifySessionValues() { OnPropertyChanged(nameof(SessionClient)); OnPropertyChanged(nameof(SessionArch)); + // Переключатели привязаны к производным признакам, а не к самим строкам. + OnPropertyChanged(nameof(IsSessionClientAuto)); + OnPropertyChanged(nameof(IsSessionClientOrdinary)); + OnPropertyChanged(nameof(IsSessionClientThick)); + OnPropertyChanged(nameof(IsSessionClientThickOrdinary)); + OnPropertyChanged(nameof(IsSessionClientThin)); + OnPropertyChanged(nameof(IsSessionArchAuto)); + OnPropertyChanged(nameof(IsSessionArch32)); + OnPropertyChanged(nameof(IsSessionArch64)); } // ======================= Очистка кеша 1С ======================= @@ -935,8 +3668,11 @@ private void OpenCacheClean(OneCCacheKind kind) return; } - var dialog = new CacheCleanWindow(Infobases, kind, SelectedInfobase); - if (!dialog.ShowSync()) + // Список отдаётся копией и окно открывается модально: иначе пока оно + // открыто, список баз можно очистить из главного окна, и очистка + // остатков посчитает остатками уже весь кеш. + var dialog = new CacheCleanWindow(Infobases.ToList(), kind, SelectedInfobase); + if (!dialog.ShowSync(OwnerWindow())) return; var infobases = dialog.SelectedInfobases; diff --git a/Configuration Management/ViewModels/MainViewModel.cs b/Configuration Management/ViewModels/MainViewModel.cs index 14ece926..c3095e97 100644 --- a/Configuration Management/ViewModels/MainViewModel.cs +++ b/Configuration Management/ViewModels/MainViewModel.cs @@ -1,3 +1,5 @@ +#if WINDOWS +using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; using System.IO; @@ -37,6 +39,12 @@ public class MainViewModel : ViewModelBase private string _noGroupIcon = string.Empty; private string _savedTheme = string.Empty; private ColorScheme? _activeColorScheme; + /// Пользовательская схема светлой темы (кастомизация хранится независимо от тёмной). + private ColorScheme? _lightColorScheme; + /// Пользовательская схема тёмной темы (кастомизация хранится независимо от светлой). + private ColorScheme? _darkColorScheme; + /// Флаг подписки на событие смены языка (предотвращает дублирование подписки). + private bool _languageChangedSubscribed; private readonly HashSet _collapsedGroups = new(StringComparer.OrdinalIgnoreCase); private List _installedPlatformVersions = new(); private List _additionalPlatformSearchPaths = new(); @@ -56,6 +64,7 @@ public class MainViewModel : ViewModelBase private bool _showVersionColumn = true; private bool _showConfigurationColumn = true; private double _configurationColumnWidth; + private double _actionsColumnWidth; private bool _showRightPanelDetails = true; private bool _statusShowConnectionPath = true; private bool _statusShowArchitecture = true; @@ -80,6 +89,7 @@ public class MainViewModel : ViewModelBase private bool _showLastLaunchColumn = true; private bool _showSizeColumn = true; private double _sizeColumnWidth; + private List _columnOrder = new(); private double _windowWidth; private double _windowHeight; private double _windowLeft; @@ -92,6 +102,9 @@ public class MainViewModel : ViewModelBase private IbasesSyncMode _ibasesSyncMode = IbasesSyncMode.None; private string _ibasesSyncFilePath = string.Empty; private IbasesSyncTrigger _ibasesSyncTrigger = IbasesSyncTrigger.OnStartup; + // ---- Профиль: резервное копирование в произвольный каталог ---- + private string _profileBackupDirectory = string.Empty; + private bool _profileRestoreOnStartup; private int _ibasesSyncIntervalMinutes = 30; private string _ibasesSyncScheduleTime = "09:00"; private bool _ibasesBackupEnabled = true; @@ -106,6 +119,7 @@ public class MainViewModel : ViewModelBase private bool _showTrayIcon = true; private bool _compactMode; private bool _escapeToTray = true; + private string _afterLaunchAction = "None"; private List _templateCatalogPaths = new(); private string _hotkeyEnterprise = "F3"; private string _hotkeyConfigurator = "F4"; @@ -182,17 +196,24 @@ public MainViewModel( _elementFonts = settings.ElementFonts is null ? new Dictionary() : new Dictionary(settings.ElementFonts); - // Активная цветовая схема: если сохранена пользовательская/настроенная тема — используем её, - // иначе встроенную схему по выбранной теме оформления. + // Раздельные пользовательские схемы для светлой и тёмной темы: кастомизация + // каждой базовой темы хранится независимо, поэтому переключение тем не затирает + // настроенное оформление. + _lightColorScheme = settings.LightColorScheme; + _darkColorScheme = settings.DarkColorScheme; + // Миграция: если задан только старый одиночный ActiveColorScheme, переносим его + // в слот соответствующей базовой темы. if (settings.ActiveColorScheme is { Colors.Count: > 0 }) { - _activeColorScheme = settings.ActiveColorScheme; - } - else - { - var baseTheme = string.IsNullOrWhiteSpace(_savedTheme) ? Themes.ThemeManager.LightThemeName : _savedTheme; - _activeColorScheme = Themes.ThemeManager.GetBuiltInScheme(baseTheme) ?? Models.ColorScheme.CreateLight(); + if (settings.ActiveColorScheme.IsDark && _darkColorScheme is not { Colors.Count: > 0 }) + _darkColorScheme = settings.ActiveColorScheme; + else if (!settings.ActiveColorScheme.IsDark && _lightColorScheme is not { Colors.Count: > 0 }) + _lightColorScheme = settings.ActiveColorScheme; } + var baseTheme = string.IsNullOrWhiteSpace(_savedTheme) + ? ((_darkColorScheme is { Colors.Count: > 0 }) ? Themes.ThemeManager.DarkThemeName : Themes.ThemeManager.LightThemeName) + : _savedTheme; + _activeColorScheme = SchemeForTheme(IsDarkTheme(baseTheme)); _additionalPlatformSearchPaths = new List(settings.AdditionalPlatformSearchPaths ?? new List()); PlatformVersionService.SetAdditionalSearchPaths(_additionalPlatformSearchPaths); // Актуальный список с диска (Program Files + доп. пути, напр. E:\1cPlatform) @@ -212,6 +233,7 @@ public MainViewModel( _showVersionColumn = settings.ShowVersionColumn; _showConfigurationColumn = settings.ShowConfigurationColumn; _configurationColumnWidth = settings.ConfigurationColumnWidth; + _actionsColumnWidth = settings.ActionsColumnWidth; _showRightPanelDetails = settings.ShowRightPanelDetails; _showSessionLaunchPanel = settings.ShowSessionLaunchPanel; if (Enum.TryParse(settings.SessionClientMode, true, out var scm)) @@ -236,6 +258,9 @@ public MainViewModel( _showLastLaunchColumn = settings.ShowLastLaunchColumn; _showSizeColumn = settings.ShowSizeColumn; _sizeColumnWidth = settings.SizeColumnWidth; + _columnOrder = settings.ColumnOrder is { Count: > 0 } + ? new List(settings.ColumnOrder) + : new List(); _windowWidth = settings.WindowWidth; _windowHeight = settings.WindowHeight; _windowLeft = settings.WindowLeft; @@ -249,6 +274,8 @@ public MainViewModel( _ibasesSyncScheduleTime = settings.IbasesSyncScheduleTime; _ibasesBackupEnabled = settings.IbasesBackupEnabled; _ibasesBackupKeepCount = settings.IbasesBackupKeepCount > 0 ? settings.IbasesBackupKeepCount : 5; + _profileBackupDirectory = settings.ProfileBackupDirectory ?? string.Empty; + _profileRestoreOnStartup = settings.ProfileRestoreOnStartup; _addTimestampToExportFileName = settings.AddTimestampToExportFileName; _exportTimestampFormat = string.IsNullOrWhiteSpace(settings.ExportTimestampFormat) ? "yyyyMMdd_HHmmss" @@ -256,6 +283,7 @@ public MainViewModel( _closeToTray = settings.CloseToTray; _showTrayIcon = settings.ShowTrayIcon; _escapeToTray = settings.EscapeToTray; + _afterLaunchAction = settings.AfterLaunchAction ?? "None"; _compactMode = settings.CompactMode; _templateCatalogPaths = settings.TemplateCatalogPaths?.Where(p => !string.IsNullOrWhiteSpace(p)).Distinct(StringComparer.OrdinalIgnoreCase).ToList() ?? new List(); @@ -304,13 +332,21 @@ public MainViewModel( SyncFavoriteHotkeys(); // Размеры и маркеры блокировки файловых баз (фоново, не блокируя UI дольше необходимого). + // Автопроверка доступности всех баз 1С при запуске убрана: вместо неё — отдельная + // команда «Проверить доступность всех баз» в верхней панели команд. RefreshFileMetadata(); - RefreshConfigurationInfoAsync(); // Дерево групп (отображается в виде «группа в группе»). GroupNodes = new ObservableCollection(); RebuildGroupTree(); + // Смена языка интерфейса: локализованные свойства VM и служебные узлы дерева + // обновляются на лету без перезапуска. XAML-привязки {loc:Loc} обновляются сами + // через LocalizationManager.Source.NotifyAll(), а здесь обновляем свойства VM, + // возвращающие LocalizationManager.T(...), и пересобираем дерево групп. + LocalizationManager.Instance.LanguageChanged += OnLanguageChanged; + _languageChangedSubscribed = true; + // Раскрываем ветку, содержащую последнюю выбранную строку, чтобы её контейнер // в виртуализированном дереве создался сразу при первой отрисовке и выделение // можно было восстановить (см. PrepareLastSelectionExpansion). @@ -320,15 +356,28 @@ public MainViewModel( RefreshCommand = new RelayCommand(Refresh); AddInfobaseCommand = new RelayCommand(AddInfobase); EditInfobaseCommand = new RelayCommand(EditInfobase, - _ => SelectedInfobase != null || SelectedGroupNode?.Group != null || IsNoGroupNodeSelected()); + p => ResolveActionTarget(p) != null || SelectedGroupNode?.Group != null || IsNoGroupNodeSelected()); DeleteInfobaseCommand = new RelayCommand(DeleteSelected, - _ => SelectedInfobase != null || SelectedGroupNode?.Group != null); + p => ResolveActionTarget(p) != null || SelectedGroupNode?.Group != null); + // Команды группы: параметр — узел группы или сама группа из строки дерева. + EditGroupCommand = new RelayCommand(p => + { + var group = ResolveGroup(p); + if (group is not null) + EditGroup(group); + }); + DeleteGroupCommand = new RelayCommand(p => + { + var group = ResolveGroup(p); + if (group is not null) + DeleteGroup(group); + }, p => ResolveGroup(p) != null); ToggleFavoriteCommand = new RelayCommand(ToggleFavorite, _ => SelectedInfobase != null); ToggleFavoriteForCommand = new RelayCommand(ToggleFavoriteFor); LaunchCommand = new RelayCommand(p => Launch(p), _ => SelectedInfobase != null); // Обратная совместимость с XAML: отдельные команды делегируют в единую LaunchCommand. - LaunchEnterpriseCommand = new RelayCommand(_ => Launch(LaunchKind.Enterprise), _ => SelectedInfobase != null); - LaunchConfiguratorCommand = new RelayCommand(_ => Launch(LaunchKind.Configurator), _ => SelectedInfobase != null); + LaunchEnterpriseCommand = new RelayCommand(p => Launch(LaunchKind.Enterprise, false, p as Infobase), p => ResolveActionTarget(p) != null); + LaunchConfiguratorCommand = new RelayCommand(p => Launch(LaunchKind.Configurator, false, p as Infobase), p => ResolveActionTarget(p) != null); // Переключение вкладок списка баз (Все / Избранное / Недавние) по горячим клавишам. ShowAllCommand = new RelayCommand(_ => IsListModeAll = true); ShowFavoritesCommand = new RelayCommand(_ => IsListModeFavorites = true); @@ -347,16 +396,18 @@ public MainViewModel( ExportToIbasesV8iCommand = new RelayCommand(_ => ExportToIbases()); SynchronizeWithIbasesCommand = new RelayCommand(SynchronizeWithIbasesManual); ToggleRightPanelDetailsCommand = new RelayCommand(_ => ShowRightPanelDetails = !ShowRightPanelDetails); + ToggleSessionLaunchPanelCommand = new RelayCommand(_ => ShowSessionLaunchPanel = !ShowSessionLaunchPanel); ExportInfobasesCommand = new RelayCommand(ExportInfobases); ImportInfobasesCommand = new RelayCommand(ImportInfobases); ClearAllInfobasesCommand = new RelayCommand(ClearAllInfobases); TogglePinCommand = new RelayCommand(TogglePin, _ => SelectedInfobase != null); TogglePinForCommand = new RelayCommand(TogglePinFor); CopyConnectionStringCommand = new RelayCommand(CopyConnectionString, _ => SelectedInfobase != null); - // Команды очистки кеша доступны всегда (в т.ч. при выбранной группе): если база не - // выделена (например, выбрана группа), окно очистки открывается без выбранной базы - // (defaultSelected = null) — в списке не выделяется ни одна база. - ClearCacheCommand = new RelayCommand(ClearCache); + // Команда очистки кеша верхней панели действует на выбранную базу: если база не + // выделена — недоступна (CanExecute=false). В колонке «Действия» строка передаёт + // свою базу параметром, поэтому там кнопка включена независимо от глобального выбора. + ClearCacheCommand = new RelayCommand(ClearCache, + p => p is Infobase ? true : SelectedInfobase != null); ClearProgramCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.Program)); ClearUserCacheCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.User)); ClearCacheBothCommand = new RelayCommand(_ => OpenCacheClean(OneCCacheKind.All)); @@ -384,13 +435,27 @@ public MainViewModel( OpenSettingsCommand = new RelayCommand(OpenSettings); OpenInfobaseByLinkCommand = new RelayCommand(OpenInfobaseByLink); RefreshConfigurationInfoCommand = new RelayCommand(RefreshConfigurationInfo, _ => SelectedInfobase != null); - RefreshAllConfigurationInfoCommand = new RelayCommand(_ => RefreshAllConfigurationInfo()); + CheckAvailabilityCommand = new RelayCommand(_ => CheckAvailability()); RegisterComConnectorCommand = new RelayCommand(RegisterComConnector); // Если список баз пуст — предлагаем загрузить базы из файла ibases.v8i. + // Диалог нельзя показывать прямо из конструктора: главное окно ещё не показано, + // и модальный MessageBox, открытый до появления окна, зависает (в т.ч. при нажатии + // «Выход»/отмены). Откладываем запрос до завершения раскладки и отрисовки первого + // кадра, когда окно уже на экране и модальный цикл сообщений работает корректно. if (Infobases.Count == 0) { - PromptImportFromIbasesV8i(); + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher is not null) + { + dispatcher.BeginInvoke( + new Action(PromptImportFromIbasesV8i), + System.Windows.Threading.DispatcherPriority.ContextIdle); + } + else + { + PromptImportFromIbasesV8i(); + } } } @@ -417,6 +482,9 @@ public Infobase? SelectedInfobase _lastSelectedInfobaseId = value.Id ?? string.Empty; _lastSelectedGroupPath = string.Empty; ScheduleSaveSettings(); + + // Размер кеша 1С вычисляется в фоне и отображается в правой панели. + value.RefreshCacheSizeAsync(); } CommandManager.InvalidateRequerySuggested(); @@ -812,6 +880,80 @@ public void ApplyIbasesSyncSettings(IbasesSyncMode mode, string filePath, RestartAutoSync(); } + // ---- Профиль: резервное копирование и восстановление ---- + + /// Каталог резервной копии профиля (настройки, базы, пользователи/пароли, ibases.v8i). + public string ProfileBackupDirectory => _profileBackupDirectory; + + /// Восстанавливать профиль из каталога резервной копии при каждом запуске. + public bool ProfileRestoreOnStartup => _profileRestoreOnStartup; + + /// Применяет настройки резервного копирования профиля из окна настроек. + public void ApplyProfileBackupSettings(string backupDirectory, bool restoreOnStartup) + { + _profileBackupDirectory = backupDirectory?.Trim() ?? string.Empty; + _profileRestoreOnStartup = restoreOnStartup; + SaveSettings(); + } + + /// + /// Сохраняет текущий профиль (настройки, список баз с пользователями и паролями, + /// группы, ibases.v8i) в настроенный каталог. Возвращает true при успехе. + /// + public bool BackupProfile() + { + if (string.IsNullOrWhiteSpace(_profileBackupDirectory)) + { + _dialogs.ShowWarning(LocalizationManager.T("Settings.Profile.NoDirectory")); + return false; + } + try + { + var count = ProfileBackupService.Backup(_profileBackupDirectory, _ibasesSyncFilePath); + _logger.Info($"Резервная копия профиля сохранена в {_profileBackupDirectory} ({count} файлов)"); + _dialogs.ShowInfo(string.Format(LocalizationManager.T("Settings.Profile.BackupDone"), count)); + return true; + } + catch (Exception ex) + { + _logger.Error("Ошибка резервного копирования профиля", ex); + _dialogs.ShowError(string.Format(LocalizationManager.T("Settings.Profile.BackupFailed"), ex.Message)); + return false; + } + } + + /// + /// Восстанавливает профиль из настроенного каталога. Файлы копируются на диск; + /// для полного применения рекомендуется перезапуск приложения (либо включённое + /// восстановление при запуске). Возвращает true при успехе. + /// + public bool RestoreProfile() + { + if (string.IsNullOrWhiteSpace(_profileBackupDirectory)) + { + _dialogs.ShowWarning(LocalizationManager.T("Settings.Profile.NoDirectory")); + return false; + } + if (!ProfileBackupService.HasBackup(_profileBackupDirectory)) + { + _dialogs.ShowWarning(LocalizationManager.T("Settings.Profile.NoBackup")); + return false; + } + try + { + var count = ProfileBackupService.Restore(_profileBackupDirectory, _ibasesSyncFilePath); + _logger.Info($"Профиль восстановлен из {_profileBackupDirectory} ({count} файлов)"); + _dialogs.ShowInfo(string.Format(LocalizationManager.T("Settings.Profile.RestoreDone"), count)); + return true; + } + catch (Exception ex) + { + _logger.Error("Ошибка восстановления профиля", ex); + _dialogs.ShowError(string.Format(LocalizationManager.T("Settings.Profile.RestoreFailed"), ex.Message)); + return false; + } + } + /// /// После локального изменения базы: выгрузка в ibases.v8i без импорта, /// чтобы не перезатереть только что заданные настройки (режим запуска и т.д.). @@ -1486,6 +1628,21 @@ public double ConfigurationColumnWidth } } + /// Ширина колонки «Действия» в списке баз (0 — по умолчанию). + public double ActionsColumnWidth + { + get => _actionsColumnWidth; + set + { + if (_actionsColumnWidth != value) + { + _actionsColumnWidth = value; + OnPropertyChanged(); + ScheduleSaveSettings(); + } + } + } + /// Показывать подробности в правой панели (иначе — только кнопки). public bool ShowRightPanelDetails { @@ -1686,6 +1843,22 @@ public double SizeColumnWidth } } + /// + /// Порядок колонок списка баз по умолчанию (колонка «Конфигурация» в самом + /// конце). Используется, пока пользователь не задал собственный порядок. + /// + private static readonly string[] DefaultColumnOrder = + { "Version", "LaunchMode", "Actions", "ServerBase", "LastLaunch", "Size", "Configuration" }; + + /// + /// Порядок колонок списка баз слева направо (кроме фиксированной колонки + /// «Название», которая всегда первая). Если порядок не задан или пуст — + /// возвращается порядок по умолчанию: «Режим запуска» сразу после названия, + /// колонка «Действия» — за ним, «Конфигурация» — в конце. + /// + public IReadOnlyList ColumnOrderKeys => + _columnOrder is { Count: > 0 } ? _columnOrder : DefaultColumnOrder; + /// /// Применяет настройки содержимого нижней панели (строки состояния). /// @@ -1724,7 +1897,8 @@ public void ApplyStatusBarSettings( public void ApplyDisplaySettings(bool showFavoritesButton, bool showPinnedButton, bool showTags, bool showVersionColumn, bool showLaunchModeColumn, bool showServerColumn, bool showLastLaunchColumn, bool groupByGroup, bool showFavoritesOnly, bool showSizeColumn = true, - bool showConfigurationColumn = true, bool showEmptyGroups = false) + bool showConfigurationColumn = true, bool showEmptyGroups = false, + List? columnOrder = null) { _showFavoritesButton = showFavoritesButton; _showPinnedButton = showPinnedButton; @@ -1751,6 +1925,10 @@ public void ApplyDisplaySettings(bool showFavoritesButton, bool showPinnedButton ShowFavoritesOnly = showFavoritesOnly; ShowEmptyGroups = showEmptyGroups; + // Пользовательский порядок колонок (пустой — вернуть порядок по умолчанию). + _columnOrder = columnOrder is { Count: > 0 } ? new List(columnOrder) : new List(); + OnPropertyChanged(nameof(ColumnOrderKeys)); + SaveSettings(); } @@ -1804,6 +1982,7 @@ public void SaveWindowLayout(double width, double height, double left, double to public ICommand ExportToIbasesV8iCommand { get; } public ICommand SynchronizeWithIbasesCommand { get; } public ICommand ToggleRightPanelDetailsCommand { get; } + public ICommand ToggleSessionLaunchPanelCommand { get; } /// Команда экспорта списка информационных баз в файл. public ICommand ExportInfobasesCommand { get; } @@ -1879,8 +2058,8 @@ public string ExportIndicatorTooltip /// Запросить и заполнить информацию о конфигурации выбранной базы (точечно). public ICommand RefreshConfigurationInfoCommand { get; } - /// Запросить и заполнить информацию о конфигурации по всем базам 1С. - public ICommand RefreshAllConfigurationInfoCommand { get; } + /// Проверить доступность всех баз 1С (файловая — наличие по пути; клиент-серверная — подключение). + public ICommand CheckAvailabilityCommand { get; } /// Зарегистрировать COM-коннектор 1С (comcntr.dll) в системе. public ICommand RegisterComConnectorCommand { get; } @@ -1942,6 +2121,12 @@ public string ExportIndicatorTooltip /// Команда удаления выбранной базы. public ICommand DeleteInfobaseCommand { get; } + /// Команда редактирования конкретной группы (кнопка в колонке «Действия» строки группы). + public ICommand EditGroupCommand { get; } + + /// Команда удаления конкретной группы (кнопка в колонке «Действия» строки группы). + public ICommand DeleteGroupCommand { get; } + /// Команда добавления/удаления из избранного. public ICommand ToggleFavoriteCommand { get; } @@ -2006,7 +2191,6 @@ private void Refresh(object? parameter) Save(); RebuildGroupTree(); RefreshFileMetadata(); - RefreshConfigurationInfoAsync(); } /// @@ -2128,6 +2312,20 @@ private IEnumerable GetAvailablePorts() .OrderBy(p => p); } + /// + /// Возвращает информационную базу для команд действия строки: если команда вызвана с + /// параметром-Infobase (кнопка в колонке «Действия»), используется она, иначе — выбранная база. + /// + private Infobase? ResolveActionTarget(object? parameter) => + parameter as Infobase ?? SelectedInfobase; + + /// + /// Возвращает группу из параметра команды (кнопка в колонке «Действия» строки группы): + /// параметром служит либо сам узел группы, либо модель группы. + /// + private static Group? ResolveGroup(object? parameter) => + parameter is Group g ? g : (parameter as GroupNodeViewModel)?.Group; + /// /// Редактирует выбранный элемент: базу — через окно подключения, группу — через окно группы. /// Тип элемента при редактировании изменить нельзя (база не станет группой и наоборот). @@ -2148,10 +2346,11 @@ private void EditInfobase(object? parameter) return; } - if (SelectedInfobase is null) + var ib = parameter as Infobase ?? SelectedInfobase; + if (ib is null) return; - var dialog = new ConnectionSettingsWindow(SelectedInfobase, Groups, _installedPlatformVersions, + var dialog = new ConnectionSettingsWindow(ib, Groups, _installedPlatformVersions, availableServers: GetAvailableServers(), availablePorts: GetAvailablePorts()) { Owner = Application.Current.MainWindow @@ -2160,7 +2359,7 @@ private void EditInfobase(object? parameter) { // Применяем изменения к существующему объекту, а не заменяем его новым. // Это важно, потому что на объект могут ссылаться и основной список, и представление. - var target = SelectedInfobase; + var target = ib; target.Id = dialog.Result.Id; target.Name = dialog.Result.Name; target.Group = dialog.Result.Group; @@ -2259,10 +2458,9 @@ private void DeleteSelected(object? parameter) return; } - if (SelectedInfobase is null) + var ib = parameter as Infobase ?? SelectedInfobase; + if (ib is null) return; - - var ib = SelectedInfobase; var dlg = new Configuration_Management.DeleteInfobaseWindow(ib) { Owner = Application.Current?.MainWindow @@ -2458,6 +2656,7 @@ public void LaunchInfobaseById(string id, bool isConfigurator) InfobasesView.Refresh(); Save(); _logger.Info($"[tray] Запущена «{ib.Name}» ({(isConfigurator ? "Конфигуратор" : "Предприятие")})"); + NotifyAfterLaunch(); } else { @@ -2482,6 +2681,7 @@ public void LaunchFavoriteByHotkey(int number) ib.LastLaunchDate = DateTime.Now; ScheduleSave(); _logger.Info($"Запущена избранная база «{ib.Name}» по Alt+{number}"); + NotifyAfterLaunch(); } else { @@ -2660,7 +2860,34 @@ public bool EscapeToTray } } - + /// + /// Глобальная настройка «что делать с окном после запуска базы/конфигуратора 1С»: + /// "None" / "MinimizeToTray" / "Close". + /// + public string AfterLaunchAction + { + get => _afterLaunchAction; + set + { + if (SetProperty(ref _afterLaunchAction, value)) + ScheduleSaveSettings(); + } + } + + /// + /// Запрос к главному окну выполнить действие после успешного запуска базы/конфигуратора + /// (свернуть или увести в трей согласно глобальной настройке). + /// + public event Action? AfterLaunchRequested; + + /// Оповещает главное окно о необходимости выполнить действие «после запуска». + public void NotifyAfterLaunch() + { + var action = Models.AfterLaunchActionHelper.Parse(_afterLaunchAction); + if (action != Models.AfterLaunchAction.None) + AfterLaunchRequested?.Invoke(action); + } + /// Обновляет список каталогов шаблонов из настроек. public void SetTemplateCatalogPaths(System.Collections.Generic.IEnumerable paths) { @@ -2930,7 +3157,7 @@ private void LaunchEnterpriseWithParams(object? parameter) Owner = Application.Current?.MainWindow }; if (dlg.ShowDialog() != true) return; - var saved = SelectedInfobase.LaunchParameters; + var saved = SelectedInfobase.LaunchParameters ?? ""; try { SelectedInfobase.LaunchParameters = dlg.Result ?? ""; @@ -2955,7 +3182,7 @@ private void LaunchConfiguratorWithParams(object? parameter) Owner = Application.Current?.MainWindow }; if (dlg.ShowDialog() != true) return; - var saved = SelectedInfobase.LaunchParameters; + var saved = SelectedInfobase.LaunchParameters ?? ""; try { SelectedInfobase.LaunchParameters = dlg.Result ?? ""; @@ -3008,9 +3235,10 @@ private void LaunchNativeStarter() /// Единая точка запуска 1С. parameter — LaunchKind, строка имени enum или null (Enterprise). /// Для Enterprise учитываются переопределения «Текущая сессия» (клиент и разрядность). /// - private void Launch(object? parameter, bool runAsAdmin = false) + private void Launch(object? parameter, bool runAsAdmin = false, Infobase? target = null) { - if (SelectedInfobase is null) + var ib = target ?? SelectedInfobase; + if (ib is null) return; var kind = ResolveLaunchKind(parameter); @@ -3018,22 +3246,22 @@ private void Launch(object? parameter, bool runAsAdmin = false) switch (kind) { case LaunchKind.Configurator: - ok = _launcher.Launch(SelectedInfobase, OneCLaunchMode.Configurator, runAsAdmin); + ok = _launcher.Launch(ib, OneCLaunchMode.Configurator, runAsAdmin); break; case LaunchKind.Thin32: - ok = _launcher.Launch(SelectedInfobase, OneCLaunchMode.Enterprise, OneCClientType.Thin, OneCArchitecture.x86, runAsAdmin); + ok = _launcher.Launch(ib, OneCLaunchMode.Enterprise, OneCClientType.Thin, OneCArchitecture.x86, runAsAdmin); break; case LaunchKind.Thick32: - ok = _launcher.Launch(SelectedInfobase, OneCLaunchMode.Enterprise, OneCClientType.Thick, OneCArchitecture.x86, runAsAdmin); + ok = _launcher.Launch(ib, OneCLaunchMode.Enterprise, OneCClientType.Thick, OneCArchitecture.x86, runAsAdmin); break; case LaunchKind.Thin64: - ok = _launcher.Launch(SelectedInfobase, OneCLaunchMode.Enterprise, OneCClientType.Thin, OneCArchitecture.x64, runAsAdmin); + ok = _launcher.Launch(ib, OneCLaunchMode.Enterprise, OneCClientType.Thin, OneCArchitecture.x64, runAsAdmin); break; case LaunchKind.Thick64: - ok = _launcher.Launch(SelectedInfobase, OneCLaunchMode.Enterprise, OneCClientType.Thick, OneCArchitecture.x64, runAsAdmin); + ok = _launcher.Launch(ib, OneCLaunchMode.Enterprise, OneCClientType.Thick, OneCArchitecture.x64, runAsAdmin); break; default: - ok = LaunchEnterpriseWithSessionOverrides(SelectedInfobase, runAsAdmin); + ok = LaunchEnterpriseWithSessionOverrides(ib, runAsAdmin); break; } @@ -3042,14 +3270,15 @@ private void Launch(object? parameter, bool runAsAdmin = false) var sessionDetails = string.Format( LocalizationManager.T("Main.LaunchHistorySessionDetails"), _sessionClientMode, _sessionArchitecture); - SelectedInfobase.AddLaunchHistory(kind.ToString(), sessionDetails); + ib.AddLaunchHistory(kind.ToString(), sessionDetails); InfobasesView.Refresh(); Save(); - _logger.Info($"Запущена база «{SelectedInfobase.Name}» ({kind}, клиент={_sessionClientMode}, арх={_sessionArchitecture})"); + _logger.Info($"Запущена база «{ib.Name}» ({kind}, клиент={_sessionClientMode}, арх={_sessionArchitecture})"); + NotifyAfterLaunch(); } else { - _logger.Warn($"Не удалось запустить базу «{SelectedInfobase.Name}» ({kind})"); + _logger.Warn($"Не удалось запустить базу «{ib.Name}» ({kind})"); } } @@ -3249,11 +3478,16 @@ public void SaveSettings() { _repository.SaveSettings(new AppSettings { + // Актуальный язык интерфейса сохраняется всегда, чтобы выбор + // пользователя не затирался при закрытии окна (OnClosing). + Language = Configuration_Management.Localization.LocalizationManager.Instance.CurrentLanguage, ShowFavoritesOnly = _showFavoritesOnly, GroupByGroup = _groupByGroup, ShowEmptyGroups = _showEmptyGroups, Theme = _savedTheme, ActiveColorScheme = _activeColorScheme, + LightColorScheme = _lightColorScheme, + DarkColorScheme = _darkColorScheme, CollapsedGroups = _collapsedGroups.ToList(), InstalledPlatformVersions = _installedPlatformVersions, AdditionalPlatformSearchPaths = _additionalPlatformSearchPaths, @@ -3270,6 +3504,7 @@ public void SaveSettings() ShowVersionColumn = _showVersionColumn, ShowConfigurationColumn = _showConfigurationColumn, ConfigurationColumnWidth = _configurationColumnWidth, + ActionsColumnWidth = _actionsColumnWidth, ShowRightPanelDetails = _showRightPanelDetails, ShowSessionLaunchPanel = _showSessionLaunchPanel, SessionClientMode = _sessionClientMode.ToString(), @@ -3289,6 +3524,7 @@ public void SaveSettings() ShowLastLaunchColumn = _showLastLaunchColumn, ShowSizeColumn = _showSizeColumn, SizeColumnWidth = _sizeColumnWidth, + ColumnOrder = _columnOrder.ToList(), WindowWidth = _windowWidth, WindowHeight = _windowHeight, WindowLeft = _windowLeft, @@ -3305,6 +3541,7 @@ public void SaveSettings() AddTimestampToExportFileName = _addTimestampToExportFileName, ExportTimestampFormat = _exportTimestampFormat, CloseToTray = _closeToTray, + AfterLaunchAction = _afterLaunchAction, ShowTrayIcon = _showTrayIcon, EscapeToTray = _escapeToTray, CompactMode = _compactMode, @@ -3332,14 +3569,16 @@ public void SaveSettings() FontStyle = _fontStyle, ElementFonts = _elementFonts, LastSelectedInfobaseId = _lastSelectedInfobaseId, - LastSelectedGroupPath = _lastSelectedGroupPath + LastSelectedGroupPath = _lastSelectedGroupPath, + ProfileBackupDirectory = _profileBackupDirectory, + ProfileRestoreOnStartup = _profileRestoreOnStartup }); } /// /// Сохраняет ширины колонок списка баз в настройках. /// - public void SaveColumnWidths(double nameWidth, double versionWidth, double configurationWidth, double launchModeWidth, double serverWidth, double lastLaunchWidth) + public void SaveColumnWidths(double nameWidth, double versionWidth, double configurationWidth, double launchModeWidth, double serverWidth, double lastLaunchWidth, double actionsWidth) { NameColumnWidth = nameWidth; VersionColumnWidth = versionWidth; @@ -3347,6 +3586,7 @@ public void SaveColumnWidths(double nameWidth, double versionWidth, double confi LaunchModeColumnWidth = launchModeWidth; ServerColumnWidth = serverWidth; LastLaunchColumnWidth = lastLaunchWidth; + ActionsColumnWidth = actionsWidth; SaveSettings(); } @@ -3354,7 +3594,7 @@ public void SaveColumnWidths(double nameWidth, double versionWidth, double confi /// Обновляет ширины колонок в памяти (без сохранения в файл). /// Используется для синхронизации колонок строк во время перетаскивания разделителя. /// - public void UpdateColumnWidths(double nameWidth, double versionWidth, double configurationWidth, double launchModeWidth, double serverWidth, double lastLaunchWidth) + public void UpdateColumnWidths(double nameWidth, double versionWidth, double configurationWidth, double launchModeWidth, double serverWidth, double lastLaunchWidth, double actionsWidth) { NameColumnWidth = nameWidth; VersionColumnWidth = versionWidth; @@ -3362,6 +3602,7 @@ public void UpdateColumnWidths(double nameWidth, double versionWidth, double con LaunchModeColumnWidth = launchModeWidth; ServerColumnWidth = serverWidth; LastLaunchColumnWidth = lastLaunchWidth; + ActionsColumnWidth = actionsWidth; } /// @@ -3383,8 +3624,57 @@ public void ApplyTheme(string theme) ApplyColorScheme(scheme); } + /// + /// Переключает базовую тему (светлую/тёмную), сохраняя пользовательские схемы каждой темы: + /// применяется схема целевой темы (сохранённая пользовательская или встроенная по умолчанию), + /// ни одна из сохранённых схем не затирается. + /// + public void ToggleTheme() + { + var targetDark = !Themes.ThemeManager.CurrentScheme.IsDark; + ApplySchemeForTheme(targetDark); + SaveSettings(); + LogTheme($"ToggleTheme -> dark={targetDark}, scheme='{_activeColorScheme?.Name}' (isDark={_activeColorScheme?.IsDark}), darkSlot='{_darkColorScheme?.Name}', lightSlot='{_lightColorScheme?.Name}'"); + } + + /// + /// Применяет схему для указанной базовой темы, обновляя активную схему и сохранённую тему. + /// Каждая базовая тема (светлая/тёмная) имеет собственную схему, поэтому правки одной + /// темы не влияют на другую. + /// + private void ApplySchemeForTheme(bool dark) + { + _activeColorScheme = SchemeForTheme(dark); + _savedTheme = _activeColorScheme.BaseThemeName; + Themes.ThemeManager.ApplyScheme(_activeColorScheme); + LogTheme($"ApplySchemeForTheme(dark={dark}) -> active='{_activeColorScheme.Name}' (isDark={_activeColorScheme.IsDark})"); + } + + /// + /// Возвращает схему для базовой темы: сохранённую пользовательскую (если есть), иначе встроенную. + /// + private Models.ColorScheme SchemeForTheme(bool dark) + { + var slot = dark ? _darkColorScheme : _lightColorScheme; + if (slot is { Colors.Count: > 0 }) + return slot; + return dark ? Models.ColorScheme.CreateDark() : Models.ColorScheme.CreateLight(); + } + + /// + /// Возвращает схему для базовой темы («Light»/«Dark»): сохранённую пользовательскую + /// (если есть) или встроенную по умолчанию. Не изменяет настройки. + /// + public Models.ColorScheme GetSchemeForTheme(string theme) + => SchemeForTheme(IsDarkTheme(theme)); + + private static bool IsDarkTheme(string? theme) + => string.Equals(theme, Themes.ThemeManager.DarkThemeName, StringComparison.OrdinalIgnoreCase); + /// /// Применяет цветовую схему, сохраняет её как активную и записывает настройки. + /// Кастомизация записывается в слот соответствующей базовой темы (светлой/тёмной), + /// поэтому переключение тем не сбрасывает настроенное оформление. /// public void ApplyColorScheme(ColorScheme scheme) { @@ -3392,8 +3682,45 @@ public void ApplyColorScheme(ColorScheme scheme) return; _activeColorScheme = scheme.Clone(); _savedTheme = _activeColorScheme.BaseThemeName; + if (_activeColorScheme.IsDark) + _darkColorScheme = _activeColorScheme; + else + _lightColorScheme = _activeColorScheme; Themes.ThemeManager.ApplyScheme(_activeColorScheme); SaveSettings(); + LogTheme($"ApplyColorScheme('{scheme.Name}', isDark={scheme.IsDark}, colors={scheme.Colors.Count}) -> active='{_activeColorScheme.Name}', darkSlot='{_darkColorScheme?.Name}', lightSlot='{_lightColorScheme?.Name}'"); + } + + /// + /// Сохраняет правки схемы в слот соответствующей базовой темы (светлой/тёмной), + /// не меняя активную тему и не трогая интерфейс. Используется редактором цветов + /// во вкладке «Цветовое оформление», чтобы каждая тема хранила собственные настройки + /// независимо от остальных. + /// + public void SaveColorSchemeSlot(ColorScheme scheme) + { + if (scheme is null) + return; + var clone = scheme.Clone(); + if (clone.IsDark) + _darkColorScheme = clone; + else + _lightColorScheme = clone; + SaveSettings(); + LogTheme($"SaveColorSchemeSlot('{scheme.Name}', isDark={scheme.IsDark}, colors={scheme.Colors.Count}) -> darkSlot='{_darkColorScheme?.Name}', lightSlot='{_lightColorScheme?.Name}'"); + } + + /// Диагностика переключения/применения темы (пишет в лог и во временный файл). + private void LogTheme(string message) + { + try { _logger.Info("[theme-debug] " + message); } catch { /* не критично */ } + try + { + System.IO.File.AppendAllText( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), "cm_theme_debug.log"), + "[theme-debug] " + message + Environment.NewLine); + } + catch { /* не критично */ } } /// @@ -3678,6 +4005,55 @@ private IEnumerable EnumerateFilteredInfobases() /// Группы и подгруппы, не содержащие баз (в том числе при активном фильтре «Только избранные»), /// в дерево не попадают. /// + /// + /// Обработчик смены языка интерфейса (Windows): пересчитывает все привязки к VM + /// и пересобирает дерево групп, чтобы локализованные свойства и служебные узлы + /// («Все базы», «Без группы», «Избранное») обновились без перезапуска. + /// Работает для любого направления (ru ↔ en и внешние языки). + /// + private void OnLanguageChanged(object? sender, EventArgs e) + { + // Событие поднимается на UI-потоке (SettingsWindow), но добавляем страховку: + // если вызов пришёл с другого потока — маршализуем на диспетчер приложения. + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess()) + { + HandleLanguageChanged(); + return; + } + + dispatcher.BeginInvoke( + System.Windows.Threading.DispatcherPriority.DataBind, + new Action(HandleLanguageChanged)); + } + + /// + /// Непосредственно применяет смену языка: уведомляет об изменении всех свойств VM + /// (WPF пересчитает StatusBarInfo, ExportIndicatorTooltip, RightPanelToggleTooltip, + /// GroupByGroupText, SyncMessage и пр.) и пересобирает дерево групп. + /// + private void HandleLanguageChanged() + { + // Пустое имя свойства означает «изменились все свойства» — WPF пересчитает + // все активные привязки к VM. + OnPropertyChanged(string.Empty); + // Дерево перестраиваем целиком: узлы со спецмаркерами возвращают + // LocalizationManager.T(...) на лету (см. GroupNodeViewModel.DisplayName). + RebuildGroupTree(); + } + + /// + /// Отписывается от события смены языка. Вызывается при полном закрытии главного + /// окна (MainWindow.OnClosing), чтобы избежать утечек и дублирования подписки. + /// + public void UnsubscribeLanguageChanged() + { + if (!_languageChangedSubscribed) + return; + LocalizationManager.Instance.LanguageChanged -= OnLanguageChanged; + _languageChangedSubscribed = false; + } + public void RebuildGroupTree() { // Один проход по базам без CollectionView.Refresh (он дорогой на больших списках). @@ -4341,14 +4717,15 @@ private void CopyConnectionString(object? parameter) /// private void ClearCache(object? parameter) { - OpenCacheClean(OneCCacheKind.All); + OpenCacheClean(OneCCacheKind.All, parameter as Infobase); } /// /// Открывает диалог выбора типа кеша и баз 1С, после подтверждения выполняет очистку. /// /// Тип кеша, выбранный по умолчанию. - private void OpenCacheClean(OneCCacheKind kind) + /// База, выделенная по умолчанию (если указана). + private void OpenCacheClean(OneCCacheKind kind, Infobase? defaultInfobase = null) { if (Infobases.Count == 0) { @@ -4357,7 +4734,7 @@ private void OpenCacheClean(OneCCacheKind kind) return; } - var dialog = new CacheCleanWindow(Infobases, kind, SelectedInfobase) + var dialog = new CacheCleanWindow(Infobases, kind, defaultInfobase ?? SelectedInfobase) { Owner = Application.Current.MainWindow }; @@ -4579,6 +4956,12 @@ private void RefreshConfigurationInfo(object? parameter) var ib = parameter as Infobase ?? SelectedInfobase; if (ib is null) return; + // Пользователь попросил явно — снимаем оба вердикта о недоступности COM: кэш + // реестра и сессионную защёлку агента. Причина сбоя могла быть разовой (антивирус, + // нехватка памяти) или уже устранённой (платформу поставили после запуска), + // а иначе до перезапуска приложения команда молча отвечала бы отказом. + OneCComConnector.ResetComVerdicts(); + var baseName = ib.Name; _ = Task.Run(() => { @@ -4621,58 +5004,79 @@ private void RefreshConfigurationInfo(object? parameter) } /// - /// Запрашивает и заполняет информацию о конфигурации по всем базам 1С. - /// Перезаписывает уже заполненные значения; итог выводится по завершении. + /// Проверяет доступность всех баз 1С и помечает недоступные красным крестиком + /// в списке баз. Для файловых баз — наличие каталога/файла по пути; для + /// клиент-серверных — реальная попытка подключения через COM-коннектор; + /// для веб-баз — заполненность адреса. Проверки выполняются в фоне, чтобы + /// не блокировать UI. /// - private void RefreshAllConfigurationInfo() + private void CheckAvailability() { var targets = Infobases.ToList(); if (targets.Count == 0) { _dialogs.ShowInfo(LocalizationManager.T("Main.ConfigListEmpty"), - LocalizationManager.T("Main.ConfigInfoListTitle")); + LocalizationManager.T("Main.AvailabilityTitle")); return; } - if (!_dialogs.Confirm( - string.Format(LocalizationManager.T("Main.RefreshAllConfigConfirm"), targets.Count), - LocalizationManager.T("Main.RefreshAllConfigTitle"))) - return; - - var updated = 0; - var failed = 0; - _ = Task.Run(() => { + // Результаты собираем заранее, чтобы не трогать модель из фонового потока. + var results = new List<(Infobase Base, bool Available)>(targets.Count); foreach (var ib in targets) - { - try - { - if (ConfigurationInfoService.ReadAndApply(ib, overwriteExisting: true) is not null) - updated++; - else - failed++; - } - catch - { - failed++; - } - } + results.Add((ib, IsBaseAvailable(ib))); Application.Current?.Dispatcher.Invoke(() => { + foreach (var (ib, available) in results) + ib.SetCheckedAvailability(available); InfobasesView?.Refresh(); - Save(); - _logger.Info( - $"Обновление информации о конфигурациях завершено: обновлено {updated}, " + - $"ошибок {failed} из {targets.Count}"); - _dialogs.ShowInfo( - string.Format(LocalizationManager.T("Main.RefreshAllConfigDone"), updated, failed, targets.Count), - LocalizationManager.T("Main.ConfigInfoListTitle")); + + var total = results.Count; + var unavailable = results.Count(r => !r.Available); + SyncMessage = string.Format( + LocalizationManager.T("Main.AvailabilityStatus"), total, unavailable); + ScheduleClearSyncMessage(); }); }); } + /// + /// Доступность отдельной базы. Файловая — есть ли каталог/файл по пути; + /// клиент-серверная — удалось ли подключиться; веб-база — заполнен ли адрес. + /// + private static bool IsBaseAvailable(Infobase ib) + { + try + { + switch (ib.Connection?.Type) + { + case ConnectionType.File: + return InfobaseMaintenanceService.FileBaseExists(ib); + + case ConnectionType.ClientServer: + { + // Проверка доступности — через безопасный путь процесс-агента (ComReadHost). + // Прямой Connect у comcntr.dll под CoreCLR обрывает процесс нативным + // fast-fail (0xC0000409), поэтому метод помечен [Obsolete] и здесь не используется. + var connector = AppServices.GetRequiredService(); + return connector.ReadConfigurationInfo(ib, timeoutMs: 8000) is not null; + } + + case ConnectionType.WebServer: + return !string.IsNullOrWhiteSpace(ib.Connection.WebUrl); + + default: + return false; + } + } + catch + { + return false; + } + } + /// /// Регистрирует COM-коннектор 1С в системе (comcntr.dll / comcntr64.dll). /// Использует версию и разрядность выбранной базы, либо новейшую установленную платформу. @@ -4742,6 +5146,10 @@ private void RegisterComConnector(object? parameter) if (result.Success && result.ProgIdVisible) { + // После успешной регистрации сбрасываем оба вердикта о недоступности: + // кэш реестра и сессионную защёлку процесса-агента. Вердиктов два, и + // снимать надо оба, иначе чтение по-прежнему откажет по устаревшему кэшу. + OneCComConnector.ResetComVerdicts(); _logger.Info("COM-коннектор 1С успешно зарегистрирован."); _dialogs.ShowInfo(sb.ToString().TrimEnd(), LocalizationManager.T("Main.ComRegTitle")); @@ -5275,7 +5683,8 @@ public void ApplyAppBehaviorSettings( string? hotkeyShowAll = null, string? hotkeyShowFavorites = null, string? hotkeyShowRecent = null, - bool rememberWindowLayout = true) + bool rememberWindowLayout = true, + string afterLaunchAction = "None") { _allowMultipleInstances = allowMultipleInstances; _showTagFilterPanel = showTagFilterPanel; @@ -5283,6 +5692,7 @@ public void ApplyAppBehaviorSettings( _showTrayIcon = showTrayIcon; _escapeToTray = escapeToTray; _rememberWindowLayout = rememberWindowLayout; + _afterLaunchAction = afterLaunchAction; if (hotkeyEnterprise != null) _hotkeyEnterprise = hotkeyEnterprise.Trim(); if (hotkeyConfigurator != null) _hotkeyConfigurator = hotkeyConfigurator.Trim(); if (hotkeyFavorite != null) _hotkeyFavorite = hotkeyFavorite.Trim(); @@ -5299,6 +5709,7 @@ public void ApplyAppBehaviorSettings( OnPropertyChanged(nameof(CloseToTray)); OnPropertyChanged(nameof(ShowTrayIcon)); OnPropertyChanged(nameof(EscapeToTray)); + OnPropertyChanged(nameof(AfterLaunchAction)); OnPropertyChanged(nameof(HotkeyEnterprise)); OnPropertyChanged(nameof(HotkeyConfigurator)); OnPropertyChanged(nameof(HotkeyFavorite)); @@ -5336,3 +5747,4 @@ public TagFilterItem(string name, bool isSelected) public string Name { get; } public bool IsSelected { get; } } +#endif diff --git a/Configuration Management/ViewModels/MetadataNodeViewModel.cs b/Configuration Management/ViewModels/MetadataNodeViewModel.cs deleted file mode 100644 index 7859f219..00000000 --- a/Configuration Management/ViewModels/MetadataNodeViewModel.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Configuration_Management.Models; - -namespace Configuration_Management.ViewModels; - -/// -/// ViewModel для узла дерева метаданных конфигурации. -/// -public class MetadataNodeViewModel : ViewModelBase -{ - private readonly MetadataNode _node; - private bool _isExpanded; - private bool _isSelected; - - public MetadataNodeViewModel(MetadataNode node) - { - _node = node; - } - - /// Наименование узла. - public string Name => _node.Name; - - /// Синоним (русское наименование). - public string Synonym => _node.Synonym; - - /// Отображаемое наименование (синоним, если задан, иначе имя). - public string DisplayName => string.IsNullOrEmpty(Synonym) ? Name : $"{Synonym} ({Name})"; - - /// Тип узла. - public MetadataType Type => _node.Type; - - /// Комментарий / описание. - public string Comment => _node.Comment; - - /// Дочерние узлы. - public List Children { get; } = new(); - - /// Признак наличия дочерних узлов. - public bool HasChildren => Children.Count > 0; - - /// Развёрнут ли узел в дереве. - public bool IsExpanded - { - get => _isExpanded; - set => SetProperty(ref _isExpanded, value); - } - - /// Выбран ли узел в дереве. - public bool IsSelected - { - get => _isSelected; - set => SetProperty(ref _isSelected, value); - } - - /// - /// Рекурсивно строит дерево ViewModel из модели метаданных. - /// - public static MetadataNodeViewModel FromModel(MetadataNode node) - { - var vm = new MetadataNodeViewModel(node); - foreach (var child in node.Children) - { - vm.Children.Add(FromModel(child)); - } - return vm; - } -} \ No newline at end of file diff --git a/Configuration Management/ViewModels/ViewModelBase.Avalonia.cs b/Configuration Management/ViewModels/ViewModelBase.Avalonia.cs index 4462aa6f..4a98deeb 100644 --- a/Configuration Management/ViewModels/ViewModelBase.Avalonia.cs +++ b/Configuration Management/ViewModels/ViewModelBase.Avalonia.cs @@ -38,10 +38,14 @@ protected bool SetProperty(ref T field, T value, [CallerMemberName] string? p /// /// Устанавливает значение и дополнительно уведомляет о связанных свойствах. + /// Имя своего свойства передаётся первым: у одноимённой перегрузки первый + /// строковый аргумент означал бы ровно обратное, и перепутать их было бы легко. /// - protected bool SetProperty(ref T field, T value, params string[] relatedProperties) + protected bool SetPropertyWithRelated(ref T field, T value, string propertyName, params string[] relatedProperties) { - if (!SetProperty(ref field, value)) + // Имя передаётся явно: при вызове без него CallerMemberName подставлял + // имя самого метода, и подписчики на имя свойства не получали ничего. + if (!SetProperty(ref field, value, propertyName)) return false; foreach (var name in relatedProperties) OnPropertyChanged(name); diff --git a/Configuration Management/build-windows-single-file.ps1 b/Configuration Management/build-windows-single-file.ps1 new file mode 100644 index 00000000..325ee73b --- /dev/null +++ b/Configuration Management/build-windows-single-file.ps1 @@ -0,0 +1,95 @@ +<# +.SYNOPSIS + Сборка ОДНОГО исполняемого файла для Windows (WPF, net10.0-windows, win-x64). + +.DESCRIPTION + Результат: self-contained single-file исполняемый файл + dist/win-x64/ConfigurationManagement.exe + (без папок, без .dll и .pdb рядом — только один исполняемый файл). + + Требуется: Windows + .NET SDK 10 (>= 10.0.400). + +.USAGE + .\build-windows-single-file.ps1 # Release, RID win-x64 + .\build-windows-single-file.ps1 -Configuration Debug # другой конфиг + .\build-windows-single-file.ps1 -RID win-arm64 # другой RID + + SKIP_PUBLISH=1 (переменная окружения) пропускает dotnet publish + (удобно для быстрой проверки синтаксиса). +#> +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + [string]$RID = 'win-x64' +) + +$ErrorActionPreference = 'Stop' + +$Root = $PSScriptRoot +$Project = Join-Path $Root 'Configuration Management.csproj' +$Dist = Join-Path $Root "dist\$RID" + +# На Windows выбирается TFM net10.0-windows (WPF), поэтому проверяем ОС. +if ($env:OS -ne 'Windows_NT') { + Write-Host "!! Скрипт предназначен для запуска НА Windows (TFM net10.0-windows/WPF задаётся по ОС)." -ForegroundColor Yellow + Write-Host " На другой ОС кросс-компиляция даст Linux/Avalonia и не запустится на Windows." -ForegroundColor Yellow + exit 1 +} + +if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + Write-Host "!! .NET SDK не найден. Установите .NET SDK 10." -ForegroundColor Red + exit 1 +} + +Write-Host "==> Конфигурация: $Configuration" -ForegroundColor Cyan +Write-Host "==> RID: $RID" -ForegroundColor Cyan +Write-Host "==> Цель: $Dist" -ForegroundColor Cyan + +Write-Host "==> Restore" -ForegroundColor Cyan +dotnet restore $Project +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +if ($env:SKIP_PUBLISH -eq '1') { + Write-Host "==> SKIP_PUBLISH=1 — публикация пропущена." -ForegroundColor Yellow + exit 0 +} + +Write-Host "==> Publish (self-contained single-file)" -ForegroundColor Cyan +if (Test-Path $Dist) { Remove-Item $Dist -Recurse -Force } +dotnet publish $Project ` + -c $Configuration ` + -r $RID ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:IncludeNativeLibrariesForSelfExtract=true ` + -p:EnableCompressionInSingleFile=true ` + -p:PublishReadyToRun=false ` + -o $Dist +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Оставляем ТОЛЬКО исполняемый файл ConfigurationManagement.exe: +# удаляем .pdb, .json, .xml и любые другие файлы и папки в выходной папке. +# Встроенные языки (ru, en) уже в ресурсах сборки, поэтому внешняя папка +# Localization/Languages здесь не нужна (см. LocalizationManager.cs). +Get-ChildItem $Dist -Recurse | Sort-Object { $_.FullName.Length } -Descending | ForEach-Object { + if ($_.FullName -eq (Join-Path $Dist 'ConfigurationManagement.exe')) { return } + if ($_.PSIsContainer) { + Write-Host " (удаляем лишнюю папку: $($_.FullName.Substring($Dist.Length)))" -ForegroundColor DarkGray + Remove-Item $_.FullName -Recurse -Force + } else { + Write-Host " (удаляем лишний файл: $($_.FullName.Substring($Dist.Length)))" -ForegroundColor DarkGray + Remove-Item $_.FullName -Force + } +} + +$exe = Join-Path $Dist 'ConfigurationManagement.exe' +if (-not (Test-Path $exe)) { + Write-Host "!! Исполняемый файл не найден: $exe" -ForegroundColor Red + exit 1 +} + +$size = [math]::Round((Get-Item $exe).Length / 1MB, 1) +Write-Host "==> Собран один исполняемый файл:" -ForegroundColor Green +Write-Host " $exe ($size MB)" -ForegroundColor Green +Write-Host "==> Готово. Запуск: $exe" -ForegroundColor Green \ No newline at end of file diff --git a/Configuration Management/build.sh b/Configuration Management/build.sh old mode 100644 new mode 100755 diff --git a/PLAN_LINUX.md b/PLAN_LINUX.md deleted file mode 100644 index cf016030..00000000 --- a/PLAN_LINUX.md +++ /dev/null @@ -1,172 +0,0 @@ -# План портирования «Управление конфигурациями 1С» на Linux - -## 1. Анализ текущей архитектуры - -### 1.1. Технологический стек (Windows) - -| Компонент | Текущее решение | Примечание | -|-----------|----------------|------------| -| UI-фреймворк | **WPF** (`UseWPF=true`) | ~20 окон XAML, кастомные контролы, конвертеры, 4 темы | -| Трей | **WinForms** (`UseWindowsForms=true`) | `NotifyIcon`, `ContextMenuStrip`, GDI+ (`System.Drawing`) | -| Отрисовка иконок трея | **GDI+** (`System.Drawing`) | Ручная отрисовка Material-иконок в `Bitmap` | -| Целевой фреймворк | `net10.0-windows` | Windows-only TFM | -| DI | `Microsoft.Extensions.DependencyInjection` | Переносится без изменений | -| Иконки UI | `MaterialDesignThemes 5.2.1` (PackIcon) | WPF-библиотека, требует замены | -| WMI / процессы | `System.Management` + `Win32_Process` | Windows-only, требует замены | -| Реестр | `Microsoft.Win32.Registry` | Windows-only | -| COM | `WScript.Shell` (ярлыки .lnk), `V83.COMConnector` (1С) | Windows-only | -| P/Invoke | `user32.dll` (`SetForegroundWindow`, `ShowWindow`) | Windows-only | -| Проводник | `explorer.exe` | Windows-only | - -### 1.2. Инвентаризация платформозависимого кода - -| Файл | Платформозависимость | -|------|----------------------| -| [`App.xaml.cs`](Configuration Management/App.xaml.cs:289) | P/Invoke `user32.dll` для активации окна; `Mutex`/`EventWaitHandle` с `Global\` (на Linux — другое пространство имён) | -| [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs:490) | Трей (WinForms `NotifyIcon`), контекстное меню трея, отрисовка иконок через `System.Drawing`, DPI/мониторы | -| [`Services/OneCLauncher.cs`](Configuration Management/Services/OneCLauncher.cs:925) | Поиск `1cv8.exe`/`1cv8c.exe`/`1CEStart.exe`, чтение командной строки через WMI, пакетные операции `DESIGNER` | -| [`Services/PlatformVersionService.cs`](Configuration Management/Services/PlatformVersionService.cs:160) | Поиск версий по `Program Files`, `1cv8*.exe` | -| [`Services/InfobaseMaintenanceService.cs`](Configuration Management/Services/InfobaseMaintenanceService.cs:188) | Ярлыки `.lnk` через COM `WScript.Shell`; `explorer.exe`; завершение процессов 1С | -| [`Services/OneCComConnector.cs`](Configuration Management/Services/OneCComConnector.cs:251) | COM-коннектор `V83.COMConnector`, реестр для ProgID | -| [`Services/OneCComConnectorRegistrar.cs`](Configuration Management/Services/OneCComConnectorRegistrar.cs:237) | `regsvr32.exe`, реестр, `System32`/`SysWOW64` | -| [`Services/OneCTemplateService.cs`](Configuration Management/Services/OneCTemplateService.cs:222) | Пути `%PUBLIC%`, `%APPDATA%`, реестр `Software\1C\...` | -| [`Services/OneCCacheCleaner.cs`](Configuration Management/Services/OneCCacheCleaner.cs:164) | Пути `%LOCALAPPDATA%`/`%APPDATA%` | -| [`Services/IbasesV8iImporter.cs`](Configuration Management/Services/IbasesV8iImporter.cs:322) | Путь `%APPDATA%\1C\1CEStart\ibases.v8i` | -| [`Services/InfobaseRepository.cs`](Configuration Management/Services/InfobaseRepository.cs:28) | Каталог данных `%APPDATA%\ConfigurationManagement` | -| Окна (`*Window.xaml.cs`) | `Microsoft.Win32.OpenFolderDialog`/`SaveFileDialog`/`OpenFileDialog` | -| [`ConnectionSettingsWindow.xaml.cs`](Configuration Management/ConnectionSettingsWindow.xaml.cs:97) | `Microsoft.Win32.OpenFolderDialog` (нет в стандартном Avalonia) | - -### 1.3. Ключевые выводы - -1. **WPF не работает нативно на Linux.** Нужен перенос UI на кроссплатформенный фреймворк. -2. **Половина сервисов** завязана на Windows-специфику 1С (`1cv8.exe`, COM, реестр, `.lnk`, `ibases.v8i` в `%APPDATA%`). -3. **На Linux платформа 1С устроена иначе**: исполняемые файлы без `.exe` (`1cv8`, `1cv8c`, `1cestart`), конфиги в `~/.1cv8/1CEStart/`, нет COM-коннектора (есть CLI и `-Fr` режимы), иные пути кэша и шаблонов. -4. **Модели, ViewModels, конвертеры, DI, JSON-хранилище — переносимы почти без изменений** (это чистый .NET). - ---- - -## 2. Выбор стратегии и фреймворка - -### 2.1. Варианты - -| Вариант | Плюсы | Минусы | Вердикт | -|---------|-------|--------|---------| -| **Avalonia UI** | Ближайший синтаксис к WPF (`XAML`, `DataGrid`, `Styles`, `Converters`); перенос XAML-разметки минимален; отличная поддержка Linux; готовый трей | Часть WPF-API переименована/отличается (ресурсы, `GridLength`, шрифты) | **Рекомендуется** | -| Uno Platform | Хорош для WinUI/UWP | Другой синтаксис, хуже поддержка десктопного Linux | Нет | -| .NET MAUI | Официальный | Нет полноценной поддержки десктопного Linux | Нет | -| WPF под Wine | Минимум работы | Не настоящий порт; проблемы с треем, DPI, зависимостями | Только как временный вариант | - -### 2.2. Рекомендация - -**Портировать UI на Avalonia 11.x**, а сервисы — на **условную компиляцию/абстракции** под Linux-специфику платформы 1С. Подход «одна кодовая база + платформенные абстракции» сохранит возможность собирать и Windows-версию. - ---- - -## 3. Этапы портирования - -### Этап 0. Подготовка (основание решения) -- [ ] Зафиксировать поддерживаемые дистрибутивы (Ubuntu/Debian, ALT Linux, Astra Linux) и разрядность. -- [ ] Определить минимально поддерживаемую версию Linux-платформы 1С:Предприятие (8.3.x). -- [ ] Провести аудит всех `*.xaml` на несвойственные Avalonia конструкции. - -### Этап 1. Смена target framework и подключение Avalonia -- [ ] В [`Configuration Management.csproj`](Configuration Management/Configuration Management.csproj:5) убрать `net10.0-windows` → `net10.0` (+ отдельная конфигурация для Windows `net10.0-windows`). -- [ ] Убрать `UseWPF`, `UseWindowsForms`, `ApplicationIcon` для Linux-конфигурации. -- [ ] Подключить NuGet: `Avalonia`, `Avalonia.Desktop`, `Avalonia.Themes.Fluent`/`Simple`, `Avalonia.Fonts.Inter`, `Avalonia.Diagnostics` (dev). -- [ ] Заменить `MaterialDesignThemes` на аналог (например `Material.Icons.Avalonia` или собрать иконки вручную, как сейчас для трея). -- [ ] Убрать `System.Management` (Windows-only). -- [ ] Добавить директивы `#if WINDOWS` / `#if LINUX` (или отдельные файлы сервисов). - -### Этап 2. Порт инфраструктуры приложения -- [ ] **`App.xaml`/`App.xaml.cs`**: заменить `Application` WPF на `Avalonia.Application`; `OnStartup` → `OnFrameworkInitializationCompleted`; регистрация DI. -- [ ] **Обработка ошибок**: перенести `DispatcherUnhandledException`/`AppDomain` в эквиваленты Avalonia. -- [ ] **Один экземпляр**: заменить `Mutex`+`EventWaitHandle` на cross-platform (файловый lock `FileStream` с `FileShare.None` в каталоге приложения) — `Global\`-имен не существует на Linux. -- [ ] **Активация окна из второго экземпляра**: вместо `user32.dll` использовать файл-сигнал (создание файла `activate`) либо локальный сокет/Unix-сокет. -- [ ] **DI-контейнер** `AppServices.cs`: заменить регистрацию `MainWindow`/`WpfDialogService` на Avalonia-версии. - -### Этап 3. Порт окон и контролов (XAML → Avalonia) -- [ ] Механический перевод разметки `*Window.xaml` (≈20 окон) в Avalonia XAML. Ключевые соответствия: - - `DataGrid` → `DataGrid` (Avalonia имеет свой; часть свойств и шаблонов отличается). - - `ResourceDictionary` → `ResourceDictionary` (Avalonia: `Styles` + `Resources`). - - `System.Windows.Thickness` → `Thickness` (Avalonia), `GridLength` → `GridLength`. - - `IValueConverter` → `IValueConverter` (Avalonia: `object? Convert(object? value, ...)`). - - `MessageBox` → свой диалог (Avalonia не имеет `MessageBox` из коробки) — расширить `WpfDialogService`. - - `OpenFileDialog`/`SaveFileDialog` → `StorageProvider` (Avalonia). - - `OpenFolderDialog` (нет в Avalonia) → реализовать через `StorageProvider` (папки) или сторонний пакет. -- [ ] Кастомные контролы [`Controls/`](Configuration Management/Controls/LeveledTreeView.cs): `GroupTreeView`, `InfobaseListView`, `LeveledTreeView` — переписать под Avalonia (дерево и виртуализация списка). -- [ ] Конвертеры [`Converters/`](Configuration Management/Converters/GroupColorConverter.cs) — почти все переносятся «как есть», правка сигнатур. -- [ ] Темы [`Themes/`](Configuration Management/Themes/ThemeManager.cs) — переписать `LightTheme.xaml`/`DarkTheme.xaml` под Avalonia `Styles`; `ThemeManager.ApplyScheme` — на динамическое изменение `ResourceDictionary`. -- [ ] **Трей**: заменить WinForms `NotifyIcon` на `Avalonia.Controls.Notifications` / пакет `Avalonia.Controls.TrayIcon` (есть в Avalonia 11) или `AvaloniaUI.Hydra`. -- [ ] **Отрисовка иконок трея**: заменить `System.Drawing` на `Avalonia.Media.DrawingContext` / рендер в `WriteableBitmap`. - -### Этап 4. Адаптация путей и хранилища (Linux-специфика 1С) -- [ ] **Данные приложения**: `%APPDATA%\ConfigurationManagement` → `~/.config/ConfigurationManagement` (через `Environment.SpecialFolder.ApplicationData` — на Linux он уже указывает туда, проверить). -- [ ] **`ibases.v8i`**: на Linux платформа хранит список в `~/.1cv8/1CEStart/ibases.v8i` (или `~/.local/share/1cv8`). Обновить `IbasesV8iImporter.FindDefaultPath()`. -- [ ] **Кэш 1С**: каталоги `~/.1cv8/1cv8//...` вместо `%LOCALAPPDATA%\1C\1cv8`. Обновить `OneCCacheCleaner`. -- [ ] **Шаблоны**: `~/.local/share/1C/1cv8/tmplts` / системные `/opt/1cv8/.../tmplts`. Обновить `OneCTemplateService`; убрать чтение реестра, заменить чтением `1cestart.cfg` (он в `~/.1cv8/1CEStart/`). - -### Этап 5. Порт сервисов запуска и платформы 1С -- [ ] **`PlatformVersionService`**: искать `1cv8` (без `.exe`) в `/opt/1cv8`, `~/.1cv8`, `/usr/bin`; бинарники `1cv8`, `1cv8c`, `1cv8s`, `ragent`. Определение архитектуры через `file`/`readelf` вместо `.exe`. -- [ ] **`OneCLauncher`**: командная строка 1С на Linux **совместима** (`ENTERPRISE`, `/F`, `/S`, `/RunModeManagedApplication` и т.д.), но пути разделители и запуск через `/opt/1cv8//bin/1cv8`. Убрать `1CEStart.exe`/`1cv8x64.exe`, WMI. -- [ ] **Чтение командной строки процессов** (`Win32_Process`): заменить на чтение `/proc//cmdline` (Linux). -- [ ] **Завершение процессов 1С**: `Process.GetProcessesByName("1cv8")` (без `.exe`) + `Kill()`, либо `pkill`. -- [ ] **COM-коннектор** (`OneCComConnector`): на Linux COM отсутствует. Использовать **`1cv8` в режиме `-Fr <база> -Execute ...`** / `DESIGNER` для чтения метаданных, либо **вызов `rac`/`1cv8`** через CLI. Заменить `IOneCComConnector` на реализацию через командную строку. `OneCComConnectorRegistrar` — удалить/заглушить (нет COM-регистрации). - -### Этап 6. Ярлыки, файловые менеджеры, открытие папок -- [ ] **Ярлык на рабочем столе**: вместо `.lnk` (COM `WScript.Shell`) создавать **`.desktop`-файл** (`~/.local/share/applications/*.desktop`) или на рабочем столе. -- [ ] **Открытие каталога в файловом менеджере**: вместо `explorer.exe /select,` — `xdg-open`/`xdg-open ` (или `gio open`, `nautilus`/`dolphin`). -- [ ] **Открытие ссылок/веб**: `xdg-open` для URL. -- [ ] **Запуск стартера 1С** (`1CEStart.exe` → `1cestart` в `/opt/1cv8/.../common/`). - -### Этап 7. Сборка и упаковка -- [ ] Обновить [`build.sh`](Configuration Management/build.sh) и [`build.ps1`](Configuration Management/build.ps1) под Avalonia+Linux. -- [ ] Self-contained single-file публикация: `dotnet publish -r linux-x64 --self-contained true -p:PublishSingleFile=true`. -- [ ] Упаковка: **AppImage** / **.deb** / **.rpm** / snap (по выбору). -- [ ] Иконка приложения: перенести `app.ico` → `app.png`/`.desktop` `Icon=`. -- [ ] Обновить `README.md` и `CHANGELOG.md` (раздел «Linux»). - -### Этап 8. Тестирование -- [ ] Смок-тест запуска на чистой Ubuntu. -- [ ] Проверка всех сценариев: запуск баз, конфигуратор, выгрузка `.dt`/`.cf`, очистка кэша, синхронизация `ibases.v8i`, трей, темы, хоткеи, перетаскивание. -- [ ] Совместимость с Linux-версией 1С 8.3 (тонкий/толстый клиент). -- [ ] Тесты на многомониторной конфигурации и DPI (Avalonia поддерживает HiDPI). - ---- - -## 4. Риски и сложности - -| Риск | Описание | Митигация | -|------|----------|-----------| -| **Объём UI-порта** | ~20 окон, кастомные контролы, 4 темы | Механический конвертер XAML + ручная доводка; приоритизация окон | -| **Нет COM-коннектора на Linux** | Чтение метаданных конфигурации иначе | Альтернатива через `1cv8 -Execute`/`DESIGNER`; возможно, сокращение функциональности на первом этапе | -| **Отличия Linux-версии 1С** | Иные пути, бинарники, отсутствие `ibases.v8i`-совместимости | Отдельные реализации сервисов через абстракции (`#if LINUX`) | -| **Кастомный список/DatGrid** | Сложная виртуализация, группировка, колонки | Переписать `LeveledTreeView` под Avalonia; тестировать на больших списках баз | -| **Трей** | Разные DE (GNOME/KDE) по-разному работают с треем | Использовать `TrayIcon` Avalonia + AppIndicator (пакет `AvaloniaGtkAppIndicator`) | -| **Пути и разделители** | Обратные слэши в данных/путях | Покрыть `Path.DirectorySeparatorChar`; миграция/нормализация путей существующих данных | - ---- - -## 5. Оценка трудозатрат (ориентировочно) - -| Этап | Трудоёмкость | -|------|--------------| -| Этап 0–1 (инфраструктура, csproj, Avalonia) | 1–2 дня | -| Этап 2 (App, DI, один экземпляр, ошибки) | 2–3 дня | -| Этап 3 (окна/контролы/конвертеры/темы) | 10–20 дней | -| Этап 4 (пути, хранилище, ibases.v8i) | 3–4 дня | -| Этап 5 (сервисы платформы 1С, launcher) | 5–8 дней | -| Этап 6 (ярлыки, xdg-open) | 1–2 дня | -| Этап 7 (сборка/упаковка) | 2–3 дня | -| Этап 8 (тестирование) | 5–10 дней | -| **Итого** | **≈ 30–50 человеко-дней** | - ---- - -## 6. Рекомендуемый порядок внедрения - -1. Сначала перенести **инфраструктуру** и **одно простое окно** (например `NameInputWindow`) — доказать, что Avalonia работает. -2. Перенести **хранилище и пути** (`InfobaseRepository`, `IbasesV8iImporter`, `OneCCacheCleaner`) — база для всего остального. -3. Перенести **`PlatformVersionService` + `OneCLauncher`** — критично для основного сценария «запуск базы». -4. Перенести **главное окно** (`MainWindow`) и трей. -5. Остальные окна и сервисы — по убыванию приоритета. -6. Упаковка и тестирование на целевых дистрибутивах. \ No newline at end of file diff --git a/README.md b/README.md index 236ac9dc..931fdcbb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Управление конфигурациями 1С -![Версия](https://img.shields.io/badge/Версия-0.3.3.24-1F6FEB) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![Windows/WPF](https://img.shields.io/badge/Windows-WPF-4B8BBE) ![Linux/Avalonia](https://img.shields.io/badge/Linux-Avalonia%2011-8B5CF6) ![Лицензия](https://img.shields.io/badge/Лицензия-Open%20Source-success) +![Версия](https://img.shields.io/badge/Версия-0.3.4.15-1F6FEB) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![Windows/WPF](https://img.shields.io/badge/Windows-WPF-4B8BBE) ![Linux/Avalonia](https://img.shields.io/badge/Linux-Avalonia%2011-8B5CF6) ![Лицензия](https://img.shields.io/badge/Лицензия-Open%20Source-success) -> Десктопное приложение для управления информационными базами 1С:Предприятие 8.3. **Версия 0.3.3.24** +> Десктопное приложение для управления информационными базами 1С:Предприятие 8.3. **Версия 0.3.4.15** **Управление конфигурациями 1С** — это кроссплатформенное настольное приложение на платформе **.NET** (**WPF** на Windows и **Avalonia 11** на Linux), которое заменяет стандартный список баз 1С и предоставляет современный интерфейс для работы с информационными базами: запуск в режимах «1С:Предприятие» и «Конфигуратор», вкладки **Все базы / Избранное / Недавние**, иерархические группы, избранное с `Alt+1…9`, мультифильтр по тегам, очистка кеша, синхронизация с `ibases.v8i`, системный трей, светлая и тёмная темы. Одна кодовая база собирается под обе ОС: `net10.0-windows` (WPF) на Windows и `net10.0` (Avalonia) на Linux. @@ -37,34 +37,46 @@ - **Дополнительные папки платформ** (Настройки → Платформы): версии из нестандартных/портативных каталогов установки (произвольная вложенность без обязательного подкаталога `1cv8`) находятся, показываются в списке и **корректно запускаются** — и для конкретной версии базы, и при выборе новейшей установленной версии, и в приоритетных режимах разрядности. - **«Перейти по ссылке»** в правой панели — аналог одноимённой функции стандартного загрузчика 1С: вставьте ссылку на базу (`e1c://...`, `C:\1C\База`, `server\База`, `http://server/base`) и запустите её в «1С:Предприятие». - **Конфигуратор параметров запуска** — окно «Параметры запуска» устроено как **поле ввода + справочник**: вверху — поле ввода параметров командной строки, ниже — справочник всех ключей запуска 1С с описанием, что делает параметр. Двойной клик по строке справочника подставляет параметр в поле ввода (`/UC`, `/DisableStartupMessages`, `/Debug` и др.). +- **Глобальная настройка «После запуска базы или конфигуратора»** (обе платформы) в **Настройки → Настройки**: сразу после успешного запуска информационной базы или конфигуратора 1С окно приложения можно **свернуть в трей** или **закрыть программу** (полностью завершить работу приложения); значение «Ничего» (по умолчанию) оставляет окно как есть. Работает для запуска из главного окна, по горячим клавишам избранного и из меню трея. ### 🌍 Локализация - **Многоязычный интерфейс**: **русский** и **английский** из коробки; выбор языка в настройках (применяется сразу), автоопределение по языку ОС при отсутствии явного выбора. - **Загрузка других языков без пересборки**: достаточно положить `*.json` (формат как у встроенных) в папку `Languages` рядом с приложением или в каталог данных — язык появится в списке выбора. - При отсутствии перевода строки выполняется откат (английский → русский → ключ), поэтому интерфейс не «ломается» при неполном переводе. Подробнее — [`Localization/README.md`](Configuration Management/Localization/README.md). -- Полностью локализованы диалоговые окна **«Создание информационной базы»** ([`CreateInfobaseWindow.xaml`](Configuration Management/CreateInfobaseWindow.xaml)) и **«Удаление информационной базы»** ([`DeleteInfobaseWindow.xaml`](Configuration Management/DeleteInfobaseWindow.xaml)), компонент **`?`-подсказки** ([`HelpLink.xaml`](Configuration Management/Controls/HelpLink.xaml)), остатки главного окна (подсказки `HelpLink`, строка «Нет выбора») и **текст-заглушка поля поиска** во всех темах ([`DarkTheme.xaml`](Configuration Management/Themes/DarkTheme.xaml), [`LightTheme.xaml`](Configuration Management/Themes/LightTheme.xaml), [`ModernTheme.xaml`](Configuration Management/Themes/ModernTheme.xaml)). Добавлены ключи `HelpLink.*`, `DeleteInfobase.*`, `CreateInfobase.*`, `Main.*` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). +- Полностью локализованы диалоговые окна **«Создание информационной базы»** ([`CreateInfobaseWindow.xaml`](Configuration Management/CreateInfobaseWindow.xaml)) и **«Удаление информационной базы»** ([`DeleteInfobaseWindow.xaml`](Configuration Management/DeleteInfobaseWindow.xaml)), компонент **`?`-подсказки** ([`HelpLink.xaml`](Configuration Management/Controls/HelpLink.xaml)), остатки главного окна (подсказки `HelpLink`, строка «Нет выбора») и **текст-заглушка поля поиска** во всех темах ([`DarkTheme.xaml`](Configuration Management/Themes/DarkTheme.xaml), [`LightTheme.xaml`](Configuration Management/Themes/LightTheme.xaml)). Добавлены ключи `HelpLink.*`, `DeleteInfobase.*`, `CreateInfobase.*`, `Main.*` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). - Дополнительно локализованы оставшиеся строки в Avalonia-контролах и код-бихайндах окон настроек: компонент **`?`-подсказки** (`HelpLink.Avalonia.cs`), поле горячей клавиши **`HotkeyBox`** (WPF и Avalonia), радио сортировки «А → Я» / «Я → А» в окнах **«Выбор группы»** ([`GroupPickerWindow.Avalonia.cs`](Configuration Management/GroupPickerWindow.Avalonia.cs)) и **«Выбор версии платформы»** ([`PlatformVersionPickerWindow.Avalonia.cs`](Configuration Management/PlatformVersionPickerWindow.Avalonia.cs)), «Авто» разрядности сессии и названия начертаний шрифта и встроенных тем в окне настроек. Добавлены ключи `Common.SortAsc/SortDesc`, `Hotkey.Tooltip`, `Theme.Light/Dark`, `Settings.Font.Style*` в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). - Локализованы **диалоговые сервисы** ([`IDialogService.cs`](Configuration Management/Services/IDialogService.cs), [`WpfDialogService.cs`](Configuration Management/Services/WpfDialogService.cs), [`AvaloniaDialogService.cs`](Configuration Management/Services/AvaloniaDialogService.cs)): заголовки сообщений и подтверждений (`Common.Information/Warning/Error/Confirm`), заголовки файловых диалогов (`Dialog.OpenFile/SaveFile/SelectFolder`), фильтр «Все файлы (*.*)» (`Common.AllFiles`) и кнопка «Отмена» (`Common.Cancel`). **Точки входа приложения** ([`App.xaml.cs`](Configuration Management/App.xaml.cs), [`App.axaml.cs`](Configuration Management/App.axaml.cs)): фатальные сообщения через ключи `App.Fatal.*`. Значения по умолчанию параметров интерфейса переведены на пустые строки — локализованный заголовок подставляется в реализациях. +- **Подсказки (ToolTips) кнопок и переключателей главного окна** полностью локализованы в обеих реализациях: WPF — `{loc:Loc Main.*}` (в т.ч. через `MultiBinding` с хоткеем), Avalonia/Linux — `LocalizationManager.T("Main.*Tooltip")` в [`BuildTopBar()`](Configuration Management/MainWindow.Avalonia.cs), [`RefreshColumnHeader()`](Configuration Management/MainWindow.Avalonia.cs) и [`BuildRightPanel()`](Configuration Management/MainWindow.Avalonia.cs); жёстко зашитых русских строк в тултипах нет. Сегментым переключателям «Все / Избранное / Недавние» в Avalonia добавлены подсказки (`Main.AllBasesTooltip`/`Main.FavoritesTooltip`/`Main.RecentTooltip`) для согласования с WPF. Ключи `Main.*Tooltip` согласованы между [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). - Локализованы **строка состояния, сообщения диалогов и тултипы главной ViewModel**: Avalonia/Linux ([`MainViewModel.Avalonia.cs`](Configuration Management/ViewModels/MainViewModel.Avalonia.cs)) — строка состояния (`Main.Ready/StatusBase/StatusGroup/LoadedBases`), тултип правой панели (`Main.CollapseRightPanel/ExpandRightPanel`), история запуска (`Main.LaunchAction`), сообщения диалогов (`Main.EditBaseInfo/AddBaseInfo/ConfirmDeleteBase/RefreshConfigInfoMsg/ErrOpenBaseFolder/ShortcutCreated/ErrShortcutCreate/ErrStartStarter/ErrLoadBases`) и синхронизация с ibases.v8i (`Sync.ChooseIbasesFile/IbasesFilter/Completed/ImportedCount/ErrSyncFailed/Failed`); WPF ([`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs)) — слова строки состояния (`Main.StatusPort/StatusPlatform/StatusUser`) и сообщения синхронизации (`Sync.PrefixExported/PrefixImported/ExportError/ImportError/AddedBases/UpdatedBases/RemovedBases/Skipped/GroupsCreated`). Технические значения сессии и логи не затрагивались. - Локализованы **отображаемые свойства моделей данных** ([`Infobase.cs`](Configuration Management/Models/Infobase.cs)): разрядность `ArchitectureDisplay` (`Infobase.ArchX64/X86/Priority64/Priority32`), тип подключения и статус базы (`Infobase.Type.*`, `Infobase.Status.*`, `Infobase.Unavailable.*`), режим запуска (`Infobase.LaunchMode.Auto`), «Не запускалась» (`Infobase.LastLaunch.Never`), история запусков (`Infobase.History.Empty/Summary`), единицы размера (переиспользован `CacheClean.SizeUnits`); группа «Без группы»/«Закреплённые» (`Group.NoGroup`, `Main.Pinned`). В редакторе тем ([`ColorScheme.cs`](Configuration Management/Models/ColorScheme.cs)) названия цветов переведены через ключи `Color.*`. Технические хранимые значения не изменены. Ключи `Infobase.*`, `Group.*`, `Color.*` добавлены в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). - **Локализованы специальные узлы дерева групп** — «Закреплённые», «Все базы» и «Без группы» ([`GroupNodeViewModel.cs`](Configuration Management/ViewModels/GroupNodeViewModel.cs), [`GroupNodeViewModel.Avalonia.cs`](Configuration Management/ViewModels/GroupNodeViewModel.Avalonia.cs), [`MainViewModel.cs`](Configuration Management/ViewModels/MainViewModel.cs), [`MainWindow.xaml.cs`](Configuration Management/MainWindow.xaml.cs)). Введён внутренний маркер спец-узла (`Pinned`/`AllBases`/`NoGroup`); отображаемое имя `DisplayName` вычисляется через `LocalizationManager.T(...)` (ключи `Main.Pinned`, `Main.FlatAllBases`, `Group.NoGroup`) и обновляется при смене языка. Логика сравнений (выбор иконки, определение узла «Без группы»/«Закреплённые», DnD) переведена на маркер и не зависит от языка. Добавлено свойство `NodeKey` для языка-независимого сохранения состояния развёрнутости. -- Вынесены в локализацию **оставшиеся сообщения об ошибках создания ИБ `CREATEINFOBASE` в Linux-шинме** ([`LinuxOneCServiceShims.cs`](Configuration Management/Services/LinuxOneCServiceShims.cs)): ошибка создания каталога (переиспользован `Launcher.CreateDirCreateFailedFormat`) и ошибка запуска `CREATEINFOBASE` (новый ключ `Launcher.CreateProcessErrorFormat`). Ключ добавлен в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). +- Вынесены в локализацию **оставшиеся сообщения об ошибках создания ИБ `CREATEINFOBASE` в Linux-реализации запуска** ([`OneCLauncher.Linux.cs`](Configuration Management/Services/OneCLauncher.Linux.cs)): ошибка создания каталога (переиспользован `Launcher.CreateDirCreateFailedFormat`) и ошибка запуска `CREATEINFOBASE` (ключ `Launcher.CreateProcessErrorFormat`). Ключи добавлены в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). - Локализовано **сообщение исключения «Резервная копия не найдена.»** ([`IbasesBackupService.cs`](Configuration Management/Services/IbasesBackupService.cs)): текст подставляется в ошибку восстановления резервной копии (`Settings.Ibases.RestoreFailed`) через новый ключ `Settings.Ibases.BackupNotFound`. Ключ добавлен в [`ru.json`](Configuration Management/Localization/Languages/ru.json) и [`en.json`](Configuration Management/Localization/Languages/en.json). +- **Названия колонок главного окна** полностью локализованы в обеих реализациях: WPF — `{loc:Loc Column.*}` в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml), Avalonia/Linux — `LocalizationManager.T("Column.*")` в [`MainWindow.Avalonia.cs`](Configuration Management/MainWindow.Avalonia.cs). Ключи `Column.*` согласованы между `ru.json`/`en.json` (Name, Platform version, Configuration, Launch mode, Server/Base, Last launch, Size); жёстко зашитых названий колонок нет. - Из форматирования размеров **убраны жёстко прописанные русские fallback-единицы** («Б,КБ,МБ,ГБ,ТБ») в [`CacheCleanWindow.xaml.cs`](Configuration Management/CacheCleanWindow.xaml.cs), [`CacheCleanWindow.Avalonia.cs`](Configuration Management/CacheCleanWindow.Avalonia.cs) и [`Infobase.cs`](Configuration Management/Models/Infobase.cs): единицы теперь берутся исключительно из локализованного ключа `CacheClean.SizeUnits`. - **Локализованы суффиксы шаблонов конфигураций** «(демо)» / «(пустая)» ([`OneCTemplateService.cs`](Configuration Management/Services/OneCTemplateService.cs), [`CreateInfobaseWindow.xaml.cs`](Configuration Management/CreateInfobaseWindow.xaml.cs)): добавление суффиксов к имени шаблона и их отсечение при подсказке имени базы выполняются через ключи `Template.SuffixDemo` / `Template.SuffixEmpty`. Добавлены в [`ru.json`](Configuration Management/Localization/Languages/ru.json) (`" (демо)"` / `" (пустая)"`) и [`en.json`](Configuration Management/Localization/Languages/en.json) (`" (demo)"` / `" (empty)"`). Детекция «demo/демо», «empty/пуст» по именам каталогов не изменялась. - **Локализованы имена встроенных тем оформления «Светлая» и «Тёмная»** ([`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs)). В выпадающем списке тем окна настроек отображаются переводимые имена через ключи `Theme.Light` / `Theme.Dark`, тогда как канонические идентификаторы (`BuiltInLightName`/`BuiltInDarkName`, значения `ColorScheme.CreateLight()`/`CreateDark()`) остаются неизменными и используются для сохранения/сравнения тем на диске. Кастомные пользовательские темы отображаются без перевода. - **Локализовано чтение назначенных горячих клавиш** ([`SettingsWindow.xaml.cs`](Configuration Management/SettingsWindow.xaml.cs)). При чтении пустого назначения метод `ReadHotkeyBox(...)` сравнивает значение с локализованным ключом `Common.None` вместо жёстко прописанного русской строки «Нет», поэтому распознавание «нет горячей клавиши» корректно работает при любом языке интерфейса (русский/английский). +- **Тексты кнопок правой панели главного окна** полностью локализованы в обеих реализациях: WPF — `{loc:Loc Main.*}` в [`MainWindow.xaml`](Configuration Management/MainWindow.xaml), Avalonia/Linux — `LocalizationManager.T("Main.*")` в методе [`BuildRightPanel()`](Configuration Management/MainWindow.Avalonia.cs) ([`PrimaryActionButton`](Configuration Management/MainWindow.Avalonia.cs)/[`SecondaryActionButton`](Configuration Management/MainWindow.Avalonia.cs)). Локализованы подписи кнопок, заголовки секций, тексты split-кнопок и пункты их контекстных меню; жёстко зашитых русских строк в текстах кнопок нет. Ключи `Main.*` согласованы между `ru.json`/`en.json`; исправлен перевод `Main.SessionClientOrdinary` («Thin (managed)» → «Ordinary»). +- **Финальный аудит прочих непереведённых строк интерфейса по всему проекту** (окна, контролы, сервисы, ViewModel, обе реализации WPF/Avalonia): проверено regex-поиском по кириллице в строковых литералах C# (`*Window.Avalonia.cs`, `*Window.xaml.cs`, `*ViewModel*.cs`, `Services/*.cs`, `Controls/*.cs`) и по содержимому XAML/AXAML вне `{loc:Loc …}`/`Content=`; комментарии игнорировались. **Жёстко зашитых пользовательских строк не обнаружено** — весь пользовательский текст уже вынесен в локализацию. Оставшиеся русские строки — технические (внутренние ключи режимов запуска/клиента, сопоставляемые с переводимыми подписями через маппинги), строки журналов и диагностики — намеренно не изменены, чтобы не сломать логику хранения/сопоставления. + +### 💾 Резервное копирование и восстановление профиля +- **Сохранение всех настроек в произвольный каталог и восстановление после переустановки системы** (обе платформы): вкладка **«Резервное копирование»** в окне настроек позволяет одной кнопкой сохранить весь «профиль» приложения — настройки интерфейса (`settings.json`), список информационных баз вместе с **пользователями и паролями запуска** (`infobases.json`), дерево групп (`groups.json`) и стандартный файл списка баз 1С `ibases.v8i`. После переустановки системы достаточно указать тот же каталог и нажать **«Восстановить профиль»**, либо включить флажок **«Восстанавливать профиль при запуске»** — тогда приложение при каждом запуске само копирует файлы профиля обратно и сразу открывается привычно настроенным, без ручных действий. +- Реализовано единым кросс-платформенным сервисом [`ProfileBackupService.cs`](Configuration Management/Services/ProfileBackupService.cs); источник и цель `ibases.v8i` определяются по настроенному пути или стандартному месту 1С. ### 🎛️ Интерфейс и плотность -- **Компактный режим** (обе платформы): переключатель доступен и **в правом верхнем углу главного окна**, и в **Настройки → Настройки**. Уменьшает размеры иконок и кнопок, сокращает отступы, внутренние поля секций-карточек и расстояния между элементами, а также ширину правой панели — убирая избыточное пустое пространство. Изменение применяется сразу и сохраняется. +- **Компактный режим** (обе платформы): переключатель доступен и **в правом верхнем углу главного окна**, и в **Настройки → Настройки**. Уменьшает размеры иконок и кнопок, сокращает отступы, внутренние поля секций-карточек и расстояния между элементами, а также ширину правой панели — убирая избыточное пустое пространство. Изменение применяется сразу и сохраняется. В компактном режиме дополнительно уменьшаются **шрифт имени базы 1С**, **шрифт имени групп**, **высота оформления групп** и **расстояние между группами**, а **ширина правой панели** максимально сжимается по длине названий команд и выпадающих меню. - **Настройка шрифта применяется ко всему интерфейсу, включая дерево групп и список баз**: у строк дерева убраны жёсткие размеры шрифта (элементы наследуют применяемый шрифт), а шрифты отдельных областей (кнопки, поля ввода, дерево групп, правая панель, статус-бар) применяются и при запуске приложения. - **Выбор языка интерфейса** находится во вкладке **«Настройки»** окна настроек. +- **Кнопка «Скопировать техническую информацию»** во вкладке **«О программе»** окна настроек (обе платформы): одним нажатием в буфер обмена копируется **обезличенный** технический отчёт — версия приложения, тип интерфейса (WPF/Avalonia), ОС, архитектура, среда выполнения .NET, число логических процессоров, объём физической памяти и памяти процесса, язык/регион. Личные данные (имя пользователя, имя компьютера, домен, сетевые адреса, пути профилей) намеренно исключены — это удобно передать разработчику для диагностики проблемы. Реализовано единым кросс-платформенным сервисом [`TechnicalInfoService.cs`](Configuration Management/Services/TechnicalInfoService.cs). ### 🗂️ Управление списком баз - **Вкладки фильтра**: **Все базы**, **Избранное**, **Недавние** (по дате последнего запуска). - Иерархический список с группами, закреплёнными базами и узлом «Без группы». -- Колонки: наименование, версия платформы, режим запуска, сервер/база, последний запуск, размер; видимость колонок настраивается, **ширину любой колонки можно изменять** перетаскиванием разделителя на её правой границе (данные и заголовок двигаются синхронно). При вложенных группах **колонки данных остаются выровненными с заголовками**; сдвигается только блок ★ / 📌 / название (под иконкой родительской группы). -- **Цветные иконки статуса баз** слева от названия показывают тип подключения и доступность базы: папка **янтарного** цвета — файловая база, глобус **синего** цвета — база на веб-сервере, сеть **фиолетового** цвета — клиент-серверная база, **красный крест** — база недоступна (для файловых — каталог/`1Cv8.1CD` не найден на диске; для клиент-серверных и веб-баз — не заполнены параметры подключения). При наведении на иконку показывается подсказка со статусом. Сетевая доступность удалённо не проверяется, чтобы не блокировать интерфейс. +- Колонки: наименование, версия платформы, режим запуска, сервер/база, последний запуск, размер; видимость колонок настраивается, **ширину любой колонки можно изменять** перетаскиванием разделителя на её правой границе (данные и заголовок двигаются синхронно). **Порядок колонок настраивается** на обеих платформах в **Настройки → Отображение → Колонки** кнопками «Вверх»/«Вниз» (колонки «Название» и «Действия» закреплены; «Действия» всегда стоит сразу после «Режима запуска»); по умолчанию колонка «Конфигурация» стоит в самом конце. При вложенных группах **колонки данных остаются выровненными с заголовками**; сдвигается только блок ★ / 📌 / название (под иконкой родительской группы). +- **Цветные иконки статуса баз** слева от названия показывают тип подключения и доступность базы: папка **янтарного** цвета — файловая база, глобус **синего** цвета — база на веб-сервере, сеть **фиолетового** цвета — клиент-серверная база, **красный крест** — база недоступна (для файловых — каталог/`1Cv8.1CD` не найден на диске; для клиент-серверных и веб-баз — не заполнены параметры подключения). При наведении на иконку показывается подсказка со статусом. Сетевая доступность удалённо не проверяется автоматически, чтобы не блокировать интерфейс. +- **Кнопка «Доступность» (иконка-сонар) в верхней панели команд** (обе платформы): по нажатию проверяет доступность всех баз по факту и сразу обновляет их иконки в списке — недоступные базы помечаются красным крестиком. Для файловых баз доступность определяется наличием каталога/файла по пути, для клиент-серверных — реальной попыткой подключения через COM-коннектор, для веб-баз — заполненностью адреса. Проверка выполняется в фоне, чтобы не блокировать интерфейс; после завершения в **нижней строке состояния** на 10 секунд показывается сообщение «Проверка доступности: всего {0}, недоступно {1}». +- **COM-коннектор 1С вынесен в отдельный процесс-агент** (Windows/WPF; авторство — [ksv47](https://github.com/ksv47), PR #69): чтение сведений о конфигурации 1С больше не выполняется в процессе приложения. Это устраняет молчаливое завершение на старте с кодом **0xC0000409** — прямой COM-вызов `comcntr.dll` под CoreCLR обрывал процесс нативным fast-fail без управляемого исключения. Агент обслуживает запросы по протоколу `stdin`/`stdout`, пароль в командную строку не попадает; текст ошибки 1С скрывается, если у базы задан пароль; строка подключения экранируется; таймаут честный, диагноз сохраняется даже при гибели агента; добавлена поддержка платформы 8.5 в переборе ProgID. Прямой COM-вызов в текущем процессе запрещён (метод помечен `[Obsolete]`). Логика агента покрыта юнит-тестами в [`Configuration Management.Tests`](Configuration Management.Tests/). - **Сортировка по клику** на заголовки колонок (название, дата и др.). - Добавление, редактирование и удаление баз; мастер «Что добавить?» (база или группа). - **Избранное** (★) и **закрепление** (📌) в одном блоке с названием, без лишних промежутков; закреплённые — в отдельной группе сверху. @@ -98,6 +110,7 @@ - Очистка локального кеша платформы для выбранной базы по GUID (программный и/или пользовательский). - **В окне «Очистка кэша» рядом с типами кеша показывается их текущий размер** — «Программный кэш (1,2 ГБ)», «Пользовательский кэш (340 МБ)». Размер вычисляется асинхронно по содержимому корневых каталогов кеша (на Windows `%LOCALAPPDATA%\1C\1cv8…` и `%APPDATA%\1C\1cv8…`; на Linux `~/.cache/1cv8`, `~/.local/share/1cv8`, `~/.1cv8/1cv8`) и отображается в человекочитаемом виде с единицами, зависящими от языка интерфейса (Б/КБ/МБ/ГБ/ТБ или B/KB/MB/GB/TB). - **Для каждой базы в списке окна показаны две колонки с размером её кеша — «Программный» и «Пользовательский».** Это позволяет сразу оценить, какая база занимает больше всего места, ещё до очистки. +- **Размер кеша выбранной базы отображается в правой панели главного окна** (строка **«Размер кеша»** в блоке сведений о подключении) на обеих платформах (Windows/WPF и Linux/Avalonia). Значение вычисляется асинхронно в фоновом потоке при выборе базы и показывается в человекочитаемом виде; пока размер не определён, выводится «…». - **Очистка «остатков» кеша от удалённых баз** — чекбокс **«Остатки от удалённых баз»** с индикатором занимаемого размера в нижней части окна. Он показывает каталоги кеша, не соответствующие ни одной текущей информационной базе (остались после удаления базы из списка или переноса её в другое приложение), и при включении удаляет их вместе с кешем выбранных баз. Очистку остатков можно выполнить и без выбора конкретных баз. - **Шапка колонок списка закреплена вверху** и остаётся видимой при прокрутке. **Ширину любой колонки можно изменить**, перетаскивая узкую зону захвата на её правой границе (как в главном окне); изменяется только выбранная колонка. Ширины запоминаются при закрытии окна и восстанавливаются при следующем открытии. - **Split-кнопка «Очистка кеша»** в секции «Обслуживание» правой панели — по аналогии с кнопкой запуска «1С:Предприятие»: прямое нажатие открывает окно очистки кеша, а стрелка «▾» справа открывает выпадающее меню («Открыть окно очистки кеша…», «Программный кеш…», «Пользовательский кеш…», «Программный и пользовательский»). Кнопка доступна даже при выбранной группе: тогда окно очистки открывается без выделенной базы в списке; при выбранной базе она выделяется в окне по умолчанию. @@ -214,7 +227,7 @@ Результат публикации: `publish/linux-x64/` — один исполняемый файл `ConfigurationManagement` (self-contained, не требует установки .NET Runtime). -> На Linux проект собирается как **Avalonia** (`net10.0`); на Windows — как **WPF** (`net10.0-windows`). Целевой TFM и RID выбираются автоматически по ОС. `build.sh` собирает/публикует и Windows (`win-x64`), и Linux (`linux-x64`). +> Целевой TFM и RID выбираются автоматически по ОС, на которой запускается сборка: на **Windows** — WPF (`net10.0-windows`, RID `win-x64`), на **Linux** — Avalonia (`net10.0`, RID `linux-x64`). Скрипт [`build.sh`](Configuration Management/build.sh) собирает/публикует для текущей ОС (Linux — `linux-x64`, Windows — `win-x64`); на Windows то же выполняет [`build.ps1`](Configuration Management/build.ps1). ### 📦 Установка на Linux @@ -278,13 +291,19 @@ dotnet run --project "Configuration Management/Configuration Management.csproj" ### 📦 Публикация автономного приложения -Проект настроен на публикацию в виде **автономного (self-contained) single-file** исполняемого файла для Windows x64. Для публикации выполните: +Проект настроен на публикацию в виде **автономного (self-contained) single-file** исполняемого файла. Для Windows x64 выполните ([`build.ps1`](Configuration Management/build.ps1)): -```bash -dotnet publish "Configuration Management/Configuration Management.csproj" -c Release +```powershell +.\build.ps1 -Publish ``` -Результат появится в каталоге `publish/` — один исполняемый файл `Configuration Management.exe`, который не требует установки .NET Runtime на целевой машине. +либо вручную: + +```powershell +dotnet publish "Configuration Management.csproj" -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true +``` + +Результат — в каталоге `publish\win-x64\`: один исполняемый файл **`ConfigurationManagement.exe`** (~75 МБ, self-contained single-file со встроенными нативными библиотеками), который не требует установки .NET Runtime на целевой машине. --- @@ -347,7 +366,6 @@ Configuration Management/ │ ├── ThemeManager.Avalonia.cs # То же для Avalonia (Linux) │ ├── LightTheme.xaml / LightTheme.axaml │ ├── DarkTheme.xaml / DarkTheme.axaml -│ ├── ModernTheme.xaml # Базовая тема Material Design (Windows) │ └── Icons.xaml / Icons.axaml # Иконки (Geometry) ├── Services/ # Сервисы │ ├── OneCLauncher.cs / OneCLauncher.Linux.cs # Запуск платформы 1С (WPF / CLI `1cv8`) @@ -363,6 +381,7 @@ Configuration Management/ │ ├── PlatformPaths.cs # Пути данных/кэша/шаблонов (Windows и Linux/XDG) │ ├── OneCComConnector.cs / .Linux.cs # COM-коннектор 1С (Windows) / эвристика+DESIGNER (Linux) │ ├── OneCComConnectorRegistrar.cs # Регистрация COM-компонента (только Windows) +│ ├── ComReadHost.cs # COM-агент: чтение сведений в отдельном процессе (автор — ksv47) │ ├── ConfigurationInfoService.cs # Запрос информации о конфигурации │ ├── LinuxProcess.cs # Чтение командной строки из /proc//cmdline (Linux) │ ├── IOneCLauncher.cs / IPlatformVersionService.cs / IAppLogger.cs / IDialogService.cs @@ -374,7 +393,6 @@ Configuration Management/ │ ├── LaunchViewModel.cs / LaunchViewModel.Avalonia.cs # Логика запуска (LaunchCommand) │ ├── ConnectionSettingsViewModel.cs │ ├── GroupNodeViewModel.cs / GroupNodeViewModel.Avalonia.cs # Узел дерева групп -│ ├── MetadataNodeViewModel.cs │ ├── ViewModelBase.cs / ViewModelBase.Avalonia.cs │ └── RelayCommand.cs / RelayCommand.Avalonia.cs ├── Controls/ # Переиспользуемые элементы (WPF + *.Avalonia.cs) @@ -382,7 +400,7 @@ Configuration Management/ │ ├── InfobaseListView.xaml(.cs) # Список информационных баз │ └── HelpLink.xaml(.cs) # Гиперссылка-подсказка «?» ├── Converters/ # Конвертеры WPF (Windows) -├── Converters/Avalonia/ # Конвертеры Avalonia (Linux) — 18 шт. +├── Converters/Avalonia/ # Конвертеры Avalonia (Linux) — 10 шт. ├── build.sh / build-linux-single-file.sh / build.ps1 # Скрипты сборки ├── Configuration Management.csproj # Единый csproj с условной компиляцией по ОС @@ -436,6 +454,24 @@ tools/ --- +## 🙏 Благодарности + +Существенную часть работ по **Linux/Avalonia-версии** — доведение приложения до сборки, запуска и работы с платформой 1С:Предприятие — выполнил **ksv47**. Все правки, вошедшие в слияние **PR #54** (`ksv47/linux-fixes`), принадлежат авторству **ksv47** (см. записи 0.3.3.12–0.3.3.24 и 0.3.3.27 в [`CHANGELOG.md`](CHANGELOG.md)). + +Продолжением стала **перетаскивание баз и групп мышью** в Linux/Avalonia-версии вместе с действием после запуска базы или конфигуратора и сохранением выбора при пересборке дерева — все правки слияния **PR #55** (`ksv47/linux-fixes`) также выполнены автором **ksv47** (см. запись 0.3.3.31 в [`CHANGELOG.md`](CHANGELOG.md)). + +Далее были влиты ещё несколько слияний из ветки `ksv47/linux-fixes` (а также `ksv47/linux-fixes-2` для PR #61): + +- **PR #56** — освобождение подписок диалогов на ресурсы темы и локализацию, оформление окон сообщений темой, кисти темы через динамический ресурс вместо ручных подписок, прокрутка переживает пересборку дерева (см. запись 0.3.3.45 в [`CHANGELOG.md`](CHANGELOG.md)). +- **PR #58** — выпадающие меню кнопок запуска, вертикальная полоса прокрутки списка, клавиши `Home`/`End`/`PageUp`/`PageDown`, разовый запуск с параметрами и с запросом имени/пароля, чистка задвоенных ключей локализации (см. запись 0.3.3.46 в [`CHANGELOG.md`](CHANGELOG.md)). +- **PR #59** — каталоги шаблонов и операции со списком баз на вкладке «Базы» (выгрузка/загрузка JSON, безопасный импорт из `ibases.v8i`) (см. запись 0.3.3.47 в [`CHANGELOG.md`](CHANGELOG.md)). +- **PR #60** — обслуживание и опасные операции на вкладке «Базы», работоспособные настройки трея, упаковка в deb и AppImage (см. запись 0.3.3.48 в [`CHANGELOG.md`](CHANGELOG.md)). +- **PR #61** — читаемый справочник ключей в параметрах запуска, кнопка тегов, меню трея, показывающее текущее состояние списка, исправление перегрузки `SetProperty` (см. запись 0.3.3.49 в [`CHANGELOG.md`](CHANGELOG.md)). + +Все правки этих слияний **#56**, **#58**, **#59**, **#60** и **#61** также выполнены автором **ksv47**. Большое спасибо за вклад! + +--- + ## 🤝 Вклад в проект Буду рад любым улучшениям! Для внесения вклада: @@ -476,19 +512,21 @@ dotnet publish "Configuration Management.csproj" -c Release -r win-x64 --self-co ### Автоматическая сборка (GitHub Actions) +Workflow'ы CI (`.github/workflows/build.yml`, `.github/workflows/release.yml`) в репозитории пока **не добавлены**. Планируются: + | Workflow | Когда запускается | Что делает | |----------|-------------------|------------| -| **Build** (`.github/workflows/build.yml`) | push / PR в `main`, `master`, `develop` | Restore → Build Debug/Release → Publish win-x64 → artifact | -| **Release** (`.github/workflows/release.yml`) | тег `v*.*.*` или ручной запуск | Publish + ZIP + GitHub Release | +| **Build** | push / PR в `main`, `master`, `develop` | Restore → Build Debug/Release → Publish win-x64 → artifact | +| **Release** | тег `v*.*.*` или ручной запуск | Publish + ZIP + GitHub Release | -Создание релиза: +Создание релиза (после добавления workflow): ```bash -git tag v0.3.1.1 -git push origin v0.3.1.1 +git tag v0.3.3.33 +git push origin v0.3.3.33 ``` -Артефакт `ConfigurationManagement-v0.3.1.1-win-x64.zip` (и `-linux-x64`, при включённом Linux-джобе) появится в Releases. +Артефакт `ConfigurationManagement-v0.3.3.33-win-x64.zip` (и `-linux-x64`, при включённом Linux-джобе) появится в Releases. > Для WPF-сборки требуется Windows-раннер (`windows-latest`); для Linux-сборки Avalonia — `ubuntu-latest`. diff --git a/SUMMARY.md b/SUMMARY.md deleted file mode 100644 index 2a7e0226..00000000 --- a/SUMMARY.md +++ /dev/null @@ -1,62 +0,0 @@ -# Краткое summary изменений - -## 0.2.7.3 -- Кнопки **«Выбрать все»** и **«Снять все»** в окне **«Очистка кэша 1С»** переработаны: убраны чекбоксы (квадратики) слева от иконок — теперь это компактные кнопки с иконкой и подписью, которые **подсвечиваются фоном при наведении**. Логика выбора/снятия всех баз сохранена. - -## 0.2.6.39 -- **Гиперссылки-вопросы «?» в интерфейсе** — новый переиспользуемый элемент [`HelpLink`](Configuration%20Management/Controls/HelpLink.xaml) (круглый значок «?»). По клику открывает всплывающую подсказку с описанием поведения элемента и способов взаимодействия; закрывается повторным кликом или кликом вне окна подсказки. Добавлены в главное окно (шапка/список баз, панель тегов, «Текущая сессия»), окно настроек (горячие клавиши, синхронизация ibases.v8i), настройки подключения, мастер «Что добавить?», окно группы и выбор родителя, создание/удаление ИБ, конфигуратор параметров запуска. - -## 0.2.6.13 -- **Двусторонняя синхронизация с ibases.v8i**: при выгрузке удаляются из файла базы, которых нет в приложении; при загрузке удаляются из приложения базы с ID 1С, которых нет в файле. В режиме «Двусторонняя» сначала выгрузка, затем загрузка. -- **Логин и пароль** больше не затираются при импорте из ibases.v8i, если в файле они пустые. -- **Пароль** вводится в `PasswordBox` (скрытые символы) в настройках подключения и в параметрах запуска. - -## 0.2.6.12 -- В **окне выбора платформы** и в **Настройки → Платформы** у узлов дерева свои значки: папка (линия 8.3), открытая папка (группа сборок 8.3.27), зелёный куб (x64), фиолетовый куб (x32), голубой значок приложения (без разрядности). - -## 0.2.6.11 -- **Окно выбора версии платформы** приведено к виду стартера 1С: фильтр **Все / x32 / x64**, сортировка **A→Z / Z→A**, дерево **8.3 → 8.3.27 → 8.3.27.2214 (x64)** (линия → группа сборок → полная версия). - -## 0.2.6.10 -- **Новые иконки приложения и трея**: современный скруглённый тайл с градиентом (indigo → blue → cyan), белый цилиндр БД и янтарный бейдж с галочкой. Файлы `app.ico` (16–256 px) и `tray.ico` (16–64 px). Векторная `AppIcon` в `App.xaml` обновлена в том же стиле. Трей загружает `tray.ico` с приоритетом. - -## 0.2.6.9 -- В **окне выбора платформы** и в **Настройки → Платформы** группировка по линии **8.2 / 8.3 / 8.5** сразу развёрнута (линия → разрядность → сборка), учитываются дополнительные пути поиска платформ. -- Группировка **основного списка баз** по версии платформы (кнопка «8.x» и настройка) — не требуется; группировка 8.x нужна только в выборе платформы. - -## 0.2.6.8 -- Значок **избранного (★)** в строке базы выровнен по горизонтали с **иконкой папки** родительской группы: отступ блока ★ / 📌 / название = `(Level − 1) × 18 + 26` (26 px — ширина кнопки разворота группы). - -## 0.2.6.7 -- **Выравнивание колонок данных** в списке баз при вложенных группах: колонки версии, режима запуска, сервера/пути, даты запуска и размера больше не смещаются относительно заголовков таблицы. Отступ `ItemsPresenter` убран; иерархия показывается сдвигом только нужных элементов. -- **Уровень вложенности (`Level`)** корректно считается для вложенных узлов: добавлен `LeveledTreeViewItem`, который выставляет `Level = parent.Level + 1` при создании дочерних контейнеров (раньше `Level` оставался 0 у всех вложенных строк). -- **Кнопка разворота группы (Expander)** сдвигается по `Level × 18 px`, заголовок группы с выделением оказывается сразу после неё — иерархия «группа в группе» читается явно. -- **Блок ★ / 📌 / название базы** собран в один горизонтальный ряд без лишних промежутков; отступ для баз — `(Level − 1) × 18 px`, старт с колонки 0, чтобы названия оказывались **под иконкой папки** родительской группы, а не правее неё. -- Теги в строке базы используют тот же отступ, что и блок названия. - -## 0.2.6.6 -- Исправлено отображение **смещения вложенных групп** в дереве списка баз («группа в группе»). Раньше заголовки вложенных групп сдвигались через `RenderTransform`, который ненадёжно пересчитывался при виртуализации списка и не влиял на компоновку, из-за чего было непонятно, что одна группа находится внутри другой. Теперь отступ вложенности для заголовков групп применяется через `Margin` (влияет на раскладку и корректно обновляется), поэтому заголовки групп на каждом уровне наглядного сдвигаются вправо, и иерархия «группа в группе» читается явно. - -## 0.2.6.5 -- Исправлена ошибка **перетаскивания группы в другую группу**: раньше информационные базы, находящиеся в перетаскиваемой группе (и во вложенных подгруппах), теряли свою группу и «уезжали» в **«Без группы»**. Причина — в пересчёте путей `Infobase.Group`: смешивались нормализованные и ненормализованные представления пути, а для самой перемещаемой группы маппинг старого пути на новый мог отсутствовать. Из-за этого итоговый путь базы не совпадал с путём группы в дереве. Теперь: - -## 0.2.6.4 -- Кнопки **«Развернуть все группы»** и **«Свернуть все группы»** возвращены в **заголовок таблицы списка баз** (отдельная колонка слева), откуда они ранее были перенесены в верхнюю панель. Возвращена и компенсирующая колонка в строках данных, чтобы колонки строк и заголовка были выровнены. - -## 0.2.6.2 -- Исправлено **изменение ширины колонок списка баз** в главном окне: - -## 0.2.6.1 -- В **правой панели** главного окна в самом низу добавлена кнопка **«Выход»**. Она **всегда полностью завершает работу приложения**, игнорируя настройку **«Закрывать в трей»** — в отличие от обычного закрытия окна (крестиком), которое при включённой опции сворачивает программу в трей. Это удобно, когда включено поведение «свернуть в трей» и нужно быстро полностью выйти из программы без открытия контекстного меню значка в трее. - -## 0.2.5.99 -- В окне **«Настройки подключения»** (используется и при создании новой базы, и при правке свойств) на вкладке **«Подключение»** поле **«Порт сервера»** теперь оформлено как **редактируемый выпадающий список** — по аналогии с полем **«Сервер»**. Список наполняется портами из существующих клиент-серверных баз списка (без дублей, по возрастанию). Это удобно, когда сервер один, а инстансы висят на разных портах: порт можно выбрать из уже используемых или ввести вручную, не обращаясь к вводу строки подключения вручную. - -## 0.2.5.98 -- В окне **«Настройки подключения»** (используется и при создании новой базы, и при правке свойств) кнопка **«Вставить строку подключения»** перенесена из вкладки «Подключение» в **нижнюю панель окна, слева**, поэтому она **всегда доступна** независимо от выбранной вкладки. -- По клику кнопка открывает **отдельное окно ввода строки подключения** с полем для ввода и кнопкой **«Вставить из буфера»** (читает строку из буфера обмена). Если в буфере обмена лежит строка, удовлетворяющая критериям ссылки на информационную базу (строка подключения 1С с параметрами `Srvr`/`Ref`/`File`/`WS`, ссылка-протокол `e1c://`, веб-адрес `http(s)://`, путь к файловой базе или `server\base`), она **автоматически подставляется** в поле при открытии окна. -- Кнопка **«Применить»** в окне ввода разбирает строку подключения 1С и заполняет поля настроек базы (тип подключения, сервер/порт, имя базы, путь файла или URL веб-публикации, пользователь и пароль). Если наименование базы не задано, подставляется имя базы (`Ref`) или каталога файловой базы. -- При открытии окна ввода поле заполняется текущей строкой подключения базы, если она задана. - -## 0.2.5.96 -- Разделены переключатели видимости тегов: diff --git a/package/linux/appimage.sh b/package/linux/appimage.sh old mode 100644 new mode 100755 index 89804de5..2520edaf --- a/package/linux/appimage.sh +++ b/package/linux/appimage.sh @@ -20,12 +20,24 @@ BINARY="$PUBLISH_DIR/ConfigurationManagement" APP_ID="configuration-management" BINARY_NAME="ConfigurationManagement" -ARCH="x86_64" + +# appimagetool читает архитектуру из переменной окружения, иначе угадывает +# её по содержимому AppDir. +export ARCH="${ARCH:-x86_64}" + +CSPROJ="$PROJECT_DIR/Configuration Management.csproj" +VERSION="$(sed -e 's///g' "$CSPROJ" \ + | sed -n 's/.*\([^<]*\)<.*/\1/p' \ + | head -n1 | tr -d '[:space:]')" +if [[ -z "$VERSION" ]]; then + echo "Ошибка: не удалось прочитать InformationalVersion из $CSPROJ" + exit 1 +fi PACKAGE_DIR="$ROOT/package/linux" APPDIR="$PACKAGE_DIR/AppDir" OUTDIR="$PACKAGE_DIR/out" -OUT_FILE="ConfigurationManagement-$ARCH.AppImage" +OUT_FILE="ConfigurationManagement-$VERSION-$ARCH.AppImage" # Инструменты упаковки. По умолчанию appimagetool из PATH; можно переопределить: # APPIMAGETOOL=/путь/к/appimagetool ./appimage.sh @@ -51,12 +63,18 @@ install -m 755 "$BINARY" "$APPDIR/usr/bin/$BINARY_NAME" install -m 644 "$PACKAGE_DIR/$APP_ID.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop" install -m 644 "$PACKAGE_DIR/app.png" "$APPDIR/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" +# appimagetool ищет описание и значок в корне AppDir и без них отказывается +# собирать («Desktop file not found»). Значок дополнительно кладётся под +# именем .DirIcon: его показывает файловый менеджер. +install -m 644 "$PACKAGE_DIR/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" +install -m 644 "$PACKAGE_DIR/app.png" "$APPDIR/$APP_ID.png" +install -m 644 "$PACKAGE_DIR/app.png" "$APPDIR/.DirIcon" + # AppRun: точка входа AppImage (запуск через PATH внутри AppDir) cat > "$APPDIR/AppRun" <<'EOF' #!/usr/bin/env bash SELF="$(dirname "$(readlink -f "$0")")" export PATH="$SELF/usr/bin:$PATH" -export LD_LIBRARY_PATH="$SELF/usr/lib:$LD_LIBRARY_PATH" exec "$SELF/usr/bin/ConfigurationManagement" "$@" EOF chmod +x "$APPDIR/AppRun" diff --git a/package/linux/configuration-management.desktop b/package/linux/configuration-management.desktop index 444c5513..89e7654a 100644 --- a/package/linux/configuration-management.desktop +++ b/package/linux/configuration-management.desktop @@ -1,11 +1,14 @@ [Desktop Entry] Type=Application -Name=Управление конфигурациями 1С -Comment=Управление информационными базами 1С:Предприятие 8.3 +Name=Configuration Management +Name[ru]=Управление конфигурациями 1С +Comment=Manage 1C:Enterprise 8.3 infobases +Comment[ru]=Управление информационными базами 1С:Предприятие 8.3 Exec=ConfigurationManagement Icon=configuration-management Terminal=false -Categories=Office;Development; -Keywords=1C;1С;инфобазы;конфигурации;1cv8; +Categories=Office; +Keywords=1C;infobase;configuration;1cv8; +Keywords[ru]=1C;1С;инфобазы;конфигурации;1cv8; StartupNotify=true -StartupWMClass=ConfigurationManagement \ No newline at end of file +StartupWMClass=ConfigurationManagement diff --git a/package/linux/deb/DEBIAN/control b/package/linux/deb/DEBIAN/control index d97b3a7e..f703778b 100644 --- a/package/linux/deb/DEBIAN/control +++ b/package/linux/deb/DEBIAN/control @@ -1,12 +1,13 @@ Package: configuration-management -Version: 0.3.1.1 +Version: @VERSION@ Section: utils Priority: optional Architecture: amd64 Maintainer: Configuration Management Team -Installed-Size: 120000 -Depends: libc6 (>= 2.31), libgtk-3-0, libglib2.0-0 -Recommends: xdg-utils +Homepage: https://github.com/sivatorov/ConfigurationManagement +Installed-Size: @INSTALLED_SIZE@ +Depends: libc6 (>= 2.31), libstdc++6, libgcc-s1, libicu76 | libicu74 | libicu72 | libicu71 | libicu70 | libicu67, libfontconfig1, libfreetype6, libexpat1, libpng16-16 | libpng16-16t64, zlib1g, libbz2-1.0, libbrotli1, libx11-6, libxext6, libxi6, libxrandr2, libxcursor1, libxfixes3 +Recommends: xdg-utils, libgtk-3-0 | libgtk-3-0t64, libgl1, libice6, libsm6, desktop-file-utils, hicolor-icon-theme Description: Управление конфигурациями 1С Десктопное приложение для управления информационными базами 1С:Предприятие 8.3: запуск в режимах «1С:Предприятие» и «Конфигуратор», @@ -14,4 +15,7 @@ Description: Управление конфигурациями 1С синхронизация с ibases.v8i, системный трей, светлая и тёмная темы. . Пакет является self-contained single-file сборкой (.NET 10, Avalonia), - поэтому не требует установки .NET Runtime. \ No newline at end of file + поэтому не требует установки .NET Runtime. Библиотеки ICU нужны самой + платформе .NET, X11 и шрифтовые библиотеки — отрисовке Avalonia. + GTK нужен только как запасной путь для файловых диалогов, когда + недоступен портал рабочего стола, поэтому он в Recommends. diff --git a/package/linux/deb/build-deb.sh b/package/linux/deb/build-deb.sh old mode 100644 new mode 100755 index 108f2291..6dac1ecb --- a/package/linux/deb/build-deb.sh +++ b/package/linux/deb/build-deb.sh @@ -11,6 +11,11 @@ # ============================================================================= set -euo pipefail +# Права каталогов в пакете не должны зависеть от umask сборщика: иначе +# на системе без каталога значков он создастся доступным на запись группе, +# а сама сборка перестанет быть воспроизводимой. +umask 022 + ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" PROJECT_DIR="$ROOT/Configuration Management" PUBLISH_DIR="$PROJECT_DIR/publish/linux-x64" @@ -20,9 +25,27 @@ APP_ID="configuration-management" BINARY_NAME="ConfigurationManagement" ARCH="amd64" -# Версия берётся из DEBIAN/control (поле Version), чтобы не дублировать. +# Версия берётся только из csproj, запасного источника нет намеренно: +# в control стоит подстановка, и молчаливый откат к устаревшему числу +# (там была 0.3.1.1 при 0.3.3.39 в сборке) хуже честной остановки. CONTROL_SRC="$ROOT/package/linux/deb/DEBIAN/control" -VERSION="$(sed -n 's/^Version:[[:space:]]*//p' "$CONTROL_SRC" | head -n1)" +CSPROJ="$PROJECT_DIR/Configuration Management.csproj" + +# Комментарии отбрасываются: в csproj над тегом лежит пояснение, +# где та же версия упомянута текстом, и оно попадало в подстановку. +VERSION="$(sed -e 's///g' "$CSPROJ" \ + | sed -n 's/.*\([^<]*\)<.*/\1/p' \ + | head -n1 | tr -d '[:space:]')" + +if [[ -z "$VERSION" ]]; then + echo "Ошибка: не удалось прочитать InformationalVersion из $CSPROJ" + exit 1 +fi +if [[ ! "$VERSION" =~ ^[0-9][A-Za-z0-9.+~-]*$ ]]; then + echo "Ошибка: версия «$VERSION» не годится для имени пакета Debian" + exit 1 +fi + PKG="$(sed -n 's/^Package:[[:space:]]*//p' "$CONTROL_SRC" | head -n1)" STAGING="$ROOT/package/linux/deb/staging" @@ -30,6 +53,11 @@ OUTDIR="$ROOT/package/linux/deb/out" OUT_FILE="${PKG}_${VERSION}_${ARCH}.deb" # --- 1. Проверка бинаря ------------------------------------------------------- +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "Ошибка: dpkg-deb не найден. Установите пакет dpkg-dev." + exit 1 +fi + if [[ ! -x "$BINARY" ]]; then echo "Ошибка: не найден собранный single-file бинарь: $BINARY" echo "Сначала выполните сборку:" @@ -47,11 +75,37 @@ mkdir -p "$STAGING/usr/share/applications" mkdir -p "$STAGING/usr/share/icons/hicolor/256x256/apps" mkdir -p "$OUTDIR" -install -m 644 "$CONTROL_SRC" "$STAGING/DEBIAN/control" +# control собирается из шаблона: подстановки заполняются вычисленными +# значениями. Замена идёт не через sed с переменной в правой части, чтобы +# символы вроде & и / в версии не меняли смысл выражения. +python3 - "$CONTROL_SRC" "$STAGING/DEBIAN/control" "$VERSION" <<'PY' +import sys +src, dst, version = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(src, encoding='utf-8').read().replace('@VERSION@', version) +open(dst, 'w', encoding='utf-8').write(text) +PY install -m 755 "$BINARY" "$STAGING/usr/bin/$BINARY_NAME" install -m 644 "$ROOT/package/linux/$APP_ID.desktop" "$STAGING/usr/share/applications/$APP_ID.desktop" install -m 644 "$ROOT/package/linux/app.png" "$STAGING/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" +# Размер установки в килобайтах: только устанавливаемое дерево, без +# служебного каталога DEBIAN, как это считает dpkg-gencontrol. +INSTALLED_SIZE="$(du -sk --exclude=DEBIAN "$STAGING" | cut -f1)" +python3 - "$STAGING/DEBIAN/control" "$INSTALLED_SIZE" <<'PY' +import sys +path, size = sys.argv[1], sys.argv[2] +text = open(path, encoding='utf-8').read().replace('@INSTALLED_SIZE@', size) +open(path, 'w', encoding='utf-8').write(text) +PY + +# Контрольные суммы: без них не работают dpkg --verify и debsums. +(cd "$STAGING" && find . -type f -not -path './DEBIAN/*' -printf '%P\0' \ + | xargs -0 md5sum > DEBIAN/md5sums) + +# Файл о лицензии обязателен по политике Debian для любого пакета. +mkdir -p "$STAGING/usr/share/doc/$PKG" +install -m 644 "$ROOT/package/linux/deb/copyright" "$STAGING/usr/share/doc/$PKG/copyright" + # --- 3. Сборка .deb ------------------------------------------------------------ echo "==> Сборка .deb (dpkg-deb) ..." dpkg-deb --build --root-owner-group "$STAGING" "$OUTDIR/$OUT_FILE" diff --git a/package/linux/deb/copyright b/package/linux/deb/copyright new file mode 100644 index 00000000..1eba8001 --- /dev/null +++ b/package/linux/deb/copyright @@ -0,0 +1,11 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Configuration Management +Source: https://github.com/sivatorov/ConfigurationManagement + +Files: * +Copyright: Sivatorov and contributors +License: see-upstream + Файла лицензии в репозитории проекта нет. Проект объявлен открытым + в README и в публикации https://infostart.ru/1c/articles/2764888/, + но текст лицензии не приложен, поэтому условия здесь не повторяются. + За точными условиями обращайтесь к автору проекта. diff --git a/tools/l10n-audit-report.txt b/tools/l10n-audit-report.txt new file mode 100644 index 00000000..5513857c --- /dev/null +++ b/tools/l10n-audit-report.txt @@ -0,0 +1,15 @@ +=== Localization audit report === +Date: 2026-08-23 20:20:59 +Keys in ru.json: 1186; in en.json: 1186 + +--- (A) Keys present in ru.json but missing in en.json --- + + +--- (B) Keys present in en.json but missing in ru.json (dead) --- + + +--- (C) Keys used in code/XAML but missing in ru.json --- + + +Total unique keys used in code: 1117 + diff --git a/tools/l10n-audit.ps1 b/tools/l10n-audit.ps1 new file mode 100644 index 00000000..cae5189b --- /dev/null +++ b/tools/l10n-audit.ps1 @@ -0,0 +1,74 @@ +# Localization audit: compares ru.json<->en.json key sets and checks that +# every key used in code/XAML exists in ru.json. +# Writes report to tools/l10n-audit-report.txt. +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $PSScriptRoot +$langDir = Join-Path $root 'Configuration Management\Localization\Languages' +$srcDir = Join-Path $root 'Configuration Management' +$reportPath = Join-Path $PSScriptRoot 'l10n-audit-report.txt' + +function Get-Keys([string]$jsonPath) { + $obj = Get-Content -Raw -Encoding UTF8 $jsonPath | ConvertFrom-Json + $keys = @{} + foreach ($p in $obj.strings.PSObject.Properties) { $keys[$p.Name] = $true } + return $keys +} + +$ru = Get-Keys (Join-Path $langDir 'ru.json') +$en = Get-Keys (Join-Path $langDir 'en.json') + +$inRuNotEn = @($ru.Keys | Where-Object { -not $en.ContainsKey($_) } | Sort-Object) +$inEnNotRu = @($en.Keys | Where-Object { -not $ru.ContainsKey($_) } | Sort-Object) + +# Collect keys used in code / XAML: +# LocalizationManager.T("KEY") (incl. string.Format(LocalizationManager.T("KEY"), ...)) +# {loc:Loc KEY} +# Loc["KEY"] / {Binding Loc[KEY]} +$used = @{} +Get-ChildItem -Path $srcDir -Recurse -File -Include *.cs,*.xaml,*.axaml | + Where-Object { $_.FullName -notmatch '\\Languages\\' } | + ForEach-Object { + $text = Get-Content -Raw -Encoding UTF8 $_.FullName + $noLine = [regex]::Replace($text, '//[^\r\n]*', '') + $noBlock = [regex]::Replace($noLine, '(?s)/\*.*?\*/', '') + + $patterns = @( + 'LocalizationManager\.T\(\s*"([^"]+)"\s*\)', + '\{loc:Loc\s+([^}]+)\}', + 'Loc\[\s*"([^"]+)"\s*\]', + 'Binding\s+Loc\[\s*([^\]]+)\s*\]' + ) + foreach ($p in $patterns) { + foreach ($m in [regex]::Matches($noBlock, $p)) { + $key = $m.Groups[1].Value.Trim() + if ($key -match '^[A-Za-z][A-Za-z0-9_.-]*$') { $used[$key] = $true } + } + } + } + +$usedInCode = @($used.Keys | Sort-Object) +$missingFromRu = @($usedInCode | Where-Object { -not $ru.ContainsKey($_) } | Sort-Object) + +$sb = New-Object System.Text.StringBuilder +[void]$sb.AppendLine('=== Localization audit report ===') +[void]$sb.AppendLine("Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')") +[void]$sb.AppendLine("Keys in ru.json: $($ru.Count); in en.json: $($en.Count)") +[void]$sb.AppendLine() +[void]$sb.AppendLine('--- (A) Keys present in ru.json but missing in en.json ---') +if ($inRuNotEn.Count -eq 0) { [void]$sb.AppendLine(' ') } +else { foreach ($k in $inRuNotEn) { [void]$sb.AppendLine(" $k") } } +[void]$sb.AppendLine() +[void]$sb.AppendLine('--- (B) Keys present in en.json but missing in ru.json (dead) ---') +if ($inEnNotRu.Count -eq 0) { [void]$sb.AppendLine(' ') } +else { foreach ($k in $inEnNotRu) { [void]$sb.AppendLine(" $k") } } +[void]$sb.AppendLine() +[void]$sb.AppendLine('--- (C) Keys used in code/XAML but missing in ru.json ---') +if ($missingFromRu.Count -eq 0) { [void]$sb.AppendLine(' ') } +else { foreach ($k in $missingFromRu) { [void]$sb.AppendLine(" $k") } } +[void]$sb.AppendLine() +[void]$sb.AppendLine("Total unique keys used in code: $($usedInCode.Count)") + +Set-Content -Path $reportPath -Value $sb.ToString() -Encoding UTF8 +Write-Host "Report written: $reportPath" +Write-Host "A) inRuNotEn=$($inRuNotEn.Count) B) inEnNotRu=$($inEnNotRu.Count) C) missingFromRu=$($missingFromRu.Count)" \ No newline at end of file