Compare commits

..
Author SHA1 Message Date
agrynco 64b03a2168 Release KSwitcher v0.6.91 2026-11-16 01:08:40 +02:00
agrynco 36baeeb081 Merge pull request 'Release KSwitcher v0.6.90' (#34)
Release KSwitcher v0.6.90
2026-11-16 00:46:44 +02:00
agrynco 7698ea040d Release KSwitcher v0.6.90 2026-11-16 00:46:06 +02:00
agrynco 84d911ae9e Merge pull request 'Release KSwitcher v0.6.89' (#33)
Release KSwitcher v0.6.89
2026-11-16 00:31:10 +02:00
agrynco 8dedf30ece Release KSwitcher v0.6.89 2026-11-16 00:30:48 +02:00
agrynco f11bfff784 Merge pull request 'Release KSwitcher v0.6.88' (#32)
Release KSwitcher v0.6.88
2026-11-16 00:03:55 +02:00
agrynco 78aea46fb3 Activate the Files test window before sending shortcuts 2026-11-16 00:03:42 +02:00
agrynco 1549a89dc3 Release KSwitcher v0.6.88 2026-11-16 00:02:59 +02:00
agrynco 47382cdd86 Merge pull request 'Release KSwitcher v0.6.87 with clean LAN PowerShell output' (#31) from codex/release-v0.6.87 into main
Release KSwitcher v0.6.87
2026-11-15 23:50:24 +02:00
agrynco 49ae0ad157 Package KSwitcher v0.6.87 2026-11-15 23:49:54 +02:00
agrynco f8c05fd0ce Fix remote PowerShell text output for v0.6.87 2026-11-15 23:48:53 +02:00
agrynco d80fad6e48 Merge pull request 'Release KSwitcher v0.6.86 with LAN PowerShell' (#30) from codex/release-v0.6.86 into main
Release KSwitcher v0.6.86
2026-11-15 23:41:30 +02:00
agrynco a504adb22d Package KSwitcher v0.6.86 2026-11-15 23:40:56 +02:00
agrynco c3fa2af348 Add approved LAN PowerShell commands and fix editor startup for v0.6.86 2026-11-15 23:39:59 +02:00
agrynco cad264597d Merge pull request 'Release KSwitcher v0.6.85 with independent editor and LAN editing' (#29) from codex/release-v0.6.85 into main
Release KSwitcher v0.6.85
2026-11-15 23:10:40 +02:00
agrynco 97e5281c65 Package KSwitcher v0.6.85 2026-11-15 23:10:08 +02:00
agrynco 0af90b565d Add independent Files windows and LAN editing for v0.6.85 2026-11-15 23:09:00 +02:00
agrynco 6e3fc16c9f Merge pull request 'Release KSwitcher v0.6.84 with Files editor and folder creation' (#28) from codex/release-v0.6.84 into main
Release KSwitcher v0.6.84
2026-11-15 22:35:51 +02:00
agrynco 21ec9f30a6 Package KSwitcher v0.6.84 2026-11-15 22:35:01 +02:00
agrynco 4dd1a87a2a Add Files editor, viewer and Far dialogs for v0.6.84 2026-11-15 22:34:02 +02:00
agrynco 6cda28fcdd Merge pull request 'Release KSwitcher v0.6.83 with Files keyboard navigation' (#27) from codex/release-v0.6.83 into main 2026-11-15 10:33:10 +02:00
agrynco 0c4b6e390a Package KSwitcher v0.6.83 2026-11-15 10:32:46 +02:00
agrynco d0bd86513e Restore Files command focus after panel clicks 2026-11-15 10:32:35 +02:00
agrynco b5c803df87 Keep Files commands focused during pane navigation 2026-11-15 10:29:35 +02:00
agrynco 983e6f7b1a Merge pull request 'Release KSwitcher v0.6.82 with Files activation and refresh' (#26) from codex/files-activation-25 into main 2026-11-14 21:57:54 +02:00
agrynco 68edcc0bfb chore(release): package KSwitcher v0.6.82
Refs #25
2026-11-14 21:56:10 +02:00
agrynco b705c4e4a0 feat(files): open local files and refresh panels automatically
Refs #25
2026-11-14 21:53:00 +02:00
agrynco 31d582d29e Merge pull request 'Package KSwitcher 0.6.81' (#24) from codex/release-0681 into main 2026-11-14 16:34:11 +02:00
agrynco ad2fa2c92e chore(release): package kswitcher v0.6.81 2026-11-14 16:32:06 +02:00
agrynco 796d03f8a9 Merge pull request 'Show all folder shortcuts with Ctrl+8 and reuse the Files window' (#23) from codex/files-all-folder-shortcuts into main 2026-11-14 16:25:07 +02:00
agrynco be68b706f0 fix(files): use ctrl+8 and activate the open files window 2026-11-14 16:24:47 +02:00
agrynco 23a7c73331 feat(files): list all folder shortcuts with ctrl+f8
Reuse the folder shortcuts dialog and open the selected destination in the active panel.
2026-11-14 16:19:23 +02:00
agrynco 82fe8ce3a1 Merge pull request 'Show specific LAN peer lookup errors in Files' (#22) from codex/files-peer-errors into main 2026-11-14 16:14:28 +02:00
agrynco 00cd1262b4 chore(release): package kswitcher v0.6.80 2026-11-14 16:10:08 +02:00
agrynco 7bc734a9a8 fix(files): distinguish lan peer lookup failures 2026-11-14 16:07:54 +02:00
agrynco a19ccaf035 Merge pull request 'Synchronize Files with PowerShell and fix the LAN drive picker' (#21) from codex/files-polish-release into main 2026-11-14 16:01:54 +02:00
agrynco 60625103f8 chore(release): package kswitcher v0.6.79 2026-11-14 15:53:27 +02:00
agrynco aa02f2cacb fix(files): synchronize terminal navigation and polish lan picker 2026-11-14 15:52:14 +02:00
agrynco 6a1e39efa9 Merge pull request 'Synchronize KSwitcher Files settings across open windows' (#20) from codex/files-live-settings into main 2026-11-14 15:18:15 +02:00
agrynco 7242c3f775 test(settings): keep picker fixture within the monitor 2026-11-14 15:17:54 +02:00
agrynco a43f6441b4 chore(release): refresh verified windows package 2026-11-14 15:15:20 +02:00
agrynco 2f5ae373a1 fix(settings): preserve unset files shortcut in options 2026-11-14 15:14:16 +02:00
agrynco b978361180 chore(release): package kswitcher v0.6.78 with files 2026-11-14 15:11:26 +02:00
agrynco 93b8055085 fix(files): synchronize settings across windows
Reload shared preferences in open windows and apply only fields changed in Options.
2026-11-14 15:09:17 +02:00
agrynco 70ee338fc3 Merge pull request 'Add searchable command history to KSwitcher Files' (#19) from codex/files-command-history into main 2026-11-14 14:43:10 +02:00
agrynco 46b4b51cea feat(files): add searchable command history
Persist command recall and align keyboard, mouse, and dialog behavior.

Refs #18
2026-11-14 14:42:48 +02:00
agrynco 721c3f0f38 docs: record KSwitcher issue workflow 2026-11-14 12:56:47 +02:00
agrynco 47851d75ef Merge pull request 'Add folder shortcuts to KSwitcher Files' (#17) from codex/issue-15-folder-shortcuts into main
Fixes #15
2026-11-14 12:20:53 +02:00
agrynco ad01951956 Add folder shortcuts to KSwitcher Files
Share Far dialog fields, palette, frame layout, and configurable padding. Document shortcut behavior and the shared-code rule.

Refs #15
2026-11-14 12:19:13 +02:00
agrynco bf092d2764 Merge pull request 'Customize file type styles and preview Files options' (#16) from codex/files-type-styles into main
Fixes #14
2026-11-14 11:26:01 +02:00
agrynco d5137887b9 Customize file type styles and preview Files options
Refs #14
2026-11-14 11:25:31 +02:00
agrynco c75cc989f4 Merge pull request 'Add Files shortcuts and unified Options' (#13) from codex/issue-12-files-options into main
Fixes #12
2026-11-14 10:47:30 +02:00
agrynco f580892ca9 Add Files shortcuts and unified Options
Refs #12
2026-11-14 10:44:54 +02:00
agrynco 372c4cb8bd Merge pull request 'Browse and manage files on paired KSwitcher PCs' (#11) from codex/issue-10-lan-files into main
Fixes #10
2026-11-14 09:49:36 +02:00
agrynco 37158c1ff0 Browse and manage files on paired KSwitcher PCs
Refs #10
2026-11-14 09:47:06 +02:00
agrynco 02f81327fd Improve Change drive and list KSwitcher LAN peers
Refs #9
2026-11-14 03:07:55 +02:00
agrynco 2834510680 Merge KSwitcher Files operations and embedded PowerShell 2026-11-14 02:39:34 +02:00
agrynco 2c53e99efc Embed PowerShell terminal and polish file panels
Refs #8
2026-11-14 02:38:47 +02:00
agrynco fb98731ec2 Add F6 rename and move to KSwitcher Files
Refs #7
2026-11-14 01:25:45 +02:00
agrynco 531efec2eb Add file deletion and Recycle Bin support
Refs #6
2026-11-14 01:00:40 +02:00
agrynco 27c397e375 Highlight the cursor only in the active file panel 2026-11-14 00:21:45 +02:00
agrynco 186621eb3c Integrate panel paths into the file manager frames 2026-11-14 00:15:55 +02:00
agrynco d3a0b83692 Merge pull request 'Activate focused dialog buttons with Enter' (#5) from codex/kswitcher-files into main 2026-11-14 00:06:19 +02:00
71 changed files with 13923 additions and 812 deletions

No files matched your search

+32
View File
@@ -1,5 +1,11 @@
# Project instructions
## Reuse shared code
Reuse existing shared code wherever possible. When the same behavior,
component, or logic is needed in two or more places, extract it into a
shared component or function and use it at every applicable call site.
## Mandatory Wiki updates for every release
A release is not complete until the published project Wiki reflects that release.
@@ -34,8 +40,34 @@ Wiki: https://gitea.hrynco.com/hrynco/hrynco-kswitcher/wiki
Updating only the release notes or changing only the Wiki version number does
not satisfy this rule; the current functionality must also be reviewed.
## Gitea issue authentication
For Gitea issue API requests from this checkout, use the project token stored
with Windows DPAPI in `kswitcher-gitea-token.dpapi` under the directory returned
by `git rev-parse --absolute-git-dir`. Read it with PowerShell
`Get-Content -LiteralPath $tokenFile | ConvertTo-SecureString` and use it only
for the Gitea authorization header. Keep the token out of tracked files and
command output.
## KSwitcher Files UI
Before changing the file manager UI, read [FILES-UI.md](FILES-UI.md).
Reuse the shared dialog rendering and layout helpers. Update the document
when an agreed UI requirement changes.
Verify visible changes in a rendered window. For rendering regressions,
add an automated check that fails on the reported appearance and run it
before handing off a new build.
## Link changes to Gitea issues
For commits that implement a Gitea issue, include `Refs #<number>` in the
commit message body. In a pull request, use `Fixes #<number>` when merging
fully resolves the issue, or `Refs #<number>` when the work is partial.
Use the existing issue for follow-up commits on the same change.
Assign each new issue to the [KSwitcher project board](https://gitea.hrynco.com/hrynco/hrynco-kswitcher/projects/8)
using its default column, and verify that the issue appears on the board.
Use Markdown headings, lists, and bold emphasis to make issue descriptions
easy to scan. Embed relevant screenshots as Markdown images using their
Gitea attachment URLs, and verify that the images render in the description.
+47 -4
View File
@@ -1,14 +1,28 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.77 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.91 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp resources/files.rc)
add_executable(KSwitcherFiles WIN32 src/files_main.cpp src/files_text_window.cpp
src/files_text_document.cpp src/files_text_language.cpp resources/files.rc)
include(cmake/FilesTextLibraries.cmake)
set_target_properties(KSwitcherFiles PROPERTIES OUTPUT_NAME "KSwitcher.Files")
target_compile_definitions(KSwitcherFiles PRIVATE UNICODE _UNICODE NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32)
find_program(KSWITCHER_POWERSHELL NAMES pwsh powershell REQUIRED)
add_custom_target(KSwitcherTerminalAssets
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tools/stage-files-terminal.ps1"
-Destination "$<TARGET_FILE_DIR:KSwitcherFiles>"
VERBATIM)
add_dependencies(KSwitcherFiles KSwitcherTerminalAssets)
add_custom_target(check-files-search-arrow
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -File "${CMAKE_CURRENT_SOURCE_DIR}/tests/files_search_arrow.ps1"
-Executable "$<TARGET_FILE:KSwitcherFiles>"
DEPENDS KSwitcherFiles
VERBATIM)
target_include_directories(KSwitcherFiles PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/build/terminal-deps/sdk/build/native/include")
target_link_libraries(KSwitcherFiles PRIVATE user32 gdi32 comctl32 shell32 comdlg32 advapi32 ole32 uuid)
if(MSVC)
target_compile_options(KSwitcherFiles PRIVATE /utf-8)
else()
@@ -28,10 +42,12 @@ foreach(utilityTarget StayHere StayHereTest)
target_link_options(${utilityTarget} PRIVATE -municode -static -static-libgcc -static-libstdc++)
endif()
endforeach()
add_library(kscore src/lan_channel.cpp src/lan_input.cpp src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
add_library(kscore src/lan_files.cpp src/lan_channel.cpp src/lan_input.cpp src/lan.cpp src/lan_pairing.cpp src/core.cpp src/platform.cpp src/autostart.cpp src/autocorrect.cpp src/storage.cpp src/ocr.cpp src/richtext.cpp src/update.cpp src/sync.cpp)
target_link_libraries(KSwitcherFiles PRIVATE kscore)
target_include_directories(kscore PUBLIC src PRIVATE vendor)
target_compile_definitions(kscore PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _WIN32_WINNT=0x0A00)
target_link_libraries(kscore PUBLIC ws2_32 bcrypt iphlpapi winhttp user32 shell32 shlwapi advapi32 crypt32 imm32 comctl32 gdi32 ole32 oleaut32 taskschd comdlg32 uuid windowscodecs runtimeobject uiautomationcore)
set_property(SOURCE resources/app.rc APPEND PROPERTY OBJECT_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/resources/update.ps1")
add_executable(KSwitcher WIN32 src/main.cpp src/app.cpp src/screenshot_notifications.cpp src/screenshot_launcher.cpp src/screenshot_activation.cpp src/configuration.cpp src/ui.cpp resources/app.rc)
target_compile_definitions(KSwitcher PRIVATE KS_RESIDENT_APP)
target_link_libraries(KSwitcher PRIVATE kscore)
@@ -98,6 +114,21 @@ else()
target_link_options(KSwitcherOcrTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
enable_testing()
add_executable(KSwitcherFilesTextTests tests/files_text_document.cpp src/files_text_document.cpp)
target_include_directories(KSwitcherFilesTextTests PRIVATE src)
target_compile_definitions(KSwitcherFilesTextTests PRIVATE UNICODE _UNICODE NOMINMAX WIN32_LEAN_AND_MEAN)
target_link_libraries(KSwitcherFilesTextTests PRIVATE ole32)
if(NOT MSVC)
target_link_options(KSwitcherFilesTextTests PRIVATE -static)
endif()
add_test(NAME files-text-document COMMAND KSwitcherFilesTextTests)
add_executable(KSwitcherFilesModalTests tests/files_modal_dialog.cpp)
target_include_directories(KSwitcherFilesModalTests PRIVATE src)
target_link_libraries(KSwitcherFilesModalTests PRIVATE user32 comctl32)
if(NOT MSVC)
target_link_options(KSwitcherFilesModalTests PRIVATE -static)
endif()
add_test(NAME files-modal-dialog COMMAND KSwitcherFilesModalTests)
add_executable(KSwitcherLanTests tests/lan.cpp)
target_link_libraries(KSwitcherLanTests PRIVATE kscore)
if(MSVC)
@@ -106,6 +137,18 @@ else()
target_link_options(KSwitcherLanTests PRIVATE -static -static-libgcc -static-libstdc++)
endif()
add_test(NAME lan-pairing-regressions COMMAND KSwitcherLanTests)
add_executable(KSwitcherLanFilesTests tests/lan_files.cpp)
target_link_libraries(KSwitcherLanFilesTests PRIVATE kscore)
add_test(NAME lan-files-regressions COMMAND KSwitcherLanFilesTests)
add_executable(KSwitcherFilesActivationFixture tests/files_activation_fixture.cpp)
if(NOT MSVC)
target_link_options(KSwitcherFilesActivationFixture PRIVATE -municode -static)
endif()
add_test(NAME files-activation-and-refresh
COMMAND "${KSWITCHER_POWERSHELL}" -NoProfile -ExecutionPolicy Bypass
-File "${CMAKE_CURRENT_SOURCE_DIR}/tests/files_activation.ps1"
-Executable "$<TARGET_FILE:KSwitcherFiles>" -Fixture "$<TARGET_FILE:KSwitcherFilesActivationFixture>")
set_tests_properties(files-activation-and-refresh PROPERTIES RUN_SERIAL TRUE TIMEOUT 120)
add_executable(KSwitcherLanInputTests tests/lan_input.cpp)
target_link_libraries(KSwitcherLanInputTests PRIVATE kscore)
if(MSVC)
+277 -3
View File
@@ -2,15 +2,276 @@
Far Manager є зразком для оформлення файлового менеджера. Основне вікно використовує системний заголовок Windows. Панелі, меню й діалоги операцій використовують моноширинний шрифт менеджера та оформлення в стилі Far.
Власні модальні діалоги панелей, редактора й переглядача використовують
`FilesModalDialog`. Escape і клік поза діалогом у клієнтській області Files
виконують Cancel, а фокус повертається до елемента, з якого відкрили діалог.
Кліки на робочому столі та в інших застосунках залишають діалог відкритим.
Заголовок і рамка вікна-власника доступні, а діалог рухається разом із
власником. Діалог центрується відносно власника та звужується разом із ним
до власної мінімальної ширини. Поки діалог відкритий, ширина власника
обмежена цією мінімальною шириною з відступами.
Це діє і для вкладених діалогів. Під час файлової операції Cancel спершу
просить підтвердити переривання.
Спільне повідомлення Files підлаштовується під заголовок і кількість рядків.
Висота діалогу залежить від видимого вмісту. Приховані та порожні секції
не займають місця. Перед завершенням змін перевір коротке повідомлення й
повідомлення з кількома рядками, включно з відступами до рамки та кнопок.
Смуга прокрутки з’являється, лише коли текст не вміщується в доступну висоту.
Оформлення, кнопки й необов’язковий прапорець задаються параметрами шаблону.
Підтвердження гібернації та віддаленого оновлення мають червоне оформлення
Warning і початковий фокус на No.
## Скорочення
- Enter і подвійний клік відкривають папку в поточній панелі, а локальний
документ — у програмі, призначеній для його типу у Windows. EXE запускається
окремим процесом. BAT і CMD виконуються в поточній сесії вбудованого
термінала, а їхній вивід з’являється під панелями. Щоб відкрити файл із
панелі LAN, спочатку скопіюй його до локальної папки.
- У головному вікні клавіатурний фокус залишається в командному рядку.
Tab перемикає активну панель, клік вибирає рядок, а стрілки вгору й вниз,
Page Up і Page Down пересувають курсор у панелі навіть під час набору команди.
Меню й діалоги тимчасово приймають фокус. Ctrl+V і Shift+Insert вставляють
текст із буфера обміну в позицію курсора або замінюють виділення.
Enter запускає введену команду або відкриває поточний елемент, якщо рядок
порожній. Escape очищає рядок.
Промпт і текст команди вирівняні по спільній базовій лінії.
- У віддаленій панелі Enter запускає введену команду PowerShell на
спареному ПК в поточному каталозі цієї панелі. Вивід і код завершення
з’являються в терміналі Files як текст. Службовий прогрес PowerShell
приховано. Кожна команда запускається окремо,
тому змінні середовища й `cd` між командами не зберігаються. Escape
в порожньому командному рядку скасовує поточну віддалену команду.
- На ПК, який приймає команди, увімкніть **Options → Command line →
Command history → Allow paired PCs to run PowerShell commands here**.
Дозвіл за замовчуванням вимкнений і діє лише для спарених ПК. Команди
запускаються без підвищених прав. Для цієї функції оновіть обидва ПК.
- **F9 → Commands → Hibernate remote PC** доступна для активної LAN-панелі.
У підтвердженні показується ім’я цільового ПК. На цільовому ПК ввімкніть
**Options → Command line → Command history → Allow paired PCs to hibernate this PC**.
Дозвіл за замовчуванням вимкнений і діє лише для спарених ПК. Для цієї дії
потрібні оновлення на обох ПК і ввімкнена гібернація Windows.
- **F9 → Commands → Update remote KSwitcher** запускає штатне оновлення для
ПК активної LAN-панелі. Підтвердження з іменем ПК показується у Files
відправника. На цільовому ПК потрібен дозвіл **Options → Command line →
Command history → Allow paired PCs to update KSwitcher here**. Після
перевірки нового стабільного пакета оновлювач без запитань закриває
KSwitcher, Files, Config і редактори скриншотів цієї інсталяції.
Незбережені зміни у примусово закритих вікнах втрачаються.
Закриття процесів і результат записуються до `kswitcher.log`.
- Ctrl+F вставляє повний шлях елемента під курсором активної панелі в позицію
курсора командного рядка або замінює виділення. Подвійні лапки додаються
лише до шляхів із пробілами. Для `..` вставляється шлях батьківської папки.
- Ctrl+Enter вставляє лише назву файла або папки під курсором активної панелі
за тими ж правилами вставлення й лапок. Рядок `..` пропускається.
- Ctrl+C копіює весь введений у командний рядок текст, навіть коли виділено
лише його частину.
- Ctrl+Insert копіює імена позначених файлів і папок активної панелі в порядку
відображення, по одному на рядок. Якщо позначок немає, копіюється ім’я під
курсором. Шляхи й рядок `..` пропускаються.
- Ctrl+E підставляє останню команду. Повторне натискання
підставляє старішу, доки не досягне початку історії. Alt+F8 відкриває історію
з групуванням за датами. Enter або подвійний клік у списку підставляє вибрану
команду в поле. Для запуску натисни Enter у самому полі.
- Історія зберігає 1000 останніх непорожніх команд, надісланих із поля KSwitcher
Files, включно з повторами. Вона зберігається локально в профілі Windows між
запусками. Options → Command line → Command history змінює обидві комбінації,
перевіряє конфлікти та дозволяє відновити типові значення.
- Ширина історії підлаштовується під найдовшу команду в межах робочої області
монітора. Рядок, який не вміщується, закінчується трьома крапками. Вибір
підставляє до командного рядка повний текст.
- Пошук в історії фільтрує команди за всіма введеними словами, незалежно від
регістру. Збіги за текстом або датою йдуть перед тематичними. Тематичні
ключові слова визначаються локально й використовуються лише для пошуку,
зокрема в раніше збережених записах. Кожна команда відображається один
раз, за датою останнього запуску.
- Історія використовує оформлення діалогів Delete і Copy та спільне поле Far.
Підпис, поле й список вирівнюються за відступами Dialog frame. Рядки команд
і роздільники дат мають власний внутрішній відступ.
Вікно історії бірюзове, поле пошуку сіро-бірюзове. Поки історія відкрита,
дії в панелях, меню й командному рядку заблоковано. Заголовок і рамка
головного вікна доступні для переміщення, зміни розміру та керування вікном.
Escape або клік у Files поза діалогом закриває історію. Заголовок і рамка головного
вікна залишають історію відкритою під час керування вікном.
Історія центрується відносно головного вікна й рухається разом із ним.
Tab циклічно переводить фокус між пошуком і списком.
Стрілка вгору або вниз із пошуку одразу пересуває вибір у списку.
Висота списку залежить від кількості результатів. Коли місця бракує,
прокручується список.
- Alt із символом шукає початок назви файлу чи папки. Після першого збігу
внизу активної панелі відкривається компактне поле Search у подвійній рамці.
Наступні символи можна набирати без Alt. Пошук враховує поточну розкладку
та ігнорує регістр. Backspace прибирає останній символ, Escape закриває пошук,
а Enter відкриває поточний елемент.
- F9 перемикає головне меню Files. Після закриття фокус повертається в командний рядок. Коли меню відкрите, Alt з літерою верхнього рівня відкриває відповідний пункт.
- Коли головне меню відкрите, L, F, C, O та R відкривають Left, Files, Commands, Options та Right. У підменю підсвічена літера викликає відповідну команду. Літери унікальні в межах поточного рівня. Усталені мнемоніки мають пріоритет, зокрема X для Exit.
- У Commands лінії відокремлюють навігацію панелі, перегляд і редагування
та керування віддаленим ПК. Остання група з’являється разом зі своїм
роздільником лише в активній LAN-панелі. Роздільники пропускаються під
час навігації й не реагують на клік.
- Глобальне скорочення **Open KSwitcher Files** можна змінити або очистити в налаштуваннях. Для профілю без цього поля пропонується Ctrl+Alt+F, якщо Windows реєструє комбінацію і вона вільна серед прив’язок KSwitcher.
- Глобальне скорочення повертає до відкритого вікна Files у поточній сесії Windows або запускає Files, якщо такого вікна немає. Пункт **Files** у треї відкриває нове вікно.
- Ctrl+Shift+0–9 відкриває діалог Title і Path із поточною папкою. OK додає запис без дублювання, Escape скасовує. Title необов’язковий, порожнє значення показує шлях. Записи LAN зберігають ID ПК, а редагування Path залишає запис на тому самому ПК.
- Ctrl+0–9 одразу відкриває єдину папку. Для кількох папок відкривається компактний попап Far з одним заголовком Folder shortcuts у подвійній рамці та коротким рядом клавіш у нижній рамці. Ширина залежить від найдовшого запису й обмежується розміром вікна, а висота — кількістю видимих рядків. Нижня рамка показує Ins Del F4 Ctrl+Up Ctrl+Down. Insert додає поточну папку через Title і Path, Delete відв’язує, F4 редагує, Ctrl+Up і Ctrl+Down змінюють і зберігають порядок. Enter відкриває папку. Escape або клік у Files поза попапом закриває його й повертає фокус до панелі. Commands → Folder shortcuts дає вибрати зайняту цифру й відкрити той самий попап навіть для єдиного запису. Brief і Wide доступні в меню Left/Right та Options.
## Налаштування Files
### Переглядач і редактор
- F3 відкриває поточний локальний файл у вбудованому переглядачі лише
для читання. F4 відкриває спільне вікно в режимі редагування. Alt+F3
відкриває файл у програмі, призначеній Windows для цього типу, а Alt+F4
викликає зареєстровану дію Edit. Ці команди також є в меню Commands.
F10 або × закривають головне вікно Files. Вбудовані Viewer та Editor
відкриваються в окремих немодальних вікнах із власними кнопками на панелі
завдань Windows. При повторному відкритті Files пропонує перейти до вже
відкритого вікна. Закриття менеджера залишає вікна файлів відкритими.
- F3 і F4 працюють і на панелі LAN. Файл завантажується у тимчасову копію
на диску у фоновому режимі. Збереження передає зміни назад на віддалений ПК.
Якщо розмір або час зміни віддаленого файла відрізняється від відкритої
копії, Files відхиляє запис і залишає зміни в редакторі. Для F4 оновіть
KSwitcher також на віддаленому ПК. Alt+F3 і Alt+F4 потребують локальної копії.
- Вікно використовує той самий шрифт і розмір, що й файлові панелі,
синю палітру Far за замовчуванням,
рядок стану згори й підказки клавіш унизу. Інформація про файл
вирівняна праворуч. Режим (Edit або View) стоїть у дужках після імені,
далі йдуть кодування, синтаксис, позиція курсора й сторінка великого файла.
Номери рядків показані приглушеним кольором з невеликим відступом від тексту.
Меню редактора й переглядача використовують моноширинний шрифт і кольори
обраної схеми. У FAR (blue) написи верхнього меню чорні, поточний пункт
має чорне тло, а літери доступу жовті. F9 відкриває меню, Alt з літерою
доступу відкриває потрібний розділ. Редактор позначає незбережені зміни зірочкою.
Esc і F10 закривають незмінений файл одразу. Якщо вміст змінився,
діалог Far пропонує Save, Discard або Cancel.
- Syntax → Automatic визначає синтаксис за іменем, розширенням або
shebang. Відомі типи включають C#, C/C++, JavaScript/TypeScript,
HTML, XML, CSS, SQL, Python, PowerShell, Bash, JSON, YAML, TOML і Markdown.
У меню Syntax можна вибрати будь-який вбудований лексер Lexilla або Plain text.
Стан багаторядкової конструкції починається заново на межі сторінки.
- Ctrl+PgUp/PgDn перемикають сторінки, F5 переходить до заданої сторінки,
Ctrl+Shift+Home/End відкривають першу й останню. Ctrl+F шукає в поточній
сторінці, F3 повторює пошук. Ctrl+A виділяє поточну сторінку.
Історія Undo/Redo діє в межах відкритої сторінки.
- Початкова сторінка займає близько 1 МіБ файла. Для вставок і редагування
сторінка може вирости до 8 МіБ у UTF-8. Зміни інших сторінок тимчасово
зберігаються на диску. F2 або Ctrl+S потоково записують нову копію в
папку файла, тож збереження потребує вільного місця для цієї копії.
UTF-8 із BOM або без нього та UTF-16 LE/BE із BOM зберігають своє
кодування. Нередаговані байти копіюються без перетворення.
- Команда Options у панелях, редакторі й переглядачі відкриває один діалог
KSwitcher Files Options. Розділи Viewer та Editor зберігають окремі профілі.
З редактора або переглядача діалог відкриває відповідний розділ. Apply
застосовує зміни до відкритих вікон, Cancel відкидає незастосовану чернетку.
Доступні перенесення рядків, номери рядків, показ пробілів і
кінців рядків, підсвічування поточного рядка, ширина табуляції від 1 до 16,
схеми FAR (blue), Dark і Light та власні кольори. Save scheme у розділі
Viewer або Editor додає поточну палітру до спільного списку схем. Apply
зберігає її та робить доступною в обох розділах. У редакторі також можна
ввімкнути вставлення пробілів замість табуляцій і автоматичні відступи,
а також вибрати символи кінця для нових рядків.
- Діалоги пошуку й переходу до сторінки використовують подвійні рамки та
кнопки Files. Search має компактний розмір, сіре тло, бірюзове поле та окремі кнопки
Find previous і Find next. Word і Selection підставляють у поле слово
під курсором або однорядкове виділення. Поле має висоту одного рядка
й вертикально центрований текст. Стрілка біля поля або Alt+Down
відкриває історію з групуванням за датами. Вибір запису підставляє
запит у поле. Editor і Viewer спільно зберігають до 100 останніх
різних запитів між запусками. Спільний шрифт панелей, меню й тексту змінюється в
Options → Appearance → Font. Редактор і переглядач отримують новий
шрифт після Apply.
### Параметри файлових панелей
- Options у головному меню спочатку прибирає смугу меню й одразу відкриває стандартний діалог Windows. Ліворуч є пошук за назвою категорії чи параметра й дерево результатів. Вибір дочірнього вузла відкриває лише його редактор праворуч. Пошук за назвою окремої властивості відкриває вузол, що її містить. Перемикання вузлів зберігає незастосовані зміни.
- File panel modes має вузли Panel layout, Wide panel columns і Status line columns. У Colors кожен колір має власний вузол. В Appearance шрифт редагується у вузлі Font. Діалог змінює розмір від розміру, який вміщує всі параметри. Ширина лівої навігації стала, дерево росте по висоті, а права область використовує додатковий простір. OK, Cancel та Apply лишаються внизу праворуч.
- Reset selected to defaults скидає вибраний рівень дерева. Корінь All settings скидає всі параметри, навіть коли пошук приховує частину дерева. У редакторах із кількома параметрами Reset property скидає лише вибрану властивість. Скидання змінює чернетку до Apply або OK. Стандартний Panel background — `#000080`.
- Apply доступна, коли чернетка відрізняється від застосованих налаштувань. Після Apply або повернення всіх значень до застосованих вона вимикається. Apply одразу зберігає зміни й перемальовує панелі за відкритим діалогом. OK робить те саме й закриває діалог. Cancel і Escape відкидають зміни після останнього Apply та повертають фокус до командного рядка.
- Colors → File type styles налаштовує текст, фон, підкреслення й закреслення для правил за розширенням і групи System and hidden. Розширення можна вибрати зі списку Windows або додати вручну з крапкою чи без неї. Вони зберігаються в KSwitcher, незалежно від асоціацій Windows. Курсор, позначки й результат передачі зберігають свої сигнальні кольори. Кольори правила можна перевизначити окремо для звичайного рядка, позначки Insert, курсора у фокусі та курсора без фокуса. Позначка має пріоритет над курсором, а підсвічування передачі має пріоритет над кольорами курсора. Без перевизначення стан зберігає звичайний вигляд панелі. Мініпанель унизу редактора одразу показує всі чотири стани на синтетичних рядках до Apply.
- Нижня область показує живе прев’ю для кольорів, шрифту, режимів панелей, широких колонок і рядка стану. Для Popup background, Menu selection і Command bar зразок показує відповідний елемент. Прев’ю читає чернетку, зокрема після Reset, і не потребує реальних файлів. Воно заповнює простір від останнього видимого елемента редактора до нижніх кнопок зі сталими відступами. Зі зміною розміру росте область зразка, а рядки зберігають масштаб шрифту.
- Підписи File type, Windows extension or custom extension і Row state стоять над відповідними полями зі спільним лівим краєм. Ширина списків враховує найдовші значення, шрифт, DPI й доступне місце. Add і Remove стоять одразу після своїх полів.
- Початковий набір правил охоплює EXE, Markdown, системні й приховані елементи, архіви, зображення, аудіо й відео, офісні документи й PDF, код і скрипти. Збережений набір правил завантажується як є, включно з видаленими правилами. Reset у File type styles відновлює весь початковий набір у чернетці.
## Панелі
- Обидві панелі автоматично підхоплюють створення, зміну й видалення файлів
у відкритих локальних папках. Фокус залишається там, де був, а курсор
лишається на поточному елементі, якщо той ще існує.
- Ctrl+8 відкриває список усіх ярликів папок. Кожен рядок показує групу
й назву або шлях. Enter відкриває папку в активній панелі, F4 редагує
ярлик, а Delete прибирає його зі списку.
- У Change drive повідомлення No paired computers з’являється після
успішної відповіді з порожнім списком. Збої локального запиту до
KSwitcher відображаються окремо.
- Якщо команда у вбудованому PowerShell змінює поточну папку,
панель, з якої надіслано команду, переходить до цієї папки.
- Коли список LAN-дисків змінює розмір меню Change drive, рамка й список
змінюють позицію разом. Рамка повністю перемальовується після зміни
розміру, а список має однаковий внутрішній відступ зліва і справа.
- Тайм-аут запиту дисків відображається як Connection timed out. Якщо
ввімкнено Write log, Files записує помилки запитів пар і дисків в окремий
журнал `data/kswitcher-files.log`. Системні помилки містять код Windows.
- Застосовані налаштування Files одразу підхоплюють інші відкриті вікна.
Apply зберігає поля, змінені в поточній чернетці. Якщо два вікна одночасно
редагували набір стилів файлів, відкрий Options заново, щоб побачити свіжі правила.
- Типовий колір тексту для ZIP-архівів — світлий коралово-рожевий.
- Меню Change drive показує літеру, тип, загальний обсяг і вільне місце. Відкриття меню запускає нове LAN-виявлення. Секція KSwitcher LAN показує збережені пари, зокрема офлайн. Після кожного відкриття меню асинхронно запитує диски й показує їх під назвою відповідного ПК. Поки триває запит, видно стан завантаження. Якщо запит не вдався, видно помилку. Повторне відкриття запускає новий запит. Кожна панель має власний диск і шлях, а в заголовку віддаленої панелі перед шляхом показано назву ПК.
- Список дисків і файлові операції використовують права облікового запису Windows, під яким працює віддалений KSwitcher. Достатньо спарювання й доступного TCP 45885, спільні папки налаштовувати не потрібно.
- F5, F6, F8 і Shift+Delete у віддалених панелях використовують спільні підтвердження, попередження й прогрес. F5/F6 працюють із файлами та деревами папок між локальними й віддаленими панелями, зокрема між двома різними ПК. Між шляхами одного віддаленого ПК вміст копіюється на ньому. F8 використовує кошик того ПК, на якому лежить файл.
- Після розриву з’єднання повтори F5 або F6 до того самого призначення. Віддалене копіювання відновлюється з підтвердженої позиції, якщо збережені байти відповідають початку вихідного файла. Підтверджене скасування видаляє частковий запис поточного файла. Завершені файли лишаються на місці. Наявний файл призначення замінюється після повного запису, а при переміщенні джерело видаляється після підтвердження запису призначення.
- F7, пункт Files → Make folder і кнопка F7 Mkdir відкривають модальний діалог Far. Стрілка біля імені та Alt+Down відкривають історію успішних введень. Link type має варіанти none, directory junction і symbolic link. Поле Target активне для посилань і приймає абсолютний шлях або шлях від поточної папки. У віддаленій панелі посилання створюється на віддаленому ПК, а symbolic link залежить від дозволів Windows на цьому ПК.
- Process multiple names дозволяє розділити імена комами або крапками з комою. Якщо такий символ є частиною імені, візьми ім’я в подвійні лапки. Діалог створює елементи у поточному каталозі, оновлює панель і ставить курсор на останній створений елемент. При помилці в середині списку вже створені елементи зберігаються.
- Після F5 або F6 успішно оброблені елементи коротко підсвічуються в панелі, що показує папку призначення. Підсвітка тексту відрізняється від курсора й не переносить фокус.
- У широкому режимі ширина під вирівняні розширення залежить від найдовшого розширення в поточній панелі. За вузької панелі залишай місце для імені та колонки Size.
- Бірюзовий курсор поточного рядка видно в активній панелі. Кожна панель зберігає свою позицію курсора. Коли панелі видимі, F5 і F6 з командного рядка діють на активну панель.
- Клік у панелі нижче списку ставить курсор на останній рядок, а клік вище списку — на перший.
- Поточний шлях відображається по центру верхньої подвійної лінії панелі. Лінію видно з обох боків напису. Бірюзове тло активної панелі охоплює лише напис шляху з невеликими відступами. Довгий шлях скорочується в межах панелі.
## Спільні елементи діалогів
- Власні діалоги Files блокують дії панелей і командного рядка, залишаючи
системний заголовок і рамку головного вікна доступними. Клік у Files поза діалогом
діє як Cancel. Для прогресу копіювання, переміщення й видалення це відкриває
підтвердження скасування.
- Enter активує кнопку у фокусі. Для кнопок із власним малюванням використовуй спільний обробник `dialogButtonEnter`. У підтвердженні скасування початковий фокус стоїть на No.
- При перенесенні вікна між моніторами з різним DPI оновлюй шрифт, висоту рядків панелей і компонування відкритого прогресу копіювання. Перевір перенесення під час активного копіювання.
- Для власних діалогів використовуй подвійну рамку з одним заголовком у верхній лінії. Звичайний діалог має сіре тло, попередження — темно-червоне тло та білий текст.
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` додає квадратні дужки, центрує текст і підсвічує фокус.
- Для власних діалогів і списків Far використовуй спільні метрики та малювання подвійної рамки. Еталон Delete задає 8 px від краю тла до першої лінії та 14 px від внутрішньої лінії до бічної межі вмісту при 96 DPI. Обидва відступи масштабуються за DPI. Вертикальний відступ враховує висоту заголовка та нижніх підказок. Діалоги й списки зберігають свою палітру та поведінку.
- Appearance → Dialog frame налаштовує зовнішній і внутрішній відступи від 0 до 24 px при 96 DPI. Row text padding додає від 0 до 4 ширин символу з кожного боку тексту в рядках Folder shortcuts. Inner padding задає відстань від рамки до краю списку. Ширина попапа враховує обидва текстові відступи, а довгі шляхи скорочуються в межах доступної ширини. Живий зразок показує чернетку. Apply зберігає значення, Cancel відкидає незастосовані зміни, Reset selected to defaults повертає 8 px, 14 px і 0 символів. Профілі без цих параметрів використовують ті самі значення.
- У ресурсі чи виклику задавай назву кнопки й клавішу доступу. Спільна функція `paintDialogButton` центрує текст, підсвічує фокус і додає фігурні дужки для дії за замовчуванням та квадратні для решти кнопок.
- Модальні діалоги редактора й переглядача використовують той самий шрифт, що й діалоги операцій Files. Розміри полів і кнопок враховують метрики цього шрифту.
- Нижні секції з кнопками у Copy, Warning і вікні прогресу мають однакову висоту, прив’язану до висоти рядка шрифту. Використовуй `layoutDialogButtons` для такої секції та `centerDialogRow` для інших рядків між роздільниками. Межі секції визначають положення рядка.
- Поля вводу й випадаючі списки використовують бірюзове тло палітри менеджера. Текст і стрілку вирівнюй вертикально по центру. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
- Search і спільні нижні секції Far використовують компактні проміжки між рядками й кнопками, прив’язані до метрик моноширинного шрифту. У Search висота кнопки дорівнює висоті одного рядка.
- Нижні кнопки Far групуються по центру. Їхня ширина залежить від напису й дужок, а проміжок між ними становить приблизно половину символу.
- Прапорці Far у Search, Copy і попередженнях використовують спільний компонент. У фокусі короткий системний курсор блимає між дужками, а тло рядка зберігає колір діалогу.
- У Copy і Rename/Move помилки шляху показує окремий червоний модальний діалог Far. Після його закриття фокус повертається до поля шляху.
- Короткі червоні повідомлення використовують звичайний текст без смуги прокрутки. Висота діалогу залежить від кількості рядків повідомлення.
- Для однорядкових полів Far використовуй спільний компонент `FAR_EDIT` і спільну підготовку та палітру діалогів. Компонент використовує нативний Win32 Edit без рамки й центрує текст вертикально. Його використовують Search, Copy/Rename, Title/Path і пошук в історії. Кольори Text і Popup background діють на всі ці поля, зокрема на поле Copy лише для читання. У відповідних розділах Options → Colors зміни видно на зразку поля. Existing files у Copy є списком без редагування. Поля та редагований список Windows Options мають системне оформлення.
- Для позиціонування роздільника використовуй невидимий маркер, якщо межу не задає інший елемент секції. Спільний обробник малює лінію.
- Випадаючі списки й поля Far мають висоту одного рядка шрифту. Бірюзове поле прилягає до сірої ділянки завширшки один символ із темною, вертикально центрованою стрілкою. Make folder, Search і Copy використовують спільне малювання стрілки. Прапорці в діалогах операцій використовують позначки `[ ]` та `[x]` і видимий клавіатурний фокус.
- Розкриті списки й історія Far використовують спільний попап із бірюзовим тлом, світлою рамкою та чорним вибраним рядком. Попап прилягає до обох країв бірюзового поля, стрілка лишається праворуч за межами списку. Історія групується за датами. Довгі списки прокручуються, Enter вибирає рядок, Escape закриває список і повертає фокус до поля.
- Повторно використовуй спільне оформлення. Текст, дії та стан операції передавай із коду конкретної операції. Наприклад, Warning повертає відповідь і стан прапорця, а копіювання вирішує, чи застосувати відповідь до наступних конфліктів.
## Діалог Copy
@@ -22,16 +283,29 @@ Far Manager є зразком для оформлення файлового м
## Вікно прогресу копіювання
- F6 використовує спільне оформлення діалогів Copy і прогресу. Заголовок підтвердження — Rename/Move, кнопка за замовчуванням — Rename, заголовок прогресу — Move.
- Поле призначення F6 початково містить шлях іншої панелі. Для одного елемента просте ім’я задає перейменування в поточній папці. Для кількох елементів діалог показує їх кількість і приймає один шлях до папки.
- Секції поточного файла й загального прогресу мають однаковий вертикальний відступ від вмісту до верхньої та нижньої ліній. Cancel стоїть по центру спільної нижньої секції.
- Обидві шкали займають усю ширину вмісту. Відсотки показуються по центру шкали, а її висота враховує поточний шрифт.
## Попередження про конфлікт
- Для однойменних файлів Warning показує точний розмір у байтах із розділенням тисяч і місцевий
час останньої зміни окремо для вхідного файла та файла в папці призначення.
- Після розрахунку остаточної висоти центр Warning збігається з центром вікна прогресу.
- Remember choice стоїть по вертикальному центру власної секції. Кнопки Overwrite, Skip і Cancel стоять по центру нижньої секції.
- Tab проходить Remember choice, Overwrite, Skip і Cancel. Стрілки вліво та вправо циклічно перемикають три кнопки в порядку їх розташування.
- Стрілки вгору й вниз переходять між сусідніми секціями з інтерактивними елементами. У Warning стрілка вгору з ряду кнопок переводить фокус на Remember choice, а вниз із прапорця — на Overwrite.
## Видалення
- F8 показує сіре підтвердження з дією Move. Shift+Delete і підтвердження непорожньої папки мають темно-червоне тло. Вікно прогресу видалення має сіре тло, як у Copy.
- Escape у початковому підтвердженні повертає фокус до командного рядка. Поточний рядок активної панелі зберігається, а стрілки одразу працюють.
- F8 переміщує підтверджені елементи в кошик Windows. Shift+Delete видаляє підтверджене дерево від вкладених елементів до батьківської папки. Прогрес і підсумок окремо показують успішні, пропущені й невдалі дії.
- Cancel і Esc у вікні прогресу відкривають спільне червоне підтвердження скасування. Yes зупиняє операцію, а No або Esc у підтвердженні повертає до прогресу. Початковий фокус стоїть на No.
## Перевірка змін
Відкрий Copy і Warning в одній збірці та порівняй висоту нижніх секцій, центрування, повноту підписів і видимість фокуса. Перевір Tab, стрілки й Esc. Після узгодження нового правила онови відповідний розділ цього документа.
+6
View File
@@ -925,6 +925,12 @@ Run `./package.ps1 -PublishRelease` after building and testing to refresh the tr
## Verification
The Files build uses CMake 3.24 or later and Ninja, both included in the local
w64devkit toolchain. CMake downloads pinned Scintilla and Lexilla source
archives for the built-in viewer and editor on the first configure. The
[Scintilla and Lexilla license](resources/files-text-license.txt) is also
embedded in KSwitcher.Files.exe.
```powershell
.\build.ps1 -Test # Pure/native regression checks; does not touch the user's clipboard
.\build.ps1 -Ocr # Render a synthetic image, decode it with WIC and recognize it with Windows OCR
Binary file not shown.
+97
View File
@@ -1,5 +1,102 @@
# Latest Windows release
## v0.6.91 — 2026-11-16
Ctrl+Insert copies the marked file and folder names from the active pane, one per line. Without marks, it copies the name under the cursor. LAN Files retries opening TCP 45885 if the port is busy at startup, and Change drive retries failed drive queries. A remote update request waits for an in-progress update check. The updater identifies and closes companion processes from its installation, including Files running from an update backup, and records confirmed exits in the journal.
**Українською.** Ctrl+Insert копіює імена позначених файлів і папок активної панелі, по одному на рядок. Якщо позначок немає, копіюється ім’я під курсором. LAN Files повторює відкриття TCP 45885, якщо порт зайнятий під час старту, а Change drive повторює невдалі запити дисків. Запит віддаленого оновлення чекає завершення поточної перевірки. Оновлювач визначає й закриває процеси своєї інсталяції, зокрема Files із резервної папки, і записує підтверджене завершення в журнал.
## v0.6.90 — 2026-11-16
Files groups Commands into navigation, viewing and editing, and remote PC actions. The remote group appears in an active LAN pane. Shared notices fit their contents and show a scrollbar only when needed. Remote hibernation and update confirmations use a compact red warning with No focused by default.
**Українською.** Меню Commands у Files групує навігацію, перегляд і редагування та дії для віддаленого ПК. Віддалена група доступна в активній LAN-панелі. Спільні повідомлення підлаштовуються під вміст і показують прокрутку лише за потреби. Гібернація та віддалене оновлення використовують компактне червоне підтвердження з фокусом на No.
## v0.6.89 — 2026-11-16
In Files, Commands now offers **Hibernate remote PC** and **Update remote KSwitcher** for the active LAN pane. The target PC has separate, default-off permissions for each action. Unattended updates verify the stable package, close the target installation's Files, Config and screenshot windows, and restart KSwitcher. Forced closure discards unsaved changes. The target journal records closed processes and the update result.
**Українською.** У меню Commands з’явилися **Hibernate remote PC** і **Update remote KSwitcher** для активної LAN-панелі. На цільовому ПК кожна дія має окремий дозвіл, вимкнений за замовчуванням. Оновлювач перевіряє стабільний пакет, без запитань закриває Files, Config і редактори скриншотів цієї інсталяції та перезапускає KSwitcher. Примусове закриття відкидає незбережені зміни. Журнал цільового ПК фіксує закриті процеси й результат оновлення.
## v0.6.88 — 2026-11-16
In Files, Ctrl+Enter inserts the name of the file or folder under the active pane cursor into the command line. An open Files window can still request the LAN peer list after KSwitcher updates and restarts.
**Українською.** У Files Ctrl+Enter вставляє в командний рядок назву файла або папки під курсором активної панелі. Відкрите вікно Files і далі може отримувати список LAN-комп’ютерів після оновлення та автоматичного перезапуску KSwitcher.
## v0.6.87 — 2026-11-15
Remote PowerShell in Files now displays errors as text and hides progress metadata. Update the PC that runs the commands to remove raw CLIXML from terminal output. This release supersedes v0.6.86.
**Українською.** Віддалений PowerShell у Files тепер показує помилки як текст і приховує службові повідомлення прогресу. Щоб прибрати сирий CLIXML із термінала, оновіть ПК, який виконує команди. Цей реліз замінює v0.6.86.
## v0.6.86 — 2026-11-15
The Files editor applies its palette before the text area appears. In a LAN pane, enter a PowerShell command to run it in that pane’s directory on the paired PC. The Files terminal displays output and the exit code. On the remote PC, enable **Allow paired PCs to run PowerShell commands here** in **Files → Options → Command line → Command history**. Both PCs need v0.6.86 or later.
**Українською.** Редактор Files застосовує палітру до появи текстового поля. У панелі LAN введена команда PowerShell виконується в каталозі цієї панелі на спареному ПК. Термінал Files показує вивід і код завершення. На віддаленому ПК увімкніть **Allow paired PCs to run PowerShell commands here** у **Files → Options → Command line → Command history**. На обох ПК потрібен v0.6.86 або новіший.
## v0.6.85 — 2026-11-15
Files viewer and editor windows now run independently with separate Windows taskbar buttons. Reopening a file offers to switch to its existing window. F3 and F4 work in LAN panes. The editor downloads files in the background and checks the remote file before replacing it on save. Update KSwitcher on the remote PC for writeback.
**Українською.** Переглядач і редактор Files працюють в окремих вікнах із власними кнопками на панелі завдань Windows. При повторному відкритті Files пропонує перейти до наявного вікна. F3 і F4 працюють у панелях LAN. Редактор завантажує файл у фоні та перевіряє віддалений файл перед записом. Для збереження оновіть KSwitcher також на віддаленому ПК.
## v0.6.84 — 2026-11-15
KSwitcher Files adds an internal viewer on F3 and editor on F4 with syntax highlighting, paged access to large text files, and shared search history. Alt+F3 and Alt+F4 open external applications. Files Options now includes viewer and editor settings, including tab width and the FAR (blue) color scheme. F7 creates folders and directory links, supports multiple names, and remembers successful inputs. Far-style dialogs share focus, resizing and keyboard behavior.
**Українською.** У KSwitcher Files з’явилися вбудований переглядач по F3 і редактор по F4 з підсвічуванням синтаксису, посторінковою роботою з великими текстовими файлами та спільною історією пошуку. Alt+F3 і Alt+F4 відкривають зовнішні програми. Спільні Options містять параметри переглядача й редактора, зокрема ширину табуляції та схему FAR (blue). F7 створює папки й посилання на каталоги, підтримує кілька імен і запам’ятовує успішні введення. Модальні діалоги Far мають спільну поведінку фокуса, зміни розміру й клавіатурної навігації.
## v0.6.83 — 2026-11-15
In KSwitcher Files, the command line keeps keyboard focus while Tab switches panes and Up, Down, Page Up, and Page Down move the active pane's cursor. Ctrl+F inserts the full path under the cursor, using double quotes only for paths with spaces. Ctrl+C copies the entire command line. Alt plus a character starts a filename or folder search. After the first match, type without Alt in the Search popup at the bottom of the active pane. F9 opens the menu.
**Українською.** У KSwitcher Files клавіатурний фокус залишається в командному рядку. Tab перемикає панелі, а стрілки вгору й вниз, Page Up і Page Down пересувають курсор активної панелі. Ctrl+F вставляє повний шлях під курсором і додає подвійні лапки лише до шляхів із пробілами. Ctrl+C копіює весь командний рядок. Alt із символом починає пошук файлу чи папки за назвою. Після першого збігу уточнюйте запит без Alt у полі Search внизу активної панелі. F9 відкриває меню.
## v0.6.82 — 2026-11-14
Enter and double-click open folders in the current pane, open local documents with their Windows default apps, and launch EXE files in separate processes. BAT and CMD files run in the existing embedded PowerShell session, and their output appears below the panes. Both local panes refresh when files are created, modified, or deleted externally and preserve focus.
**Українською.** Enter і подвійний клік відкривають папки в поточній панелі та локальні документи у стандартних програмах Windows. EXE запускаються окремими процесами, а BAT і CMD виконуються в поточній сесії вбудованого PowerShell із виводом під панелями. Обидві локальні панелі підхоплюють створення, зміну й видалення файлів ззовні та зберігають фокус.
## v0.6.81 — 2026-11-14
Ctrl+8 lists all folder shortcuts. Press Enter to open a folder, F4 to edit the shortcut, or Delete to remove it. Ctrl+Alt+F brings an open Files window forward.
**Українською.** Ctrl+8 показує всі ярлики папок. Enter відкриває папку, F4 редагує ярлик, Delete видаляє його. Ctrl+Alt+F повертає до відкритого вікна Files.
## v0.6.80 — 2026-11-14
Includes the Files updates from v0.6.79. Change drive now explains why the local peer lookup failed and shows No paired computers only when the response is empty.
**Українською.** Випуск включає зміни Files із v0.6.79. Change drive показує конкретну причину збою запиту пар. No paired computers з’являється лише після успішної порожньої відповіді.
## v0.6.79 — 2026-11-14
In KSwitcher Files, changing directory in PowerShell updates the panel
that sent the command. The global Files shortcut brings an open window to
the foreground. Change drive redraws its frame when LAN drives arrive and
shows a short timeout message. Files writes LAN lookup errors to its own log.
**Українською.** Зміна каталогу в PowerShell оновлює панель, з якої
надіслано команду. Глобальна клавіша Files повертає до відкритого вікна.
Change drive перемальовує рамку після появи LAN-дисків і показує коротке
повідомлення про тайм-аут. Files записує помилки LAN у власний журнал.
## v0.6.78 — 2026-11-14
The Windows package includes KSwitcher Files, a two-panel file manager with
local and paired LAN drives, folder shortcuts, an embedded PowerShell terminal,
and searchable command history. Applied Files settings update other open
instances immediately.
**Українською.** До пакета для Windows входить KSwitcher Files, двопанельний
файловий менеджер із локальними дисками й дисками спарених ПК, ярликами папок,
вбудованим PowerShell та пошуком в історії команд. Застосовані налаштування
Files одразу підхоплюють інші відкриті вікна.
## v0.6.77 — 2026-11-13
The screenshot editor remembers the local pencil smoothing default across
+1 -1
View File
@@ -1 +1 @@
51d95639527590b36453a5ef485532a7d2135950222fa197eb431a5309f1a45c KSwitcher-win-x64.zip
7db5db4c5d1f5c199d1cee7887336bdb2e34d4036b5af8cc20709957d6729d32 KSwitcher-win-x64.zip
+77 -27
View File
@@ -1,86 +1,136 @@
{
"version": "0.6.77",
"version": "0.6.91",
"schema": 1,
"files": [
{
"size": 2467840,
"path": "KSwitcher.exe",
"size": 2249216,
"sha256": "f1c79d337d746efcda03946cc833b7b5588ef4e739f1dff7d3afa15d0465469f"
"sha256": "eb4f1f7be3027a8ba073443932b0ea930a5d77f274f9603854f1331bd0954765"
},
{
"size": 2446848,
"path": "KSwitcher.Config.exe",
"size": 2251776,
"sha256": "016edc389007e0d15ee365fff7115fc91a820b46080cb39a3fb362f3b463bae7"
"sha256": "00a4817543f98643f679c4a92d197730bf9a62a114b918ab8540c179958b7004"
},
{
"size": 3053568,
"path": "KSwitcher.Screenshot.exe",
"size": 3247616,
"sha256": "c8ef070ad01acc410802c62e0df6a96cb299fe036fe8e99f0cf6a398b7324ec6"
"sha256": "68b430e7c3c7f84c21ec843849a5f763ee3563c523cfd0507da6a4bef3db3810"
},
{
"size": 3676672,
"path": "KSwitcher.Files.exe",
"sha256": "e9257342788c8212a0d89eb62ed200115d88272225841181fc30feeeba8064c4"
},
{
"size": 194912,
"path": "WebView2Loader.dll",
"sha256": "d3fce90bdeecb08596e377dea6a316354dd8949e32ff09b89d51be00c14e21b6"
},
{
"size": 1103,
"path": "terminal/addon-fit-LICENSE.txt",
"sha256": "e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f"
},
{
"size": 1521,
"path": "terminal/addon-fit.js",
"sha256": "ba3ea256ce0620a0992a197d6c9baea64823fc93d8da07a9e366ca9943c18527"
},
{
"size": 527,
"path": "terminal/index.html",
"sha256": "084d97a727b778fb10d31adcd557ee7840a38377ed60e0ef0f3ece93b349b1ef"
},
{
"size": 4375,
"path": "terminal/terminal.js",
"sha256": "7a31fe8679b7942775c7a9a3e34e836744111b7337e750d6ec7b21188dc4cd61"
},
{
"size": 1261,
"path": "terminal/xterm-LICENSE.txt",
"sha256": "b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd"
},
{
"size": 7112,
"path": "terminal/xterm.css",
"sha256": "854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6"
},
{
"size": 488663,
"path": "terminal/xterm.js",
"sha256": "14903579ff54664cd72f8e8699e6961a6272c21863ec1c3b118cdc8af5d4a972"
},
{
"path": "LICENSE",
"size": 4401,
"path": "LICENSE",
"sha256": "d3c81140cce01c26bf6cbf52aa40e0825e1fa35b9a91d1387170a65d2597082c"
},
{
"path": "json-LICENSE.txt",
"size": 1075,
"path": "json-LICENSE.txt",
"sha256": "c0d068392ea65358b798b8c165103560f06e9e3b38c4ab4e2d8810a7b931af86"
},
{
"size": 883,
"path": "files-text-license.txt",
"sha256": "185c5a37521316e940ead89cbf0b1ba5862d810c7c966e6c6a14a4a515b23625"
},
{
"size": 94223,
"path": "README.md",
"size": 93885,
"sha256": "df6d40d0eb66ad7da8db1cffe6bd211de70dfee474cc228e041c821b3940718b"
"sha256": "7b34f2af7341f57d7bac1b5b7e55a40595a6bc20f461f98c0c6320336d0deba7"
},
{
"path": "LAN-PREVIEW.md",
"size": 11418,
"path": "LAN-PREVIEW.md",
"sha256": "2c13d3ed4761e679c352fb1ade24a1ec3060ad2862376ede801cdee5fcc3bada"
},
{
"path": "dictionaries/en.txt",
"size": 682867,
"path": "dictionaries/en.txt",
"sha256": "5f267c141c3d6837870e1c098f86b89a113ba300343119432644ead88f720c21"
},
{
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"size": 20138,
"path": "dictionaries/LICENSE-CC-BY-SA-4.0.txt",
"sha256": "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5"
},
{
"size": 2265,
"path": "dictionaries/README.md",
"size": 2230,
"sha256": "2df17fe4e17d527c67ab57269b07dce59a49c1fb128c429a1408decbb47109ec"
"sha256": "3017bf44d3be9d9dfab21bd6778aa52b7947e61acf9ce2bc86b41d6e949ac311"
},
{
"path": "dictionaries/ru.txt",
"size": 1060364,
"path": "dictionaries/ru.txt",
"sha256": "9ef922c7699569304a428f6aba0fd13a21341c0d0cb9561f37ef83c41577a28b"
},
{
"size": 1280,
"path": "dictionaries/sources.json",
"size": 1279,
"sha256": "d6e11a3aaba22a8a7eaba19f778d8a5bd66653ff306810e85a1b0e7fff3a6b86"
"sha256": "98e5b3fe7304e37705c9e5ff48b0b399ccb16e806736d21f09921b99e5f2a408"
},
{
"size": 940799,
"path": "dictionaries/uk.txt",
"size": 898248,
"sha256": "caeb097749d4afc84b68ad96b3d7dfb6a6569561cd32ccce0bee94a13b43ba86"
"sha256": "d30e990d6ec2983c04f3644a3c61c6c668e8c542323f5931916fdd55b916eebf"
},
{
"path": "tools/README.txt",
"size": 993,
"path": "tools/README.txt",
"sha256": "a34b5161a8315af037e1ec71d816bfc688f7c364141d4ee2347cf0515ef0c82a"
},
{
"size": 133120,
"path": "tools/StayHere.exe",
"size": 134656,
"sha256": "6ef5cf64708d62b7fc63c3c793cc99188a513433ff4774cae01dc014f6664376"
"sha256": "2fb100dab7de5609505091df539278c7966c229ed51458132a93736db766f2ac"
},
{
"path": "tools/StayHere-README.md",
"size": 2796,
"path": "tools/StayHere-README.md",
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413"
}
],
"schema": 1
]
}
+35 -21
View File
@@ -22,7 +22,7 @@ try {
& $compiler @common -Werror -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o $stayHere
if ($LASTEXITCODE) { throw 'StayHere build failed' }
Copy-Item -LiteralPath $stayHere -Destination build/release/tools/StayHere.exe -Force
foreach ($name in @('lan_channel','lan_input','lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
foreach ($name in @('lan_files','lan_channel','lan_input','lan','lan_pairing','core','platform','autostart','autocorrect','storage','sync','ocr','richtext','update','screenshot','screenshot_main','screenshot_notifications','screenshot_activation','screenshot_launcher','app','ui','main','configuration','config_main')) {
& $compiler @common -c "src/$name.cpp" -o "build/$name.o"
if ($LASTEXITCODE) { throw "Compilation failed: $name" }
}
@@ -35,24 +35,34 @@ try {
& windres --codepage=65001 -DKS_SCREENSHOT_RESOURCES resources/app.rc -O coff -o build/screenshot-res.o
if ($LASTEXITCODE) { throw 'Screenshot resource compilation failed' }
New-Item -ItemType Directory -Force build/release | Out-Null
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui-resident.o build/main.o build/app-res.o @libs -o $executablePath
if ($LASTEXITCODE) { throw 'Link failed' }
Copy-Item -LiteralPath $executablePath -Destination build/release/KSwitcher.exe -Force
$configPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Config.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o build/configuration.o build/config_main.o build/ui-config.o build/app-res.o @libs -o $configPath
if ($LASTEXITCODE) { throw 'Configurator link failed' }
Copy-Item -LiteralPath $configPath -Destination build/release/KSwitcher.Config.exe -Force
$screenshotPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe'
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
& $compiler -mwindows -municode -static -s '-Wl,--gc-sections' build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o build/screenshot.o build/screenshot_notifications.o build/screenshot_activation.o build/screenshot_main.o build/screenshot-res.o @libs -o $screenshotPath
if ($LASTEXITCODE) { throw 'Screenshot link failed' }
Copy-Item -LiteralPath $screenshotPath -Destination build/release/KSwitcher.Screenshot.exe -Force
$filesPath = Join-Path (Split-Path $executablePath) 'KSwitcher.Files.exe'
& windres resources/files.rc -O coff -o build/files-res.o
if ($LASTEXITCODE) { throw 'File manager resource compilation failed' }
& $compiler @common -mwindows -municode -static -s src/files_main.cpp build/files-res.o -luser32 -lgdi32 -lcomctl32 -lshell32 -lcomdlg32 -ladvapi32 -lole32 -o $filesPath
$filesBuild = Join-Path $outDir 'files-cmake'
& cmake -S $root -B $filesBuild -G Ninja "-DCMAKE_CXX_COMPILER=$compiler" -DCMAKE_BUILD_TYPE=MinSizeRel
if ($LASTEXITCODE) { throw 'File manager configuration failed' }
& cmake --build $filesBuild --target KSwitcherFiles --parallel 8
if ($LASTEXITCODE) { throw 'File manager build failed' }
Copy-Item -LiteralPath (Join-Path $filesBuild 'KSwitcher.Files.exe') -Destination $filesPath -Force
& strip $filesPath
if ($LASTEXITCODE) { throw 'File manager stripping failed' }
& "$root/tools/stage-files-terminal.ps1" -Destination (Split-Path $filesPath) | Out-Null
& "$root/tools/stage-files-terminal.ps1" -Destination build/release | Out-Null
Copy-Item -LiteralPath $filesPath -Destination build/release/KSwitcher.Files.exe -Force
if ($Test) {
& cmake --build $filesBuild --target KSwitcherFilesTextTests
if ($LASTEXITCODE) { throw 'Files text test build failed' }
& (Join-Path $filesBuild 'KSwitcherFilesTextTests.exe')
if ($LASTEXITCODE) { throw 'Files text tests failed' }
& $compiler @common -Werror tests/settings_merge.cpp build/sync.o build/storage.o build/core.o build/platform.o build/ocr.o @libs -static -s -o build/KSwitcherSettingsMergeTests.exe
if ($LASTEXITCODE) { throw 'Settings merge test build failed' }
& .\build\KSwitcherSettingsMergeTests.exe
@@ -65,10 +75,14 @@ try {
if ($LASTEXITCODE) { throw 'LAN input test build failed' }
& .\build\KSwitcherLanInputTests.exe
if ($LASTEXITCODE) { throw 'LAN input tests failed' }
& $compiler @common -Werror tests/lan.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe
& $compiler @common -Werror tests/lan.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanTests.exe
if ($LASTEXITCODE) { throw 'LAN test build failed' }
& .\build\KSwitcherLanTests.exe
if ($LASTEXITCODE) { throw 'LAN pairing tests failed' }
& $compiler @common -Werror tests/lan_files.cpp build/lan_files.o build/lan_channel.o build/lan_pairing.o build/core.o build/platform.o @libs -static -s -o build/KSwitcherLanFilesTests.exe
if ($LASTEXITCODE) { throw 'LAN file test build failed' }
& .\build\KSwitcherLanFilesTests.exe
if ($LASTEXITCODE) { throw 'LAN file tests failed' }
& $compiler @common -Werror -DSTAYHERE_TEST_INSTANCE -mwindows -municode -static -s utilities/StayHere/main.cpp -luser32 -lshell32 -lgdi32 -lwtsapi32 -o build/StayHereTest.exe
if ($LASTEXITCODE) { throw 'StayHere fixture build failed' }
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File tests/stayhere.ps1 -Executable (Join-Path $outDir 'StayHereTest.exe')
@@ -77,11 +91,11 @@ try {
if ($LASTEXITCODE) { throw 'Utilities test build failed' }
& .\build\KSwitcherUtilitiesTests.exe
if ($LASTEXITCODE) { throw 'Utilities tests failed' }
& $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
& $compiler @common tests/dictionary.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autocorrect.o @libs -lpsapi -static -s -o build/KSwitcherDictionaryTests.exe
if ($LASTEXITCODE) { throw 'Dictionary test build failed' }
& .\build\KSwitcherDictionaryTests.exe
if ($LASTEXITCODE) { throw 'Dictionary tests failed' }
& $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
& $compiler @common tests/sync.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/sync.o build/ocr.o @libs -static -s -o build/KSwitcherSyncTests.exe
if ($LASTEXITCODE) { throw 'Sync test build failed' }
& .\build\KSwitcherSyncTests.exe
if ($LASTEXITCODE) { throw 'Sync tests failed' }
@@ -89,19 +103,19 @@ try {
if ($LASTEXITCODE) { throw 'Scroll test build failed' }
& .\build\KSwitcherScrollTests.exe
if ($LASTEXITCODE) { throw 'Scroll tests failed' }
& $compiler @common tests/tests.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
& $compiler @common tests/tests.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o @libs -static -s -o build/KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Test build failed' }
& .\build\KSwitcherTests.exe
if ($LASTEXITCODE) { throw 'Tests failed' }
& $compiler @common tests/input.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
& $compiler @common tests/input.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input test build failed' }
& .\build\KSwitcherInputTests.exe
if ($LASTEXITCODE) { throw 'Input tests failed' }
& $compiler @common tests/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
& $compiler @common tests/chat_profiles.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherChatProfileTests.exe
if ($LASTEXITCODE) { throw 'Chat profile test build failed' }
& .\build\KSwitcherChatProfileTests.exe
if ($LASTEXITCODE) { throw 'Chat profile tests failed' }
& $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
& $compiler @common tests/screenshot_ui.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/ocr.o build/screenshot-res.o @libs -static -s -o build/KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI test build failed' }
& .\build\KSwitcherScreenshotUITests.exe
if ($LASTEXITCODE) { throw 'Screenshot UI tests failed' }
@@ -109,7 +123,7 @@ try {
if ($LASTEXITCODE) { throw 'Screenshot save tests failed' }
$registrationTest = Join-Path (Split-Path $executablePath) 'KSwitcherRegistrationTests.exe'
try {
& $compiler @common tests/screenshot_registration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
& $compiler @common tests/screenshot_registration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o @libs -static '-Wl,--gc-sections' -s -o $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration test build failed' }
& $registrationTest
if ($LASTEXITCODE) { throw 'Screenshot registration tests failed' }
@@ -121,7 +135,7 @@ try {
if ($LASTEXITCODE) { throw 'Updater progress UI tests failed' }
}
if ($Far) {
& $compiler @common tests/far_snippets.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
& $compiler @common tests/far_snippets.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -mwindows -municode -static -s -o build/KSwitcherFarSnippetTests.exe
if ($LASTEXITCODE) { throw 'Far snippet test build failed' }
$fixture = Start-Process -FilePath (Join-Path $outDir 'KSwitcherFarSnippetTests.exe') -WindowStyle Hidden -Wait -PassThru
Get-Content -LiteralPath (Join-Path $outDir 'far-snippets.log')
@@ -133,28 +147,28 @@ try {
Copy-Item $executablePath,(Join-Path (Split-Path $executablePath) 'KSwitcher.Screenshot.exe') -Destination $historyFixture
Set-Content (Join-Path $historyFixture '.local-dev') 'Isolated history integration test'
$historyTest = Join-Path $historyFixture 'KSwitcherHistoryHostTests.exe'
& $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
& $compiler @common tests/history_host.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $historyTest
if ($LASTEXITCODE) { throw 'History host test build failed' }
& $historyTest
if ($LASTEXITCODE) { throw 'History host tests failed' }
$snapshotTest = Join-Path (Split-Path $executablePath) 'KSwitcherClipboardSnapshotTests.exe'
& $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
& $compiler @common tests/clipboard_snapshot.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -lpsapi -static -s -o $snapshotTest
if ($LASTEXITCODE) { throw 'Clipboard snapshot test build failed' }
& $snapshotTest
if ($LASTEXITCODE) { throw 'Clipboard snapshot tests failed' }
$captureTest = Join-Path (Split-Path $executablePath) 'KSwitcherImageCaptureTests.exe'
& $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
& $compiler @common tests/image_capture.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/storage.o build/ocr.o @libs -static -s -o $captureTest
if ($LASTEXITCODE) { throw 'Image capture test build failed' }
& $captureTest
if ($LASTEXITCODE) { throw 'Image capture tests failed' }
& $compiler @common tests/integration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
& $compiler @common tests/integration.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o build/KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration test build failed' }
& .\build\KSwitcherIntegration.exe
if ($LASTEXITCODE) { throw 'Integration tests failed' }
}
if ($Ocr) {
$ocrTest = Join-Path (Split-Path $executablePath) 'KSwitcherOcrTests.exe'
& $compiler @common tests/ocr.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
& $compiler @common tests/ocr.cpp build/lan_channel.o build/lan_input.o build/lan.o build/lan_files.o build/lan_pairing.o build/core.o build/platform.o build/autostart.o build/autocorrect.o build/storage.o build/sync.o build/ocr.o build/richtext.o build/update.o build/screenshot.o build/screenshot_launcher.o build/screenshot_activation.o build/screenshot_notifications.o build/app.o build/configuration.o build/ui.o build/screenshot-res.o @libs -static -s -o $ocrTest
if ($LASTEXITCODE) { throw 'OCR test build failed' }
& $ocrTest
if ($LASTEXITCODE) { throw 'OCR tests failed' }
+26
View File
@@ -0,0 +1,26 @@
include(FetchContent)
FetchContent_Declare(scintilla
URL https://www.scintilla.org/scintilla569.zip
URL_HASH SHA256=fddc637afa6e3a9205a5ad67c777d711a921ed2797d792f3034010c1be1539d6
DOWNLOAD_EXTRACT_TIMESTAMP FALSE)
FetchContent_Declare(lexilla
URL https://www.scintilla.org/lexilla555.zip
URL_HASH SHA256=713ada097b7da0594638e2c5723d8dea486653c23ec2e0c12631df7e5b7f0991
DOWNLOAD_EXTRACT_TIMESTAMP FALSE)
FetchContent_MakeAvailable(scintilla lexilla)
file(GLOB scintilla_sources "${scintilla_SOURCE_DIR}/src/*.cxx" "${scintilla_SOURCE_DIR}/win32/*.cxx")
list(FILTER scintilla_sources EXCLUDE REGEX "/ScintillaDLL\\.cxx$")
file(GLOB lexilla_sources "${lexilla_SOURCE_DIR}/lexlib/*.cxx" "${lexilla_SOURCE_DIR}/lexers/*.cxx")
add_library(KSwitcherFilesText STATIC ${scintilla_sources} ${lexilla_sources}
"${lexilla_SOURCE_DIR}/src/Lexilla.cxx")
target_include_directories(KSwitcherFilesText
PUBLIC "${scintilla_SOURCE_DIR}/include" "${lexilla_SOURCE_DIR}/include"
PRIVATE "${scintilla_SOURCE_DIR}/src" "${lexilla_SOURCE_DIR}/lexlib")
target_compile_definitions(KSwitcherFilesText PRIVATE UNICODE _UNICODE NOMINMAX LEXILLA_NO_EXPORT)
target_link_libraries(KSwitcherFilesText PRIVATE imm32 ole32 oleaut32 msimg32)
target_link_libraries(KSwitcherFiles PRIVATE KSwitcherFilesText)
if(MSVC)
set_property(TARGET KSwitcherFilesText KSwitcherFiles PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_compile_options(KSwitcherFilesText PRIVATE /utf-8)
endif()
+4 -1
View File
@@ -7,8 +7,11 @@ Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.exe') -Destinat
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Config.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Screenshot.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\KSwitcher.Files.exe') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\WebView2Loader.dll') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'build\release\terminal') -Destination $destination -Recurse -Force
Copy-Item -LiteralPath (Join-Path $root 'LICENSE') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'vendor\json-LICENSE.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'resources\files-text-license.txt') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'README.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'LAN-PREVIEW.md') -Destination $destination
Copy-Item -LiteralPath (Join-Path $root 'dictionaries') -Destination $destination -Recurse -Force
@@ -17,7 +20,7 @@ Copy-Item -LiteralPath (Join-Path $root 'resources\portable-tools-README.txt') -
Copy-Item -LiteralPath (Join-Path $root 'build\release\tools\StayHere.exe') -Destination (Join-Path $destination 'tools\StayHere.exe') -Force
Copy-Item -LiteralPath (Join-Path $root 'utilities\StayHere\README.md') -Destination (Join-Path $destination 'tools\StayHere-README.md') -Force
# Enumerate the distributable files explicitly; do not include a user's data directory.
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','LICENSE','json-LICENSE.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$files = @('KSwitcher.exe','KSwitcher.Config.exe','KSwitcher.Screenshot.exe','KSwitcher.Files.exe','WebView2Loader.dll','terminal','LICENSE','json-LICENSE.txt','files-text-license.txt','README.md','LAN-PREVIEW.md','dictionaries','tools\README.txt','tools\StayHere.exe','tools\StayHere-README.md') | ForEach-Object { Join-Path $destination $_ }
$entries = foreach ($path in $files) {
foreach ($file in @(Get-ChildItem -LiteralPath $path -File -Recurse)) {
@{path=$file.FullName.Substring($destination.Length+1).Replace('\','/'); size=$file.Length; sha256=(Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()}
+3 -3
View File
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
1 RT_MANIFEST "app.manifest"
201 RCDATA "update.ps1"
1 VERSIONINFO
FILEVERSION 0,6,77,0
PRODUCTVERSION 0,6,77,0
FILEVERSION 0,6,91,0
PRODUCTVERSION 0,6,91,0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
@@ -20,7 +20,7 @@ BEGIN
BEGIN
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
VALUE "ProductName", "KSwitcher"
VALUE "FileVersion", "0.6.77"
VALUE "FileVersion", "0.6.91"
END
END
BLOCK "VarFileInfo"
+20
View File
@@ -0,0 +1,20 @@
License for Lexilla, Scintilla, and SciTE
Copyright 1998-2021 by Neil Hodgson <[email protected]>
All Rights Reserved
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation.
NEIL HODGSON DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS, IN NO EVENT SHALL NEIL HODGSON BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE
OR PERFORMANCE OF THIS SOFTWARE.
+236 -30
View File
@@ -1,40 +1,166 @@
#pragma code_page(65001)
#include <windows.h>
#include <commctrl.h>
#define FAR_EDIT(id, mode, x, y, width, height) \
CONTROL "", id, "KSwitcher.Files.FarEdit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP | mode, x, y, width, height
1 ICON "kswitcher-files.ico"
1 RT_MANIFEST "app.manifest"
601 RCDATA "files-text-license.txt"
101 DIALOGEX 0, 0, 256, 141
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Wide panel mode"
FONT 9, "Segoe UI"
602 DIALOGEX 0, 0, 230, 85
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
FONT 10, "Consolas"
BEGIN
GROUPBOX "Panel columns — Name, Size", -1, 8, 6, 240, 59
LTEXT "Size column &width (6-16 characters)", -1, 17, 22, 166, 10
EDITTEXT 201, 196, 18, 41, 14, ES_NUMBER | ES_AUTOHSCROLL
AUTOCHECKBOX "&Align file extensions", 202, 17, 42, 104, 12
AUTOCHECKBOX "Align &folder extensions", 204, 130, 42, 108, 12
GROUPBOX "Status line columns", -1, 8, 72, 240, 39
AUTOCHECKBOX "Show &details", 203, 17, 83, 94, 11
AUTOCHECKBOX "Name", 205, 17, 96, 47, 11
AUTOCHECKBOX "Size", 206, 72, 96, 42, 11
AUTOCHECKBOX "Date", 207, 128, 96, 45, 11
AUTOCHECKBOX "Time", 208, 185, 96, 45, 11
DEFPUSHBUTTON "OK", IDOK, 139, 119, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 196, 119, 50, 14
LTEXT "&Page number", 101, 18, 21, 194, 12
FAR_EDIT(100, ES_NUMBER, 18, 38, 194, 16)
CONTROL "&Go", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 53, 59, 60, 17
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 117, 59, 60, 17
END
102 DIALOGEX 0, 0, 350, 114
604 DIALOGEX 0, 0, 268, 101
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
FONT 10, "Consolas"
BEGIN
CONTROL "&Search for", 109, "Static", SS_OWNERDRAW, 18, 17, 130, 12
FAR_EDIT(100, 0, 18, 30, 241, 14)
CONTROL "↓", 108, "Button", BS_OWNERDRAW | WS_TABSTOP, 259, 30, 11, 14
CONTROL "W&ord", 106, "Button", BS_OWNERDRAW | WS_TABSTOP, 160, 14, 49, 15
CONTROL "Se&lection", 107, "Button", BS_OWNERDRAW | WS_TABSTOP, 212, 14, 58, 15
CONTROL "", 104, "Static", NOT WS_VISIBLE, 18, 51, 1, 1
AUTOCHECKBOX "&Case sensitive", 101, 18, 52, 110, 12
AUTOCHECKBOX "&Whole words", 102, 18, 64, 110, 12
CONTROL "", 105, "Static", NOT WS_VISIBLE, 18, 81, 1, 1
CONTROL "Find pre&vious", 103, "Button", BS_OWNERDRAW | WS_TABSTOP, 33, 80, 83, 15
CONTROL "Find &next", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 121, 80, 76, 15
CONTROL "Canc&el", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 202, 80, 53, 15
END
603 DIALOGEX 0, 0, 230, 72
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
FONT 10, "Consolas"
BEGIN
LTEXT "Save changes to this file?", -1, 18, 23, 194, 12, SS_CENTER
CONTROL "&Save", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 17, 42, 60, 17
CONTROL "&Discard", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 82, 42, 66, 17
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 153, 42, 60, 17
END
607 DIALOGEX 0, 0, 185, 57
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
FONT 10, "Consolas"
BEGIN
CONTROL "", 100, "Edit", ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL, 14, 16, 157, 13
AUTOCHECKBOX "&Remember for this session", 101, 14, 30, 157, 13, NOT WS_VISIBLE
CONTROL "", 102, "Static", NOT WS_VISIBLE, 14, 35, 1, 1
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 67, 38, 51, 15
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 36, 38, 51, 15
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP | NOT WS_VISIBLE, 98, 38, 51, 15
END
608 DIALOGEX 0, 0, 220, 67
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "Save color scheme"
FONT 9, "Segoe UI"
BEGIN
LTEXT "Scheme &name", -1, 8, 8, 204, 10
EDITTEXT 100, 8, 21, 204, 14, ES_AUTOHSCROLL
DEFPUSHBUTTON "Save", IDOK, 105, 44, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 162, 44, 50, 14
END
101 DIALOGEX 0, 0, 397, 310
STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_CLIPCHILDREN
CAPTION "KSwitcher Files Options"
FONT 9, "Segoe UI"
BEGIN
LTEXT "&Search settings", -1, 8, 8, 113, 10
EDITTEXT 270, 8, 21, 113, 14, ES_AUTOHSCROLL
CONTROL "", 271, "SysTreeView32", WS_TABSTOP | WS_BORDER | TVS_HASLINES | TVS_LINESATROOT | TVS_HASBUTTONS | TVS_SHOWSELALWAYS, 8, 40, 113, 240
LTEXT "", 272, 131, 8, 253, 12
AUTOCHECKBOX "Left panel: &Wide", 273, 132, 27, 115, 12
AUTOCHECKBOX "Right panel: W&ide", 274, 251, 27, 125, 12
GROUPBOX "Wide panel columns", 275, 129, 27, 259, 59
LTEXT "Size column &width (6–16 characters)", 276, 138, 44, 175, 10
EDITTEXT 201, 335, 41, 43, 14, ES_NUMBER | ES_AUTOHSCROLL
AUTOCHECKBOX "&Align file extensions", 202, 138, 64, 108, 12
AUTOCHECKBOX "Align &folder extensions", 204, 252, 64, 125, 12
GROUPBOX "Status line columns", 277, 129, 27, 259, 46
AUTOCHECKBOX "Show &details", 203, 138, 40, 100, 12
AUTOCHECKBOX "Name", 205, 138, 55, 47, 12
AUTOCHECKBOX "Size", 206, 196, 55, 42, 12
AUTOCHECKBOX "Date", 207, 254, 55, 45, 12
AUTOCHECKBOX "Time", 208, 312, 55, 45, 12
PUSHBUTTON "Change &color…", 279, 131, 48, 82, 15
LTEXT "", 280, 222, 50, 155, 12
LTEXT "", 281, 131, 30, 254, 14
PUSHBUTTON "Change &font…", 282, 131, 52, 82, 15
LTEXT "&Outer padding (px at 96 DPI)", 302, 131, 31, 173, 12
COMBOBOX 303, 311, 28, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "&Inner padding (px at 96 DPI)", 304, 131, 54, 173, 12
COMBOBOX 305, 311, 51, 56, 125, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "&Row text padding (characters)", 306, 131, 77, 173, 12
COMBOBOX 307, 311, 74, 56, 76, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "Previous command", 320, 131, 30, 120, 12
CONTROL "", 321, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 27, 110, 15
LTEXT "Open command history", 322, 131, 53, 120, 12
CONTROL "", 323, "msctls_hotkey32", WS_TABSTOP | WS_BORDER, 260, 50, 110, 15
AUTOCHECKBOX "Allow &paired PCs to run PowerShell commands here", 370, 131, 79, 250, 14
AUTOCHECKBOX "Allow paired PCs to &hibernate this PC", 371, 131, 95, 250, 14
AUTOCHECKBOX "Allow paired PCs to &update KSwitcher here", 372, 131, 111, 250, 14
PUSHBUTTON "Reset selected to defaults", 284, 131, 181, 125, 15
COMBOBOX 285, 131, 183, 139, 95, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "Reset property", 286, 278, 183, 108, 15
LTEXT "File type", 287, 131, 26, 75, 10
COMBOBOX 288, 131, 38, 151, 100, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "&Remove", 289, 290, 38, 77, 14
LTEXT "Windows extension or custom extension", 290, 131, 63, 246, 10
COMBOBOX 291, 131, 75, 151, 108, CBS_DROPDOWN | CBS_AUTOHSCROLL | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "A&dd", 292, 290, 75, 77, 14
LTEXT "Row state", 300, 131, 100, 72, 10
COMBOBOX 301, 131, 112, 174, 80, CBS_DROPDOWNLIST | WS_TABSTOP
PUSHBUTTON "&Foreground…", 293, 131, 135, 78, 14
PUSHBUTTON "&Background…", 294, 216, 135, 78, 14
PUSHBUTTON "&Inherit colors", 295, 301, 135, 83, 14
LTEXT "", 296, 131, 152, 253, 13
AUTOCHECKBOX "&Underline", 297, 131, 166, 80, 12
AUTOCHECKBOX "Stri&kethrough", 298, 218, 166, 100, 12
CONTROL "Settings preview", 299, "Static", SS_OWNERDRAW | WS_BORDER, 131, 202, 253, 81
AUTOCHECKBOX "&Wrap lines", 350, 131, 28, 118, 12
AUTOCHECKBOX "&Line numbers", 351, 257, 28, 125, 12
AUTOCHECKBOX "Show &whitespace and line endings", 352, 131, 45, 118, 12
AUTOCHECKBOX "&Highlight current line", 353, 257, 45, 125, 12
AUTOCHECKBOX "Insert &spaces for tabs", 354, 131, 62, 118, 12
AUTOCHECKBOX "Auto &indent", 355, 257, 62, 125, 12
LTEXT "&Tab width", 356, 131, 84, 118, 10
COMBOBOX 357, 257, 81, 125, 100, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
LTEXT "Line &endings for new lines", 358, 131, 104, 118, 10
COMBOBOX 359, 257, 101, 125, 77, CBS_DROPDOWNLIST | WS_TABSTOP
LTEXT "Color sc&heme", 362, 131, 125, 118, 10
COMBOBOX 363, 257, 122, 125, 67, CBS_DROPDOWNLIST | WS_TABSTOP
LTEXT "Customize &color", 364, 131, 145, 118, 10
COMBOBOX 365, 257, 142, 125, 116, CBS_DROPDOWNLIST | WS_TABSTOP | WS_VSCROLL
PUSHBUTTON "Change color…", 366, 257, 181, 82, 15
LTEXT "", 367, 345, 184, 45, 12
PUSHBUTTON "&Save scheme…", 368, 131, 163, 118, 15
DEFPUSHBUTTON "OK", IDOK, 222, 289, 50, 14
PUSHBUTTON "Cancel", IDCANCEL, 280, 289, 50, 14
PUSHBUTTON "&Apply", 283, 338, 289, 50, 14
END
102 DIALOGEX 0, 0, 350, 110
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Copy"
FONT 10, "Consolas"
BEGIN
LTEXT "Copy to", 210, 14, 15, 322, 12, SS_PATHELLIPSIS
CONTROL "", 211, "Edit", ES_MULTILINE | ES_AUTOHSCROLL | WS_TABSTOP, 14, 29, 322, 18
LTEXT "Existing files:", 212, 14, 61, 122, 14, SS_CENTERIMAGE
COMBOBOX 213, 140, 61, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
CONTROL "Also ask on read-only files", 214, "Button", BS_OWNERDRAW | WS_TABSTOP, 14, 81, 322, 14
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 96, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 96, 86, 18
FAR_EDIT(211, 0, 14, 29, 322, 18)
LTEXT "Existing files:", 212, 14, 57, 122, 14, SS_CENTERIMAGE
COMBOBOX 213, 140, 57, 196, 100, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_VSCROLL | WS_TABSTOP
AUTOCHECKBOX "Also ask on read-only files", 214, 14, 77, 322, 14
CONTROL "&Copy", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 87, 92, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 177, 92, 86, 18
END
103 DIALOGEX 0, 0, 460, 126
@@ -53,7 +179,7 @@ BEGIN
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 106, 86, 18
END
104 DIALOGEX 0, 0, 360, 106
104 DIALOGEX 0, 0, 360, 139
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Warning"
FONT 10, "Consolas"
@@ -61,11 +187,16 @@ BEGIN
LTEXT "", 230, 12, 16, 336, 12
LTEXT "", 231, 12, 33, 336, 12, SS_PATHELLIPSIS
LTEXT "", 233, 12, 55, 1, 1
LTEXT "", 234, 12, 81, 1, 1
CONTROL "Remember choice", 232, "Button", BS_OWNERDRAW | WS_TABSTOP, 12, 57, 336, 18
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 89, 110, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 89, 68, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 89, 86, 18
LTEXT "New", 329, 12, 58, 65, 13
RTEXT "", 330, 78, 58, 270, 13
LTEXT "Existing", 331, 12, 72, 65, 13
RTEXT "", 332, 78, 72, 270, 13
LTEXT "", 333, 12, 88, 1, 1
LTEXT "", 234, 12, 114, 1, 1
AUTOCHECKBOX "Remember choice", 232, 12, 90, 336, 18
CONTROL "&Overwrite", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 42, 122, 110, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 158, 122, 68, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 232, 122, 86, 18
END
105 DIALOGEX 0, 0, 310, 65
@@ -78,3 +209,78 @@ BEGIN
CONTROL "&Yes", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 83, 42, 68, 18
CONTROL "&No", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 159, 42, 68, 18
END
106 DIALOGEX 0, 0, 400, 190
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Delete"
FONT 10, "Consolas"
BEGIN
LTEXT "", 240, 14, 14, 372, 16
LTEXT "", 241, 14, 32, 372, 122, SS_NOPREFIX
LTEXT "", 243, 14, 177, 1, 1
CONTROL "&Move", IDYES, "Button", BS_OWNERDRAW | WS_TABSTOP, 37, 178, 86, 18
CONTROL "&All", 244, "Button", BS_OWNERDRAW | WS_TABSTOP, 129, 178, 63, 18
CONTROL "&Skip", IDNO, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 63, 18
CONTROL "Skip a&ll", 249, "Button", BS_OWNERDRAW | WS_TABSTOP, 197, 178, 94, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 265, 178, 79, 18
END
107 DIALOGEX 0, 0, 400, 82
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Deleting"
FONT 10, "Consolas"
BEGIN
LTEXT "Scanning...", 245, 14, 15, 372, 14, SS_PATHELLIPSIS | SS_NOPREFIX
LTEXT "", 246, 14, 31, 372, 14
CONTROL "", 247, "msctls_progress32", PBS_SMOOTH, 14, 48, 372, 18
LTEXT "", 248, 14, 73, 1, 1
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 157, 74, 86, 18
END
108 DIALOGEX 0, 0, 264, 110
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
FONT 10, "Consolas"
BEGIN
CONTROL "Create the &folder:", 360, "Static", SS_OWNERDRAW, 14, 15, 236, 12
FAR_EDIT(361, 0, 14, 27, 225, 16)
CONTROL "↓", 363, "Button", BS_OWNERDRAW | WS_TABSTOP, 239, 27, 11, 16
CONTROL "", 362, "Static", NOT WS_VISIBLE, 14, 48, 1, 1
CONTROL "&Link type:", 364, "Static", SS_OWNERDRAW, 14, 51, 73, 12
COMBOBOX 365, 90, 48, 160, 64, CBS_DROPDOWNLIST | CBS_OWNERDRAWFIXED | CBS_HASSTRINGS | WS_TABSTOP
CONTROL "&Target:", 366, "Static", SS_OWNERDRAW, 14, 65, 73, 12
FAR_EDIT(367, 0, 90, 63, 160, 16)
AUTOCHECKBOX "Process &multiple names", 368, 14, 79, 236, 13
CONTROL "", 369, "Static", NOT WS_VISIBLE, 14, 96, 1, 1
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 72, 97, 54, 15
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 132, 97, 60, 15
END
109 DIALOGEX 0, 0, 250, 85
STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CLIPCHILDREN
CAPTION "Folder shortcuts"
FONT 10, "Consolas"
BEGIN
CONTROL "", 310, "ListBox", LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP, 8, 14, 234, 50
END
110 DIALOGEX 0, 0, 370, 116
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
CAPTION "Folder shortcuts"
FONT 10, "Consolas"
BEGIN
LTEXT "&Title", 316, 16, 18, 338, 12
FAR_EDIT(314, ES_LEFT, 16, 31, 338, 15)
LTEXT "&Path", 317, 16, 49, 338, 12
FAR_EDIT(211, ES_LEFT, 16, 62, 338, 15)
LTEXT "", 315, 10, 82, 1, 1, NOT WS_VISIBLE
CONTROL "&OK", IDOK, "Button", BS_OWNERDRAW | WS_TABSTOP, 97, 86, 86, 18
CONTROL "&Cancel", IDCANCEL, "Button", BS_OWNERDRAW | WS_TABSTOP, 187, 86, 86, 18
END
111 DIALOGEX 0, 0, 385, 220
STYLE DS_SETFONT | DS_CENTER | WS_POPUP
FONT 9, "Consolas"
BEGIN
LTEXT "&Search commands or topics", 326, 12, 14, 361, 12
FAR_EDIT(325, 0, 12, 28, 361, 16)
LISTBOX 324, 12, 49, 361, 155, LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | WS_TABSTOP
END
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>PowerShell terminal</title>
<link rel="stylesheet" href="xterm.css">
<style>
html, body, #terminal { width:100%; height:100%; margin:0; overflow:hidden; background:#181818; }
</style>
<div id="terminal"></div>
<script src="xterm.js"></script>
<script src="addon-fit.js"></script>
<script src="terminal.js"></script>
</html>
+120
View File
@@ -0,0 +1,120 @@
const bridge = window.chrome.webview;
const terminal = new Terminal({
fontFamily: 'Consolas, monospace',
fontSize: 16,
cursorBlink: false,
stdinDisabled: true,
theme: { background: '#181818', foreground: '#e8e8e8' },
});
const fit = new FitAddon.FitAddon();
terminal.parser.registerOscHandler(633, data => {
const prefix = 'ks-directory:';
if (!data.startsWith(prefix)) return false;
try {
bridge.postMessage('directory:' + decodeURIComponent(data.slice(prefix.length)));
} catch {
bridge.postMessage('directory:');
}
return true;
});
terminal.loadAddon(fit);
const host = document.getElementById('terminal');
terminal.open(host);
host.addEventListener('pointerup', () => bridge.postMessage('focus-input'), true);
const screen = host.querySelector('.xterm-screen');
let traceEnabled = false;
let traceBudget = 4096;
let outputTraceBudget = 256;
let traceOutput = false;
let currentOffset = 0;
function trace(phase) {
if (!traceEnabled || traceBudget <= 0) return;
traceBudget--;
const buffer = terminal.buffer.active;
bridge.postMessage(`trace:${phase},${host.clientHeight},${terminal.rows},${buffer.viewportY},${buffer.baseY},${Math.round(currentOffset)}`);
}
function anchorOutput() {
const buffer = terminal.buffer.active;
let offset = 0;
if (buffer.type === 'normal' && buffer.viewportY === buffer.baseY) {
let last = 0;
for (let row = terminal.rows - 1; row >= 0; row--) {
if (buffer.getLine(buffer.baseY + row)?.translateToString(true)) {
last = row;
break;
}
}
offset = Math.max(0, terminal.rows - 1 - last) * screen.offsetHeight / terminal.rows;
}
screen.style.transform = `translateY(${offset}px)`;
currentOffset = offset;
trace(2);
}
terminal.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown' || !event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return true;
if (event.code === 'KeyO') bridge.postMessage('toggle');
else if (event.code === 'ArrowUp') bridge.postMessage('grow');
else if (event.code === 'ArrowDown') bridge.postMessage('shrink');
else if (event.code === 'KeyF' || event.code === 'KeyC') {
if (!event.repeat) bridge.postMessage(event.code === 'KeyF' ? 'insert-path' : 'copy-command-line');
}
else if (event.key === 'Enter') {
if (!event.repeat) bridge.postMessage('insert-name');
}
else if (event.key === 'Insert') {
if (!event.repeat) bridge.postMessage('copy-panel-names');
}
else return true;
event.preventDefault();
return false;
});
const reportSize = () => bridge.postMessage(`size:${terminal.cols},${terminal.rows}`);
terminal.onResize(reportSize);
terminal.onScroll(() => {
trace(3);
anchorOutput();
});
bridge.addEventListener('message', ({ data }) => {
if (data === 'trace:on' || data === 'trace:output') {
traceEnabled = true;
traceOutput = data === 'trace:output';
trace(2);
return;
}
const font = /^font:(\d+):(\d+):([01]):(.*)$/u.exec(data);
if (font) {
const [, points, weight, italic, family] = font;
const quotedFamily = family.replace(/["\\]/g, '\\$&');
terminal.options.fontFamily = `"${quotedFamily}", monospace`;
terminal.options.fontSize = Number(points) * 96 / 720;
terminal.options.fontWeight = Number(weight);
host.style.fontStyle = italic === '1' ? 'italic' : 'normal';
fit.fit();
anchorOutput();
return;
}
const bytes = new Uint8Array(data.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(data.slice(2*i, 2*i+2), 16);
terminal.write(bytes, () => {
anchorOutput();
if (traceOutput && outputTraceBudget > 0) {
outputTraceBudget--;
const buffer = terminal.buffer.active;
const lines = Array.from({ length: terminal.rows }, (_, row) =>
buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '');
bridge.postMessage('output:' + lines.join('\n').trimEnd());
}
});
});
new ResizeObserver(() => {
const buffer = terminal.buffer.active;
const wasAtBottom = buffer.viewportY === buffer.baseY;
trace(0);
fit.fit();
trace(1);
if (wasAtBottom) terminal.scrollToBottom();
requestAnimationFrame(anchorOutput);
}).observe(host);
bridge.postMessage('ready');
fit.fit();
reportSize();
+68 -5
View File
@@ -4,6 +4,7 @@
[string]$InstallDirectory, [string]$WorkDirectory,
[int]$ParentProcessId, [string]$ReadyEvent,
[switch]$ShowProgress,
[switch]$Unattended, [string]$Journal,
[switch]$Library
)
$ErrorActionPreference = 'Stop'
@@ -408,12 +409,59 @@ function Undo-Files([string]$Target,[string]$Backup,[Collections.Generic.List[st
function Write-Result($Value) {
$Value | ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $WorkDirectory 'result.json') -Encoding UTF8
}
function Write-UpdateJournal([string]$Message) {
if (!$Journal) { return }
[IO.File]::AppendAllText($Journal, "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') LAN update: $Message`n",
[Text.UTF8Encoding]::new($false))
}
function Stop-UpdateCompanions([string]$Directory) {
if (!('KSwitcherUpdateProcessImage' -as [type])) {
Add-Type @'
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class KSwitcherUpdateProcessImage {
[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
[DllImport("kernel32.dll", CharSet=CharSet.Unicode)]
static extern bool QueryFullProcessImageName(IntPtr process, int flags, StringBuilder image, ref int length);
[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle);
public static string Read(int pid) {
var process = OpenProcess(0x1000, false, pid);
if (process == IntPtr.Zero) return null;
try {
var length = 32768;
var image = new StringBuilder(length);
return QueryFullProcessImageName(process, 0, image, ref length) ? image.ToString() : null;
} finally { CloseHandle(process); }
}
}
'@
}
$root = [IO.Path]::GetFullPath($Directory).TrimEnd('\')
$prefix = [regex]::Escape($root + '\')
$pattern = '^' + $prefix + '(?:\.kswitcher-update-[0-9a-f]{32}\\backup\\)?KSwitcher\.(?:Config|Screenshot|Files)\.exe$'
foreach ($process in @(Get-Process 'KSwitcher.Config','KSwitcher.Screenshot','KSwitcher.Files' -ErrorAction SilentlyContinue)) {
$image = [KSwitcherUpdateProcessImage]::Read($process.Id)
if (!$image -and !$process.HasExited) {
Write-UpdateJournal "Could not identify $($process.ProcessName) (PID $($process.Id))."
}
if ($image -notmatch $pattern) { continue }
Write-UpdateJournal "Closing $($process.ProcessName) (PID $($process.Id))."
if (!$process.HasExited) {
try { $process.Kill() } catch { if (!$process.HasExited) { throw } }
if (!$process.WaitForExit(10000)) { throw "Could not close $($process.ProcessName) (PID $($process.Id))." }
}
Write-UpdateJournal "Closed $($process.ProcessName) (PID $($process.Id))."
}
}
if ($Library) { return }
$transaction = $null; $parentProcess = $null; $ready = $null; $started = $null
$changed = [Collections.Generic.List[string]]::new()
$parentExited = $false; $success = $false; $preserveBackup = $false; $child = $null; $uiFinished = $false
try {
if ($ShowProgress -or $Mode -eq 'Install') { Start-UpdateProgress }
if (!$Unattended -and ($ShowProgress -or $Mode -eq 'Install')) { Start-UpdateProgress }
$current = Get-StableVersion $CurrentVersion
if (!$current) { throw 'Invalid installed version.' }
if ($Mode -eq 'Install') {
@@ -426,9 +474,12 @@ try {
$metadata = Join-Path $WorkDirectory 'releases.json'
Receive-File 'https://gitea.hrynco.com/api/v1/repos/hrynco/hrynco-kswitcher/releases?limit=100' $metadata 2MB
$release = Select-Release (Get-Content -LiteralPath $metadata -Raw | ConvertFrom-Json) $current
if (!$release) { Write-Result @{state='current'}; exit 0 }
if (!$release) {
if ($Unattended) { Write-UpdateJournal "No newer stable release is available ($CurrentVersion)." }
Write-Result @{state='current'}; exit 0
}
if ($Mode -eq 'Check') { Write-Result @{state='available';version=$release.version}; exit 0 }
if ($ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
if (!$Unattended -and $ExpectedVersion -ne $release.version) { throw 'Release changed since checking. Please select Update again.' }
Assert-PlainDirectory $InstallDirectory
$transaction = Join-Path $InstallDirectory ('.kswitcher-update-'+[guid]::NewGuid().ToString('N'))
$stage = Join-Path $transaction 'stage'; $backup = Join-Path $transaction 'backup'
@@ -442,12 +493,18 @@ try {
Show-UpdateProgress 'Preparing installation' 'Validating and unpacking the release files'
Expand-Release $archive $stage (Get-StableVersion $release.version)
$screenshotPath = [IO.Path]::GetFullPath((Join-Path $InstallDirectory 'KSwitcher.Screenshot.exe'))
if (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath }) { throw 'Close screenshot editors before installing an update.' }
if (!$Unattended -and (Get-Process 'KSwitcher.Screenshot' -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $screenshotPath })) {
throw 'Close screenshot editors before installing an update.'
}
if ($Unattended) {
Write-UpdateJournal "Verified $($release.version). Closing KSwitcher (PID $ParentProcessId)."
}
Write-Result @{state='prepared';version=$release.version}
Show-UpdateProgress 'Closing KSwitcher' 'Waiting for the running application to exit safely'
$ready.Set() | Out-Null
if (!$parentProcess.WaitForExit(120000)) { throw 'KSwitcher did not exit; the installed files were not changed.' }
$parentExited = $true
if ($Unattended) { Stop-UpdateCompanions $InstallDirectory }
Show-UpdateProgress 'Installing update' 'Replacing application files; keeping your personal data'
Install-Files $stage $InstallDirectory $backup $changed
$startedName = 'Local\KSwitcher.Update.Started.'+[guid]::NewGuid().ToString('N')
@@ -456,6 +513,9 @@ try {
$child = Start-Process -FilePath (Join-Path $InstallDirectory 'KSwitcher.exe') -ArgumentList "--update-ready-event=$startedName" -WorkingDirectory $InstallDirectory -WindowStyle Hidden -PassThru
if (!$started.WaitOne(30000) -or $child.HasExited) { throw 'The updated application did not confirm startup.' }
$success = $true
if ($Unattended) {
try { Write-UpdateJournal "Completed $($release.version). KSwitcher restarted." } catch {}
}
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update completed' "KSwitcher $(Get-StableVersion $release.version) is now running." $true
}
@@ -474,7 +534,10 @@ try {
}
}
Write-Result @{state='error';error=$message}
if ($ready -and $ready.WaitOne(0)) {
if ($Unattended) {
try { Write-UpdateJournal "Failed. $($message -replace '[\r\n]+',' ')" } catch {}
}
if (!$Unattended -and $ready -and $ready.WaitOne(0)) {
if ($script:progressWindow) {
$uiFinished = Complete-UpdateProgress 'Update failed' $message $false
} else {
+104 -18
View File
@@ -4,6 +4,7 @@
#include "../resources/resource.h"
#include "ocr.h"
#include "lan_clipboard_image.h"
#include "lan_files.h"
#include "configuration.h"
#include "diagnostics.h"
#include "paste_probe.h"
@@ -141,6 +142,30 @@ ULONG_PTR snippetReplayMarker()
static const char marker{};
return reinterpret_cast<ULONG_PTR>(&marker);
}
constexpr int OpenFiles = 25, FilesHotkeyFirst = 0x4B10, FilesHotkeySecond = 0x4B11;
bool activateFilesWindow()
{
HWND files{};
EnumWindows([](HWND candidate, LPARAM context) -> BOOL
{
wchar_t name[32]{};
if(!IsWindowVisible(candidate) || !GetClassNameW(candidate,name,std::size(name)) ||
wcscmp(name,L"KSwitcher.Files"))
return TRUE;
*reinterpret_cast<HWND *>(context)=candidate;
return FALSE;
},reinterpret_cast<LPARAM>(&files));
if(!files) return false;
if(IsIconic(files)) ShowWindowAsync(files,SW_RESTORE);
if(!SetForegroundWindow(files))
{
FLASHWINFO flash{sizeof(flash),files,FLASHW_TRAY,3,0};
FlashWindowEx(&flash);
}
return true;
}
std::vector<std::pair<int, std::wstring>> shortcuts(const Settings &s)
{
std::vector<std::pair<int, std::wstring>> out{{Toggle, s.toggle},
@@ -681,6 +706,7 @@ App::App(HINSTANCE h, bool test, const std::filesystem::path &dataRoot, HWND ser
}
App::~App()
{
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
if (snippetPopup) DestroyWindow(snippetPopup);
lanService.reset();
stopBrowserChatMonitor();
@@ -854,6 +880,19 @@ bool App::registerHotkeys(const Settings &s, bool rollback)
registrationWarning = L"Keyboard interception could not start.";
return false;
}
const auto files = Hotkey::parse(s.filesHotkey.value_or(L"")).value_or(Hotkey{});
if (files != registeredFilesHotkey)
{
const int next = filesHotkeyId == FilesHotkeyFirst ? FilesHotkeySecond : FilesHotkeyFirst;
if (files.key && !RegisterHotKey(window, next, files.modifiers | MOD_NOREPEAT, files.key))
{
if (!rollback) registrationWarning = L"The KSwitcher Files shortcut is unavailable. Choose another shortcut.";
return false;
}
if (filesHotkeyId) UnregisterHotKey(window, filesHotkeyId);
filesHotkeyId = files.key ? next : 0;
registeredFilesHotkey = files;
}
hookBindings = std::move(bindings);
return true;
}
@@ -1022,6 +1061,7 @@ void App::setHotkeyCapture(HWND field)
}
bool App::isHotkey(DWORD key, UINT mods)
{
if (registeredFilesHotkey.key && registeredFilesHotkey.key == key && registeredFilesHotkey.modifiers == mods) return true;
for (auto &[id, text] : shortcuts(settings))
{
auto h = Hotkey::parse(text);
@@ -1964,6 +2004,20 @@ void App::dispatch(int command)
try
{
if (command == 23) { checkUpdate(true); return; }
if (command == OpenFiles)
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
auto args = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), args.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return;
}
if (command == Screenshot)
{
if (!busy) { resetInput(); launchScreenshot(*this); }
@@ -2769,7 +2823,7 @@ void App::trayMenu()
AppendMenuW(menu, MF_STRING, 10, L"Clipboard &history");
AppendMenuW(menu, MF_STRING, 24, L"&Capture screen region…");
AppendMenuW(menu, MF_STRING, 11, L"&Settings…");
AppendMenuW(menu, MF_STRING, 25, L"&Files…");
AppendMenuW(menu, MF_STRING, OpenFiles, L"&Files…");
SetMenuDefaultItem(menu, 11, FALSE);
AppendMenuW(menu, MF_STRING, 12, L"S&nippets…");
AppendMenuW(menu, MF_STRING, 13, L"&Keyboard shortcuts…");
@@ -2822,20 +2876,9 @@ void App::trayMenu()
}
switch (cmd)
{
case 25:
{
auto path = std::filesystem::path(executable()).parent_path() / L"KSwitcher.Files.exe";
auto command = L"\"" + path.wstring() + L"\"";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
if (!CreateProcessW(path.c_str(), command.data(), nullptr, nullptr, FALSE, 0, nullptr,
path.parent_path().c_str(), &startup, &process))
throw std::runtime_error(utf8(lastError(L"Cannot start KSwitcher Files")));
AllowSetForegroundWindow(process.dwProcessId);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
case OpenFiles:
dispatch(OpenFiles);
break;
}
case 24:
dispatch(Screenshot);
break;
@@ -2899,12 +2942,12 @@ void App::trayMenu()
void App::checkUpdate(bool interactive, bool install, const std::wstring &version)
{
if (updateRunning) return;
if (install && FindWindowW(L"KSwitcher.Config", nullptr))
if (install && interactive && FindWindowW(L"KSwitcher.Config", nullptr))
{
report(L"Close the configurator before installing an update.");
return;
}
if (install && busy)
if (install && interactive && busy)
{
report(L"Wait for the current text operation to finish, then select Update again.");
return;
@@ -2964,6 +3007,22 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
}
switch (msg)
{
case LanUpdateRequestedMessage:
if (!lan::remoteUpdateAllowed()) return 0;
if (app->updateRunning)
{
if (!app->installingUpdate)
{
app->lanUpdatePending = true;
log(L"LAN update queued behind an update check.");
}
}
else
{
log(L"Starting unattended KSwitcher update requested over LAN.");
app->checkUpdate(false, true);
}
return 0;
case UpdateMessage:
{
std::unique_ptr<UpdateResult> result(reinterpret_cast<UpdateResult *>(lp));
@@ -2977,6 +3036,12 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
// The helper has verified and staged the package; it waits for process exit.
DestroyWindow(hwnd);
}
else if (std::exchange(app->lanUpdatePending, false) && lan::remoteUpdateAllowed() &&
result->state != L"current")
{
log(L"Starting queued unattended KSwitcher update.");
app->checkUpdate(false, true);
}
else if (result->state == L"available")
{
app->availableUpdate = result->version;
@@ -3015,7 +3080,11 @@ LRESULT CALLBACK App::windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
return 0;
}
case WM_HOTKEY:
app->dispatch((int)wp);
if (app->filesHotkeyId && wp == static_cast<WPARAM>(app->filesHotkeyId))
{
if(!activateFilesWindow()) app->dispatch(OpenFiles);
}
else if (wp != FilesHotkeyFirst && wp != FilesHotkeySecond) app->dispatch((int)wp);
return 0;
case MouseActionMessage:
app->dispatchMouseActions();
@@ -3341,7 +3410,24 @@ int App::execute()
}
keyboardHook = SetWindowsHookExW(WH_KEYBOARD_LL, keyProc, instance, 0);
mouseHook = SetWindowsHookExW(WH_MOUSE_LL, mouseProc, instance, 0);
registerHotkeys(settings);
const bool useDefaultFilesHotkey = !settings.filesHotkey;
if (useDefaultFilesHotkey)
{
const auto fallback = *Hotkey::parse(L"Ctrl+Alt+F");
const auto bindings = shortcuts(settings);
const bool conflict = std::any_of(bindings.begin(), bindings.end(), [&](const auto &binding)
{
return Hotkey::parse(binding.second) == fallback;
});
settings.filesHotkey = conflict ? L"" : L"Ctrl+Alt+F";
}
if (!registerHotkeys(settings) && settings.filesHotkey && !settings.filesHotkey->empty())
{
if (useDefaultFilesHotkey) settings.filesHotkey = L"";
auto fallback = settings;
fallback.filesHotkey = L"";
registerHotkeys(fallback, true);
}
if (!keyboardHook || !mouseHook)
report(L"Input tracking could not start. Automatic recent-text conversion is unavailable.");
AddClipboardFormatListener(window);
+3 -1
View File
@@ -155,7 +155,7 @@ class App
std::vector<SyncConflict> pendingSyncConflicts;
Bytes syncSnapshot;
std::wstring syncStatus = L"Not connected.";
bool updateRunning{}, installingUpdate{};
bool updateRunning{}, installingUpdate{}, lanUpdatePending{};
std::wstring availableUpdate, notifiedUpdate;
bool (*updateNotifier)(const std::wstring &){};
bool stop{};
@@ -169,6 +169,8 @@ class App
HWND previousAttemptWindow{};
int previousAttemptCommand{}, repeatAttempts{};
std::vector<std::pair<int, Hotkey>> hookBindings;
Hotkey registeredFilesHotkey{};
int filesHotkeyId{};
HotkeyHook hotkeyHook;
ChatSwitchHook chatSwitchHook;
HWND chatWindow{};
+37 -6
View File
@@ -1,19 +1,28 @@
#include "configuration.h"
#include "settings_merge.h"
#include "../vendor/json.hpp"
#include <cstring>
namespace ks
{
bool configurationPeer(HWND window, const wchar_t *filename)
namespace
{
std::filesystem::path processImage(HWND window)
{
DWORD pid{};
GetWindowThreadProcessId(window, &pid);
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (!process) return false;
if (!process) return {};
wchar_t path[32768]; DWORD count = std::size(path);
bool ok = QueryFullProcessImageNameW(process, 0, path, &count) != 0;
CloseHandle(process);
auto expected = std::filesystem::path(executable()).parent_path() / filename;
return ok && _wcsicmp(path, expected.c_str()) == 0;
return ok ? std::filesystem::path(path) : std::filesystem::path{};
}
}
bool configurationPeer(HWND window, const wchar_t *filename)
{
const auto expected = std::filesystem::path(executable()).parent_path() / filename;
return _wcsicmp(processImage(window).c_str(), expected.c_str()) == 0;
}
void launchConfiguration(App &app, bool snippets, bool logs)
{
@@ -33,7 +42,11 @@ void launchConfiguration(App &app, bool snippets, bool logs)
}
LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &message)
{
if (!configurationPeer(sender, L"KSwitcher.Config.exe") || message.cbData > ConfigLimit ||
const bool filesPeers = message.dwData == FilesLanPeers;
const bool knownSender = filesPeers
? isFilesPeerImage(processImage(sender), std::filesystem::path(executable()).parent_path())
: configurationPeer(sender, L"KSwitcher.Config.exe");
if (!knownSender || message.cbData > ConfigLimit ||
(message.cbData && !message.lpData)) return FALSE;
Bytes reply;
bool ok = false;
@@ -41,6 +54,23 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
{
switch (message.dwData)
{
case FilesLanPeers:
{
if(message.cbData) return FALSE;
const std::string request=R"({"action":"filesPeers"})";
const auto snapshot=nlohmann::json::parse(app.lanCommand(Bytes(request.begin(),request.end())));
auto paired=nlohmann::json::array();
for(const auto &peer:snapshot.at("peers"))
{
if(!peer.value("paired",false)) continue;
const auto name=wide(peer.at("name").get<std::string>());
if(name.empty() || name.find_first_of(std::wstring_view(L"\0\r\n\t",4))!=name.npos) continue;
paired.push_back({{"id",peer.at("id")},{"name",peer.at("name")},{"address",peer.at("address")},{"filesBrowse",peer.value("filesBrowse",false)}});
}
const auto encoded=paired.dump();
reply.assign(encoded.begin(),encoded.end());
break;
}
case ConfigLan:
{
if (!message.cbData || message.cbData > 4096) throw std::runtime_error("Invalid LAN request.");
@@ -121,7 +151,8 @@ LRESULT configurationRequest(App &app, HWND sender, const COPYDATASTRUCT &messag
std::string text = error.what();
reply.assign(text.begin(), text.end());
}
COPYDATASTRUCT response{ConfigReply, (DWORD)reply.size(), reply.data()};
if(filesPeers && !ok) return FALSE;
COPYDATASTRUCT response{filesPeers?FilesLanPeersReply:ConfigReply, (DWORD)reply.size(), reply.data()};
DWORD_PTR received{};
if (!SendMessageTimeoutW(sender, WM_COPYDATA, (WPARAM)app.window, (LPARAM)&response,
SMTO_ABORTIFHUNG, 2000, &received)) return FALSE;
+2 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include "app.h"
#include "files_lan_ipc.h"
namespace ks
{
inline constexpr UINT ConfigSettingsDownloaded = WM_APP + 24;
@@ -8,7 +9,7 @@ inline constexpr ULONG_PTR ConfigRead = 0x4b530100, ConfigSave = ConfigRead + 1,
ConfigCapture = ConfigRead + 4, ConfigLogs = ConfigRead + 5,
ConfigReply = ConfigRead + 6, ConfigClearLog = ConfigRead + 7,
ConfigSync = ConfigRead + 8, ConfigSyncConflicts = ConfigRead + 9,
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = ConfigRead + 13;
ConfigSyncResolve = ConfigRead + 10, ConfigLan = ConfigRead + 11, ConfigSaveEdited = ConfigRead + 12, ConfigEnd = FilesLanPeers + 1;
inline constexpr size_t ConfigLimit = 8 * 1024 * 1024;
bool configurationPeer(HWND, const wchar_t *filename);
void launchConfiguration(App &, bool snippets = false, bool logs = false);
+1
View File
@@ -97,6 +97,7 @@ struct Settings
std::wstring toggle = L"Ctrl+Alt+Shift+Space", casing = L"Ctrl+Alt+Shift+C",
translit = L"Ctrl+Alt+Shift+T", undo = L"Ctrl+Alt+Shift+Z", clipboard = L"Win+V",
screenshot = L"Win+Shift+S";
std::optional<std::wstring> filesHotkey;
std::vector<std::wstring> cycle, direct, directIds;
// Exact application + visible chat name; an absent entry uses the global cycle.
std::map<std::wstring, std::vector<std::wstring>> chatCycles;
+104
View File
@@ -0,0 +1,104 @@
constexpr std::array<WORD,2> defaultHistoryKeys{MAKEWORD('E',HOTKEYF_CONTROL),MAKEWORD(VK_F8,HOTKEYF_ALT)};
auto commandHistoryKeys=defaultHistoryKeys;
constexpr size_t commandHistoryLimit=1000;
bool validHistoryKeys(const std::array<WORD,2> &keys)
{
if(keys[0]==keys[1]) return false;
return std::all_of(keys.begin(),keys.end(),[](WORD binding)
{
const auto key=LOBYTE(binding), modifiers=HIBYTE(binding);
const bool letter=key>='A' && key<='Z';
const bool function=key>=VK_F1 && key<=VK_F12;
if(!letter && !function) return false;
if(modifiers==HOTKEYF_CONTROL)
return (letter && key!='C' && key!='F' && key!='V' && key!='X' && key!='A' && key!='Z' && key!='Y') || function;
if(modifiers==HOTKEYF_ALT) return function && key!=VK_F1 && key!=VK_F2 && key!=VK_F4 && key!=VK_F10;
return false;
});
}
std::array<WORD,2> optionHistoryKeys(HWND dialog)
{
return {static_cast<WORD>(SendDlgItemMessageW(dialog,321,HKM_GETHOTKEY,0,0)),
static_cast<WORD>(SendDlgItemMessageW(dialog,323,HKM_GETHOTKEY,0,0))};
}
void setOptionHistoryKeys(HWND dialog,const std::array<WORD,2> &keys)
{
SendDlgItemMessageW(dialog,321,HKM_SETHOTKEY,keys[0],0);
SendDlgItemMessageW(dialog,323,HKM_SETHOTKEY,keys[1],0);
}
void loadCommandHistoryKeys()
{
const std::array<WORD,2> keys{static_cast<WORD>(preference(L"PreviousCommandKey",defaultHistoryKeys[0])),
static_cast<WORD>(preference(L"CommandHistoryKey",defaultHistoryKeys[1]))};
commandHistoryKeys=validHistoryKeys(keys)?keys:defaultHistoryKeys;
}
void loadCommandHistory()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)
!=ERROR_SUCCESS || bytes>16*1024*1024 || bytes%sizeof(wchar_t)) return;
std::wstring data(bytes/sizeof(wchar_t),L'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)
!=ERROR_SUCCESS) return;
size_t start{};
while(start<data.size())
{
const auto dateEnd=data.find(L'\0',start);
if(dateEnd==std::wstring::npos) break;
const auto commandEnd=data.find(L'\0',dateEnd+1);
if(commandEnd==std::wstring::npos) break;
terminalHistory.push_back({data.substr(start,dateEnd-start),data.substr(dateEnd+1,commandEnd-dateEnd-1)});
start=commandEnd+1;
}
terminalHistoryIndex=terminalHistory.size();
}
void rememberCommand(const std::wstring &command)
{
terminalHistory.push_back({ks::files::historyDate(),command});
if(terminalHistory.size()>commandHistoryLimit) terminalHistory.erase(terminalHistory.begin());
std::wstring data;
for(const auto &entry:terminalHistory)
{
data.append(entry.date).push_back(L'\0');
data.append(entry.command).push_back(L'\0');
}
if(RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"CommandHistory",REG_BINARY,data.data(),
static_cast<DWORD>(data.size()*sizeof(wchar_t)))!=ERROR_SUCCESS)
setStatus(L"Could not save command history.");
refreshCommandHistory();
}
void recallCommand(size_t index)
{
terminalHistoryIndex=index;
SetWindowTextW(terminalInput,index<terminalHistory.size()?terminalHistory[index].command.c_str():L"");
SetFocus(terminalInput);
SendMessageW(terminalInput,EM_SETSEL,-2,-1);
}
void showCommandHistory(HWND owner);
bool handleCommandHistoryKey(HWND window,const MSG &message)
{
if(message.message!=WM_KEYDOWN && message.message!=WM_SYSKEYDOWN) return false;
if((GetKeyState(VK_LWIN)&0x8000) || (GetKeyState(VK_RWIN)&0x8000)) return false;
const BYTE modifiers=((GetKeyState(VK_CONTROL)&0x8000)?HOTKEYF_CONTROL:0) |
((GetKeyState(VK_MENU)&0x8000)?HOTKEYF_ALT:0) | ((GetKeyState(VK_SHIFT)&0x8000)?HOTKEYF_SHIFT:0);
const auto binding=MAKEWORD(message.wParam,modifiers);
if(binding==commandHistoryKeys[0] && (message.hwnd==terminalInput ||
message.hwnd==panels[0].list || message.hwnd==panels[1].list))
{
if(terminalHistoryIndex) recallCommand(terminalHistoryIndex-1);
return true;
}
if(binding!=commandHistoryKeys[1]) return false;
if(!(message.lParam&(1LL<<30))) showCommandHistory(window);
return true;
}
+328
View File
@@ -0,0 +1,328 @@
constexpr int historyList=324;
constexpr int historySearch=325;
constexpr COLORREF historySearchSurface=RGB(132,177,177);
constexpr int historySearchLabel=326;
HWND commandHistoryWindow{};
std::wstring historyLower(std::wstring value)
{
if(!value.empty()) CharLowerBuffW(value.data(),static_cast<DWORD>(value.size()));
return value;
}
std::wstring_view commandTopicKeywords(const std::wstring &command)
{
const auto start=command.find_first_not_of(L" \t");
if(start==command.npos) return L"";
const auto end=command.find_first_of(L" \t",start);
auto tool=command.substr(start,end-start);
if(tool.ends_with(L".exe")) tool.resize(tool.size()-4);
const auto args=end==command.npos?L"":command.substr(end);
if(tool==L"ipconfig" && args.find(L"/flushdns")!=args.npos)
return L"network tools мережа dns flush clear cache очистити кеш";
if(tool==L"nslookup" || tool==L"resolve-dnsname")
return L"network tools мережа dns lookup запит";
if(tool==L"ping" || tool==L"tracert" || tool==L"ipconfig" || tool==L"test-netconnection" ||
tool==L"netstat" || tool==L"curl" || tool==L"invoke-webrequest")
return L"network tools мережа з’єднання connectivity address адреса";
if(tool==L"cls" || tool==L"clear" || tool==L"clear-host")
return L"terminal clear screen очистити екран консоль";
if(tool==L"git")
{
const auto first=args.find_first_not_of(L" \t");
const auto action=first==args.npos?L"":args.substr(first,args.find_first_of(L" \t",first)-first);
if(action==L"pull" || action==L"fetch" || action==L"update")
return L"git version control repository update fetch pull оновити репозиторій синхронізація";
return L"git version control repository репозиторій контроль версій";
}
return L"";
}
void populateCommandHistory(HWND dialog)
{
const auto search=GetDlgItem(dialog,historySearch);
std::wstring query(GetWindowTextLengthW(search)+1,L'\0');
query.resize(GetWindowTextW(search,query.data(),static_cast<int>(query.size())));
query=historyLower(std::move(query));
std::wistringstream stream(query);
std::vector<std::wstring> words;
for(std::wstring word;stream>>word;) words.push_back(word);
const auto list=GetDlgItem(dialog,historyList);
SendMessageW(list,WM_SETREDRAW,FALSE,0);
SendMessageW(list,LB_RESETCONTENT,0,0);
const auto add=[&](const std::wstring &label,LPARAM index)
{
const auto row=SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
if(row>=0) SendMessageW(list,LB_SETITEMDATA,row,index);
return row;
};
std::array<std::vector<size_t>,2> matches;
std::set<std::wstring_view> seen;
for(size_t index=terminalHistory.size();index-- > 0;)
{
const auto &entry=terminalHistory[index];
if(!seen.insert(entry.command).second) continue;
const auto command=historyLower(entry.command);
const auto containsAll=[&](const std::wstring &text)
{
return std::all_of(words.begin(),words.end(),[&](const auto &word){return text.find(word)!=text.npos;});
};
if(containsAll(command+L" "+entry.date))
{
matches[0].push_back(index);
continue;
}
const auto keywords=commandTopicKeywords(command);
if(!keywords.empty() && containsAll(command+L" "+entry.date+L" "+std::wstring(keywords)))
matches[1].push_back(index);
}
LRESULT selected=LB_ERR;
for(const auto &group:matches)
{
std::wstring lastDate;
bool first=true;
for(auto match=group.rbegin();match!=group.rend();++match)
{
const auto index=*match;
const auto &entry=terminalHistory[index];
const auto &date=entry.date;
if(first || date!=lastDate) add(date,-1);
first=false;
lastDate=date;
const auto row=add(entry.command,index);
if(row>=0 && (words.empty() || selected==LB_ERR)) selected=row;
}
}
if(selected==LB_ERR) add(words.empty()?L"No commands yet":L"No matching commands",-2);
else SendMessageW(list,LB_SETCURSEL,selected,0);
SendMessageW(list,WM_SETREDRAW,TRUE,0);
InvalidateRect(list,nullptr,TRUE);
}
LRESULT CALLBACK historySearchKeys(HWND search,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
{
if(message==WM_GETDLGCODE)
{
const auto code=DefSubclassProc(search,message,key,flags);
if(key==VK_TAB || key==VK_ESCAPE) return code&~DLGC_WANTALLKEYS;
if(key==VK_RETURN) return code|DLGC_WANTMESSAGE;
return code|DLGC_WANTARROWS;
}
if(message==WM_KEYDOWN && key==VK_RETURN)
{
SendMessageW(GetParent(search),WM_COMMAND,IDOK,0);
return 0;
}
if(message==WM_KEYDOWN && (key==VK_UP || key==VK_DOWN))
{
const auto list=GetDlgItem(GetParent(search),historyList);
SetFocus(list);
SendMessageW(list,message,key,flags);
return 0;
}
return DefSubclassProc(search,message,key,flags);
}
int historyRowInset(UINT dpi)
{
return std::max(MulDiv(4,dpi,96),dialogFrameMetrics(dpi).inner)+dialogRowTextInset(cellWidth);
}
LRESULT CALLBACK historyListScroll(HWND list,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(message==WM_GETDLGCODE && wp==VK_RETURN)
return DefSubclassProc(list,message,wp,lp)|DLGC_WANTMESSAGE;
if(message==WM_KEYDOWN && wp==VK_RETURN)
{
SendMessageW(GetParent(list),WM_COMMAND,IDOK,0);
return 0;
}
if(message==LB_GETTOPINDEX || message==WM_NCDESTROY)
return DefSubclassProc(list,message,wp,lp);
const auto top=SendMessageW(list,LB_GETTOPINDEX,0,0);
const auto result=scrollList(list,message,wp,lp);
if(top!=SendMessageW(list,LB_GETTOPINDEX,0,0))
InvalidateRect(GetParent(list),nullptr,FALSE);
return result;
}
void sizeCommandHistory(HWND dialog,HWND list)
{
const auto owner=GetWindow(dialog,GW_OWNER);
RECT ownerBounds{};
if(!GetWindowRect(owner,&ownerBounds) || IsIconic(owner)) return;
MONITORINFO monitor{sizeof(monitor)};
if(!GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor)) return;
RECT area{};
if(!IntersectRect(&area,&ownerBounds,&monitor.rcWork)) area=monitor.rcWork;
const auto dc=GetDC(list);
if(!dc) return;
const auto previous=SelectObject(dc,mono);
LONG longest{};
for(const auto &entry:terminalHistory)
{
SIZE size{};
if(GetTextExtentPoint32W(dc,entry.command.c_str(),static_cast<int>(entry.command.size()),&size))
longest=std::max(longest,size.cx);
}
SelectObject(dc,previous);
ReleaseDC(list,dc);
const auto dpi=GetDpiForWindow(dialog);
const auto frame=dialogFrameMetrics(dpi);
const int inset=frame.content();
const int top=dialogListInset(frame,textHeight);
const int gap=MulDiv(4,dpi,96);
const int listTop=top+2*textHeight+2*gap;
const LONG desired=std::max<LONG>(MulDiv(320,dpi,96),
longest+2*cellWidth+2*historyRowInset(dpi)+GetSystemMetricsForDpi(SM_CXVSCROLL,dpi))+2*inset;
const auto target=std::min(desired,area.right-area.left);
const auto rows=std::max<LRESULT>(1,SendMessageW(list,LB_GETCOUNT,0,0));
RECT listWindow{},listClient{};
GetWindowRect(list,&listWindow);
GetClientRect(list,&listClient);
const auto listBorder=listWindow.bottom-listWindow.top-listClient.bottom;
const auto height=std::min<LONG>(listTop+rows*textHeight+listBorder+inset,
area.bottom-area.top);
const auto left=std::clamp((ownerBounds.left+ownerBounds.right-target)/2,area.left,area.right-target);
const auto y=std::clamp((ownerBounds.top+ownerBounds.bottom-height)/2,area.top,area.bottom-height);
SetWindowPos(dialog,nullptr,left,y,target,height,SWP_NOZORDER|SWP_NOACTIVATE);
const auto place=[&](HWND control,int rowTop,int rowHeight)
{
SetWindowPos(control,nullptr,inset,rowTop,std::max(1L,target-2*inset),rowHeight,
SWP_NOZORDER|SWP_NOACTIVATE);
};
place(GetDlgItem(dialog,historySearchLabel),top,textHeight);
place(GetDlgItem(dialog,historySearch),top+textHeight+gap,textHeight);
SetWindowPos(list,nullptr,inset,listTop,std::max(1L,target-2*inset-5),
std::max(1L,height-listTop-inset),SWP_NOZORDER|SWP_NOACTIVATE);
GetClientRect(list,&listClient);
if(rows*textHeight<=listClient.bottom) SendMessageW(list,LB_SETTOPINDEX,0,0);
InvalidateRect(dialog,nullptr,FALSE);
FilesModalDialog::refreshLayout(dialog);
}
void layoutOpenCommandHistory()
{
if(commandHistoryWindow)
sizeCommandHistory(commandHistoryWindow,GetDlgItem(commandHistoryWindow,historyList));
}
INT_PTR CALLBACK commandHistoryDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
commandHistoryWindow=dialog;
SetWindowLongPtrW(dialog,DWLP_USER,lp);
prepareCopyDialog(dialog);
const auto list=GetDlgItem(dialog,historyList);
SetWindowSubclass(list,historyListScroll,1,0);
SendMessageW(list,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
SendMessageW(list,LB_SETITEMHEIGHT,0,std::max(1,textHeight));
const auto search=GetDlgItem(dialog,historySearch);
SendDlgItemMessageW(dialog,historySearchLabel,WM_SETFONT,reinterpret_cast<WPARAM>(mono),TRUE);
SetWindowSubclass(search,historySearchKeys,1,0);
populateCommandHistory(dialog);
sizeCommandHistory(dialog,list);
SetFocus(search);
return FALSE;
}
if(message==WM_COMMAND && LOWORD(wp)==historySearch && HIWORD(wp)==EN_CHANGE)
{
populateCommandHistory(dialog);
sizeCommandHistory(dialog,GetDlgItem(dialog,historyList));
return TRUE;
}
if(message==WM_NCDESTROY && commandHistoryWindow==dialog)
commandHistoryWindow=nullptr;
if(message==WM_CTLCOLORLISTBOX || message==WM_CTLCOLOREDIT)
{
const auto dc=reinterpret_cast<HDC>(wp);
const bool search=message==WM_CTLCOLOREDIT;
SetTextColor(dc,search?RGB(0,0,0):colors[Text]);
const auto background=search?historySearchSurface:colors[Dialog];
SetBkColor(dc,background);
if(search) SetDCBrushColor(dc,background);
return reinterpret_cast<INT_PTR>(search?GetStockObject(DC_BRUSH):teal);
}
if(message!=WM_DRAWITEM || wp!=historyList)
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" History ",false,{},true,
GetDlgItem(dialog,historyList)))
return painted;
if(message==WM_DRAWITEM && wp==historyList)
{
const auto &item=*reinterpret_cast<DRAWITEMSTRUCT*>(lp);
if(item.itemID==UINT(-1)) return TRUE;
const auto count=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
if(count<0) return TRUE;
std::wstring label(static_cast<size_t>(count)+1,L'\0');
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
label.resize(count);
const bool selected=(item.itemState&ODS_SELECTED)!=0 && item.itemData<terminalHistory.size();
const auto background=colors[selected?Selection:Dialog];
const auto ink=colors[Text];
if(item.itemData==static_cast<ULONG_PTR>(-1))
{
auto bounds=item.rcItem;
InflateRect(&bounds,-historyRowInset(GetDpiForWindow(dialog)),0);
const auto saved=SaveDC(item.hDC);
SelectObject(item.hDC,mono);
SetBkColor(item.hDC,background);
SetTextColor(item.hDC,ink);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&bounds,nullptr,0,nullptr);
const auto middle=(bounds.top+bounds.bottom)/2;
RECT rule{bounds.left,middle,bounds.right,middle+1};
SetBkColor(item.hDC,ink);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&rule,nullptr,0,nullptr);
SIZE size{};
GetTextExtentPoint32W(item.hDC,label.c_str(),static_cast<int>(label.size()),&size);
const auto left=(bounds.left+bounds.right-size.cx)/2;
RECT caption{left-cellWidth,bounds.top,left+size.cx+cellWidth,bounds.bottom};
SetBkColor(item.hDC,background);
ExtTextOutW(item.hDC,left,middle-size.cy/2,ETO_OPAQUE,&caption,label.c_str(),
static_cast<UINT>(label.size()),nullptr);
RestoreDC(item.hDC,saved);
}
else paintDialogListRow(item.hDC,item.rcItem,label,mono,ink,background,
historyRowInset(GetDpiForWindow(dialog)),DT_END_ELLIPSIS);
if(selected && (item.itemState&ODS_FOCUS)) DrawFocusRect(item.hDC,&item.rcItem);
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
FilesModalDialog::finish(dialog,IDCANCEL);
return TRUE;
}
if(message==WM_COMMAND && (LOWORD(wp)==IDOK || (LOWORD(wp)==historyList && HIWORD(wp)==LBN_DBLCLK)))
{
const auto row=SendDlgItemMessageW(dialog,historyList,LB_GETCURSEL,0,0);
const auto index=SendDlgItemMessageW(dialog,historyList,LB_GETITEMDATA,row,0);
if(index>=0 && static_cast<size_t>(index)<terminalHistory.size())
{
auto &choice=*reinterpret_cast<size_t *>(GetWindowLongPtrW(dialog,DWLP_USER));
choice=static_cast<size_t>(index);
FilesModalDialog::finish(dialog,IDOK);
}
return TRUE;
}
return FALSE;
}
void refreshCommandHistory()
{
if(commandHistoryWindow) populateCommandHistory(commandHistoryWindow);
layoutOpenCommandHistory();
}
void showCommandHistory(HWND owner)
{
if(commandHistoryWindow) return;
size_t choice{};
if(FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(111),owner,commandHistoryDialog,
reinterpret_cast<LPARAM>(&choice))==IDOK)
recallCommand(choice);
}
+330
View File
@@ -0,0 +1,330 @@
HWND activeFilesDialog{};
HHOOK filesDialogMouseHook{};
bool filesMouseDown(UINT message)
{
return message==WM_LBUTTONDOWN || message==WM_RBUTTONDOWN ||
message==WM_MBUTTONDOWN || message==WM_XBUTTONDOWN;
}
bool handleFilesDialogClick(HWND dialog,HWND clicked,POINT point)
{
if(!clicked || clicked==dialog || IsChild(dialog,clicked) ||
GetAncestor(clicked,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER))
return false;
wchar_t windowClass[16]{};
if(GetClassNameW(clicked,windowClass,16) && std::wstring_view(windowClass)==L"#32768")
{
GUITHREADINFO thread{sizeof(thread)};
if(GetGUIThreadInfo(GetWindowThreadProcessId(dialog,nullptr),&thread) &&
(thread.hwndMenuOwner==dialog || IsChild(dialog,thread.hwndMenuOwner)))
return false;
}
// System pickers and dropdowns owned by this dialog handle their own input.
for(auto popup=GetAncestor(clicked,GA_ROOT);popup;popup=GetWindow(popup,GW_OWNER))
if(popup==dialog || IsChild(dialog,popup)) return false;
for(auto owner=GetWindow(dialog,GW_OWNER);owner;owner=GetWindow(owner,GW_OWNER))
{
if(clicked!=owner) continue;
RECT client{};
GetClientRect(owner,&client);
ScreenToClient(owner,&point);
if(!PtInRect(&client,point)) return false;
break;
}
if(const auto dropdown=static_cast<HWND>(GetPropW(dialog,ks::files::farDropDownProperty)))
return PostMessageW(dropdown,WM_CLOSE,0,0)!=FALSE;
return PostMessageW(dialog,WM_COMMAND,IDCANCEL,0)!=FALSE;
}
LRESULT CALLBACK filesDialogMouse(int code,WPARAM message,LPARAM data)
{
const auto dialog=activeFilesDialog;
if(code==HC_ACTION && dialog && IsWindowVisible(dialog) && filesMouseDown(static_cast<UINT>(message)))
{
const auto point=reinterpret_cast<const MSLLHOOKSTRUCT*>(data)->pt;
if(handleFilesDialogClick(dialog,WindowFromPoint(point),point)) return 1;
}
return CallNextHookEx(nullptr,code,message,data);
}
class FilesModalDialog
{
public:
FilesModalDialog(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
: owner_(owner),focus_(GetFocus()),procedure_(procedure),argument_(argument)
{
window_=CreateDialogParamW(instance,resource,owner,dispatchDialog,reinterpret_cast<LPARAM>(this));
}
~FilesModalDialog() { close(); }
FilesModalDialog(const FilesModalDialog &)=delete;
FilesModalDialog &operator=(const FilesModalDialog &)=delete;
HWND window() const { return window_; }
static HWND activeFor(HWND owner)
{
const auto root=GetAncestor(owner,GA_ROOTOWNER);
for(auto dialog=activeFilesDialog;dialog;)
{
if(GetAncestor(dialog,GA_ROOTOWNER)==root) return dialog;
const auto *state=reinterpret_cast<const FilesModalDialog *>(GetPropW(dialog,stateProperty_));
dialog=state?state->previous_:nullptr;
}
return nullptr;
}
static void fitWidthToOwner(HWND dialog,int minimum,void (*layout)(HWND))
{
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
RECT bounds{};
if(!state || !GetWindowRect(dialog,&bounds)) return;
state->preferredWidth_=bounds.right-bounds.left;
state->minimumWidth_=minimum;
state->layout_=layout;
}
static void refreshLayout(HWND dialog)
{
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
if(!state || !state->entered_ || state->layout_) return;
state->captureLayout();
state->fitWidth(dialog,state->ownerBounds_);
}
static bool dismissOwned(HWND owner)
{
const auto dialog=activeFor(owner);
for(auto parent=GetWindow(dialog,GW_OWNER);parent;parent=GetWindow(parent,GW_OWNER))
if(parent==owner) return PostMessageW(dialog,WM_CLOSE,0,0)!=FALSE;
return false;
}
void show()
{
if(!window_ || entered_) return;
previous_=activeFilesDialog;
activeFilesDialog=window_;
entered_=true;
if(!filesDialogMouseHook)
filesDialogMouseHook=SetWindowsHookExW(WH_MOUSE_LL,filesDialogMouse,GetModuleHandleW(nullptr),0);
if(GetWindowRect(owner_,&ownerBounds_))
{
if(!layout_) captureLayout();
SetWindowSubclass(owner_,trackOwner,reinterpret_cast<UINT_PTR>(this),reinterpret_cast<DWORD_PTR>(this));
if(ownerBounds_.right-ownerBounds_.left<minimumOwnerWidth() && !IsZoomed(owner_))
SetWindowPos(owner_,nullptr,0,0,minimumOwnerWidth(),ownerBounds_.bottom-ownerBounds_.top,
SWP_NOMOVE|SWP_NOZORDER|SWP_NOACTIVATE);
fitWidth(window_,ownerBounds_,true);
}
ShowWindow(window_,SW_SHOW);
SetActiveWindow(window_);
}
void close()
{
if(entered_ && IsWindow(owner_))
RemoveWindowSubclass(owner_,trackOwner,reinterpret_cast<UINT_PTR>(this));
if(window_ && IsWindow(window_)) DestroyWindow(window_);
window_=nullptr;
if(!entered_) return;
entered_=false;
activeFilesDialog=previous_;
if(!previous_ && filesDialogMouseHook)
{
UnhookWindowsHookEx(filesDialogMouseHook);
filesDialogMouseHook=nullptr;
}
if(IsWindow(owner_) && !IsIconic(GetAncestor(owner_,GA_ROOTOWNER)))
{
SetActiveWindow(owner_);
if(IsWindow(focus_) && IsWindowEnabled(focus_) && (focus_==owner_ || IsChild(owner_,focus_)))
SetFocus(focus_);
}
}
static bool handleMessage(HWND dialog,MSG &message)
{
if(message.hwnd==dialog || IsChild(dialog,message.hwnd))
{
if(message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)
{
SendMessageW(dialog,WM_COMMAND,IDCANCEL,0);
return true;
}
return IsDialogMessageW(dialog,&message)!=FALSE;
}
if(!message.hwnd || GetAncestor(message.hwnd,GA_ROOTOWNER)!=GetAncestor(dialog,GA_ROOTOWNER))
return false;
const bool input=(message.message>=WM_KEYFIRST && message.message<=WM_KEYLAST) ||
(message.message>=WM_MOUSEFIRST && message.message<=WM_MOUSELAST) ||
message.message==WM_CONTEXTMENU;
if(input && (filesMouseDown(message.message) ||
(message.message==WM_KEYDOWN && message.wParam==VK_ESCAPE)))
PostMessageW(dialog,WM_COMMAND,IDCANCEL,0);
return input;
}
static void finish(HWND dialog,INT_PTR answer)
{
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
if(!state) return;
state->result_=answer;
state->finished_=true;
ShowWindow(dialog,SW_HIDE);
}
static INT_PTR run(HINSTANCE instance,LPCWSTR resource,HWND owner,DLGPROC procedure,LPARAM argument)
{
FilesModalDialog dialog(instance,resource,owner,procedure,argument);
if(!dialog.window()) return -1;
dialog.show();
MSG message{};
while(!dialog.finished_ && IsWindow(dialog.window()))
{
const auto status=GetMessageW(&message,nullptr,0,0);
if(status<=0)
{
if(status==0) PostQuitMessage(static_cast<int>(message.wParam));
break;
}
if(handleMessage(dialog.window(),message)) continue;
TranslateMessage(&message);
DispatchMessageW(&message);
}
return dialog.result_;
}
private:
static constexpr wchar_t stateProperty_[]=L"KSwitcher.Files.ModalDialog";
HWND window_{},owner_{},focus_{},previous_{};
RECT ownerBounds_{};
int preferredWidth_{},minimumWidth_{};
std::vector<std::pair<HWND,RECT>> controls_;
void (*layout_)(HWND){};
DLGPROC procedure_;
LPARAM argument_;
INT_PTR result_=IDCANCEL;
bool entered_{},finished_{};
void captureLayout()
{
RECT bounds{};
GetWindowRect(window_,&bounds);
preferredWidth_=bounds.right-bounds.left;
minimumWidth_=MulDiv(preferredWidth_,3,4);
controls_.clear();
if(GetWindowLongPtrW(window_,GWL_STYLE)&WS_THICKFRAME)
{
MINMAXINFO limits{};
SendMessageW(window_,WM_GETMINMAXINFO,0,reinterpret_cast<LPARAM>(&limits));
minimumWidth_=std::max<int>(minimumWidth_,limits.ptMinTrackSize.x);
return;
}
const auto dc=GetDC(window_);
if(!dc) return;
for(auto field=GetWindow(window_,GW_CHILD);field;field=GetWindow(field,GW_HWNDNEXT))
{
RECT rect{};
GetWindowRect(field,&rect);
MapWindowPoints(nullptr,window_,reinterpret_cast<POINT *>(&rect),2);
wchar_t type[16]{},label[128]{};
GetClassNameW(field,type,16);
RECT dropdown{};
if(lstrcmpiW(type,L"ComboBox")==0 &&
SendMessageW(field,CB_GETDROPPEDCONTROLRECT,0,reinterpret_cast<LPARAM>(&dropdown)))
rect.bottom=rect.top+dropdown.bottom-dropdown.top;
controls_.emplace_back(field,rect);
const auto fieldWidth=rect.right-rect.left;
if(!(GetWindowLongPtrW(field,GWL_STYLE)&WS_VISIBLE) || fieldWidth<=0) continue;
if(lstrcmpiW(type,L"Button")!=0 || !GetWindowTextW(field,label,128)) continue;
const auto font=reinterpret_cast<HFONT>(SendMessageW(field,WM_GETFONT,0,0));
const auto previous=font?SelectObject(dc,font):nullptr;
RECT text{};
DrawTextW(dc,label,-1,&text,DT_SINGLELINE|DT_CALCRECT);
TEXTMETRICW metrics{};
GetTextMetricsW(dc,&metrics);
if(previous) SelectObject(dc,previous);
const auto needed=std::min(fieldWidth,text.right+4*metrics.tmAveCharWidth);
minimumWidth_=std::max(minimumWidth_,static_cast<int>(
(preferredWidth_*needed+fieldWidth-1)/fieldWidth));
}
ReleaseDC(window_,dc);
}
int ownerMargin() const { return MulDiv(12,GetDpiForWindow(owner_),96); }
int minimumOwnerWidth() const { return minimumWidth_+2*ownerMargin(); }
void fitWidth(HWND window,const RECT &owner,bool initial=false)
{
RECT bounds{};
if(!GetWindowRect(window,&bounds)) return;
const int width=std::clamp(static_cast<int>(owner.right-owner.left)-2*ownerMargin(),
minimumWidth_,std::max(minimumWidth_,preferredWidth_));
const int height=bounds.bottom-bounds.top;
const int x=(owner.left+owner.right-width)/2;
const int y=(owner.top+owner.bottom-height)/2;
const bool resized=width!=bounds.right-bounds.left;
if(x!=bounds.left || y!=bounds.top || resized)
SetWindowPos(window,nullptr,x,y,width,height,SWP_NOZORDER|SWP_NOACTIVATE|
(resized?SWP_NOCOPYBITS:0));
if(layout_ && (initial || resized)) layout_(window);
else if(resized && preferredWidth_>0)
for(const auto &[field,rect]:controls_)
{
const auto left=MulDiv(rect.left,width,preferredWidth_);
const auto right=MulDiv(rect.right,width,preferredWidth_);
SetWindowPos(field,nullptr,left,rect.top,right-left,rect.bottom-rect.top,
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS);
}
if(initial || resized) RedrawWindow(window,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
}
static LRESULT CALLBACK trackOwner(HWND owner,UINT message,WPARAM wp,LPARAM lp,UINT_PTR id,DWORD_PTR data)
{
auto &dialog=*reinterpret_cast<FilesModalDialog *>(data);
if(message==WM_WINDOWPOSCHANGING && !IsIconic(owner))
{
auto &position=*reinterpret_cast<WINDOWPOS *>(lp);
if(!(position.flags&SWP_NOSIZE) && position.cx>0)
position.cx=std::max(position.cx,dialog.minimumOwnerWidth());
}
if(message==WM_NCDESTROY) RemoveWindowSubclass(owner,trackOwner,id);
const auto result=DefSubclassProc(owner,message,wp,lp);
if(message==WM_GETMINMAXINFO)
{
auto &limits=*reinterpret_cast<MINMAXINFO *>(lp);
limits.ptMinTrackSize.x=std::max<LONG>(limits.ptMinTrackSize.x,dialog.minimumOwnerWidth());
return result;
}
if((message!=WM_MOVE && message!=WM_SIZE) || IsIconic(owner)) return result;
RECT ownerBounds{};
if(!GetWindowRect(owner,&ownerBounds)) return result;
if(EqualRect(&ownerBounds,&dialog.ownerBounds_)) return result;
dialog.ownerBounds_=ownerBounds;
dialog.fitWidth(dialog.window_,ownerBounds);
return result;
}
static INT_PTR CALLBACK dispatchDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
auto *state=reinterpret_cast<FilesModalDialog *>(GetPropW(dialog,stateProperty_));
if(message==WM_INITDIALOG)
{
state=reinterpret_cast<FilesModalDialog *>(lp);
if(!SetPropW(dialog,stateProperty_,state)) return FALSE;
lp=state->argument_;
}
if(!state) return FALSE;
const auto handled=state->procedure_(dialog,message,wp,lp);
if(message==WM_NCDESTROY) RemovePropW(dialog,stateProperty_);
if(!handled && message==WM_CLOSE)
{
SendMessageW(dialog,WM_COMMAND,IDCANCEL,0);
return TRUE;
}
return handled;
}
};
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <windows.h>
#include <algorithm>
namespace ks::files
{
inline void paintFarDropDownArrow(HDC dc, const RECT &bounds, HBRUSH surface, COLORREF ink, UINT dpi)
{
FillRect(dc, &bounds, surface);
// Center the drawn pixels. A font's arrow glyph can sit below the center of its line box.
const auto halfWidth = std::min((bounds.right - bounds.left - 1) / 2,
static_cast<LONG>(MulDiv(3, dpi, 96)));
const auto halfHeight = std::min((bounds.bottom - bounds.top - 1) / 2,
static_cast<LONG>(MulDiv(5, dpi, 96)));
if (halfWidth < 1 || halfHeight < 1) return;
const auto x = (bounds.left + bounds.right - 1) / 2;
const auto y = (bounds.top + bounds.bottom - 1) / 2;
const auto tip = y + halfHeight;
const auto head = std::min(halfWidth, halfHeight);
for (LONG row = y - halfHeight; row <= tip; ++row)
SetPixelV(dc, x, row, ink);
for (LONG offset = 1; offset <= head; ++offset)
{
SetPixelV(dc, x - offset, tip - offset, ink);
SetPixelV(dc, x + offset, tip - offset, ink);
}
}
inline void paintFarFieldArrow(HDC dc, const RECT &bounds, bool enabled, UINT dpi)
{
paintFarDropDownArrow(dc, bounds, static_cast<HBRUSH>(GetStockObject(LTGRAY_BRUSH)),
enabled ? RGB(0,0,0) : RGB(48,48,48), dpi);
}
}
+456
View File
@@ -0,0 +1,456 @@
struct FolderShortcut
{
fs::path path;
std::string peerId;
std::wstring peerName,title;
};
using FolderShortcuts=std::array<std::vector<FolderShortcut>,10>;
FolderShortcuts folderShortcuts;
constexpr wchar_t folderShortcutsKey[]=L"FolderShortcuts";
bool sameFolderShortcut(const FolderShortcut &left,const FolderShortcut &right)
{
return left.peerId==right.peerId &&
CompareStringOrdinal(left.path.c_str(),-1,right.path.c_str(),-1,TRUE)==CSTR_EQUAL;
}
bool saveFolderShortcuts(const FolderShortcuts &shortcuts)
{
try
{
auto groups=Json::array();
for(const auto &group:shortcuts)
{
auto rows=Json::array();
for(const auto &entry:group)
rows.push_back({{"path",ks::utf8(entry.path.native())},{"peer",entry.peerId},
{"name",ks::utf8(entry.peerName)},{"title",ks::utf8(entry.title)}});
groups.push_back(std::move(rows));
}
const auto data=groups.dump();
return data.size()<=1024*1024 &&
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,REG_BINARY,data.data(),
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
}
catch(const std::exception &){return false;}
}
void loadFolderShortcuts()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
!bytes || bytes>1024*1024) return;
std::string data(bytes,'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,folderShortcutsKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
return;
try
{
const auto groups=Json::parse(data.begin(),data.begin()+bytes);
if(!groups.is_array() || groups.size()!=folderShortcuts.size()) return;
FolderShortcuts loaded;
for(size_t digit=0;digit<loaded.size();++digit)
{
if(!groups[digit].is_array()) return;
for(const auto &row:groups[digit])
{
FolderShortcut entry{filePath(row.at("path")),row.at("peer"),ks::wide(row.at("name")),ks::wide(row.value("title",""))};
if(!entry.path.is_absolute() || entry.path.native().find(L'\0')!=std::wstring::npos) continue;
loaded[digit].push_back(std::move(entry));
}
}
folderShortcuts=std::move(loaded);
}
catch(const std::exception &) {}
}
constexpr int shortcutTitle=314,shortcutPath=211;
struct FolderShortcutEdit
{
FolderShortcut entry;
int digit;
std::optional<size_t> index;
};
std::wstring shortcutField(HWND dialog,int id)
{
const auto field=GetDlgItem(dialog,id);
const auto length=GetWindowTextLengthW(field);
std::wstring result(static_cast<size_t>(length)+1,L'\0');
result.resize(GetWindowTextW(field,result.data(),length+1));
return result;
}
INT_PTR CALLBACK folderShortcutEditDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(const auto painted=paintCopyDialog(dialog,message,wp,lp,L" Folder shortcuts ",false,{315}))
return painted;
if(message==WM_INITDIALOG)
{
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
prepareCopyDialog(dialog);
layoutDialogContent(dialog,316);
for(const auto id:{316,317,IDOK,IDCANCEL})
SendDlgItemMessageW(dialog,id,WM_SETFONT,reinterpret_cast<WPARAM>(mono),FALSE);
SendDlgItemMessageW(dialog,shortcutTitle,EM_SETLIMITTEXT,255,0);
SendDlgItemMessageW(dialog,shortcutPath,EM_SETLIMITTEXT,32767,0);
SetDlgItemTextW(dialog,shortcutTitle,edit.entry.title.c_str());
SetDlgItemTextW(dialog,shortcutPath,edit.entry.path.c_str());
if(!edit.entry.peerId.empty())
SetDlgItemTextW(dialog,317,(L"Path on "+edit.entry.peerName).c_str());
layoutDialogButtons(dialog,315,{IDOK,IDCANCEL});
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
FilesModalDialog::finish(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND || LOWORD(wp)!=IDOK) return FALSE;
auto &edit=*reinterpret_cast<FolderShortcutEdit*>(GetWindowLongPtrW(dialog,DWLP_USER));
auto path=fs::path(shortcutField(dialog,shortcutPath)).lexically_normal();
if(path.has_relative_path() && !path.has_filename()) path=path.parent_path();
const auto root=path.root_name().native();
const bool lanDrive=root.size()==2 && root[1]==L':' &&
((root[0]>=L'A' && root[0]<=L'Z') || (root[0]>=L'a' && root[0]<=L'z'));
if(!path.is_absolute() || (!edit.entry.peerId.empty() && !lanDrive))
{
showFilesNotice(dialog,L"Enter an absolute folder path on the original computer.",L"Folder shortcuts");
SetFocus(GetDlgItem(dialog,shortcutPath));
return TRUE;
}
auto entry=edit.entry;
entry.path=std::move(path);
entry.title=shortcutField(dialog,shortcutTitle);
const auto &group=folderShortcuts[edit.digit];
for(size_t i=0;i<group.size();++i)
if((!edit.index || i!=*edit.index) && sameFolderShortcut(group[i],entry))
{
showFilesNotice(dialog,L"This folder is already assigned to this digit.",L"Folder shortcuts");
return TRUE;
}
auto updated=folderShortcuts;
if(edit.index) updated[edit.digit][*edit.index]=std::move(entry);
else updated[edit.digit].push_back(std::move(entry));
if(!saveFolderShortcuts(updated))
{
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
return TRUE;
}
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
FilesModalDialog::finish(dialog,IDOK);
return TRUE;
}
bool editFolderShortcut(HWND owner,FolderShortcutEdit &edit)
{
return FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(110),owner,
folderShortcutEditDialog,reinterpret_cast<LPARAM>(&edit))==IDOK;
}
void addFolderShortcut(const Panel &panel,int digit)
{
if(panel.directory.empty()) return;
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
panel.peer?panel.peer->name:L""},digit,{}};
if(editFolderShortcut(GetParent(panel.list),edit))
setStatus(L"Folder added to Ctrl+"+std::to_wstring(digit)+L".");
SetFocus(panel.list);
}
void openFolderShortcut(Panel &panel,const FolderShortcut &entry)
{
if(entry.peerId.empty())
{
openOn(panel,entry.path,true,{});
return;
}
queryDrivePeers();
const auto peer=std::find_if(drivePeers.begin(),drivePeers.end(),
[&](const auto &candidate){return candidate.id==entry.peerId && !candidate.address.empty();});
if(peer==drivePeers.end())
{
setStatus(L"Paired computer unavailable: "+entry.peerName);
return;
}
openOn(panel,entry.path,false,*peer);
}
constexpr int shortcutList=310,quickRemove=313,quickEdit=318,quickInsert=319,quickMoveUp=320,quickMoveDown=321;
constexpr wchar_t quickFolderHints[]=L"Ins Del F4 Ctrl+Up Ctrl+Down";
struct FolderShortcutView
{
int digit;
std::optional<FolderShortcut> chosen;
std::vector<int> digits;
int rowHeight{};
Panel *panel{};
bool all{};
std::vector<std::pair<int,size_t>> entries;
};
std::wstring shortcutLabel(const FolderShortcut &entry)
{
return entry.title.empty()?(entry.peerId.empty()?L"":entry.peerName+L" · ")+entry.path.native():entry.title;
}
void layoutQuickFolders(HWND dialog,FolderShortcutView &view,int selection)
{
const auto list=GetDlgItem(dialog,shortcutList);
SendMessageW(list,LB_RESETCONTENT,0,0);
view.digits.clear();
view.entries.clear();
const auto owner=GetWindow(dialog,GW_OWNER);
const auto dc=GetDC(dialog);
const auto font=SelectObject(dc,mono);
const auto dpi=GetDpiForWindow(dialog);
const auto px=[&](int value){return MulDiv(value,dpi,96);};
TEXTMETRICW metrics{};
GetTextMetricsW(dc,&metrics);
view.rowHeight=std::max(1L,metrics.tmHeight);
const auto frame=dialogFrameMetrics(dpi);
const int inset=frame.content();
const int listTop=dialogListInset(frame,view.rowHeight);
const auto measure=[&](std::wstring_view label)
{
SIZE size{};
GetTextExtentPoint32W(dc,label.data(),static_cast<int>(label.size()),&size);
return static_cast<int>(size.cx);
};
int width=std::max(measure(L"Folder shortcuts"),measure(view.all?L"Del F4":view.digit<0?L"Enter Esc":quickFolderHints))+3*cellWidth;
const int textInset=dialogRowTextInset(cellWidth);
const auto add=[&](const std::wstring &label)
{
width=std::max(width,measure(label)+2*textInset);
SendMessageW(list,LB_ADDSTRING,0,reinterpret_cast<LPARAM>(label.c_str()));
};
if(view.all)
{
for(int digit=0;digit<static_cast<int>(folderShortcuts.size());++digit)
for(size_t index=0;index<folderShortcuts[digit].size();++index)
{
view.entries.emplace_back(digit,index);
add(std::to_wstring(digit)+L" "+shortcutLabel(folderShortcuts[digit][index]));
}
}
else if(view.digit<0)
{
for(int digit=0;digit<10;++digit)
if(!folderShortcuts[digit].empty())
{
view.digits.push_back(digit);
add(L"Ctrl+"+std::to_wstring(digit));
}
}
else for(const auto &entry:folderShortcuts[view.digit])
add(shortcutLabel(entry));
SelectObject(dc,font);
ReleaseDC(dialog,dc);
MONITORINFO monitor{sizeof(monitor)};
GetMonitorInfoW(MonitorFromWindow(owner,MONITOR_DEFAULTTONEAREST),&monitor);
const auto &work=monitor.rcWork;
RECT parent{};
GetWindowRect(owner,&parent);
const auto windowWidth=std::max(px(260),static_cast<int>(parent.right-parent.left));
const auto windowHeight=std::max(px(140),static_cast<int>(parent.bottom-parent.top));
width=std::min(width,std::max(1,std::min(windowWidth,static_cast<int>(work.right-work.left))-2*inset));
const auto maxRows=std::max(1,(std::min(windowHeight,static_cast<int>(work.bottom-work.top))-2*listTop)/view.rowHeight);
const auto count=static_cast<int>(SendMessageW(list,LB_GETCOUNT,0,0));
const int rows=std::min(count,maxRows);
SendMessageW(list,LB_SETITEMHEIGHT,0,view.rowHeight);
const int listHeight=rows*view.rowHeight;
const int w=width+2*inset,h=listHeight+2*listTop;
const int x=std::clamp(static_cast<int>((parent.left+parent.right-w)/2),
static_cast<int>(work.left),std::max(static_cast<int>(work.left),static_cast<int>(work.right)-w));
const int y=std::clamp(static_cast<int>((parent.top+parent.bottom-h)/2),
static_cast<int>(work.top),std::max(static_cast<int>(work.top),static_cast<int>(work.bottom)-h));
SetWindowPos(dialog,nullptr,x,y,w,h,SWP_NOZORDER|SWP_NOACTIVATE);
SetWindowPos(list,nullptr,inset,listTop,width,listHeight,SWP_NOZORDER|SWP_NOACTIVATE);
SendMessageW(list,LB_SETCURSEL,std::clamp(selection,0,std::max(0,count-1)),0);
InvalidateRect(dialog,nullptr,TRUE);
FilesModalDialog::refreshLayout(dialog);
}
LRESULT CALLBACK quickFolderKeys(HWND list,UINT message,WPARAM key,LPARAM flags,UINT_PTR,DWORD_PTR)
{
if(message==WM_KEYDOWN)
{
int command=0;
if(key==VK_RETURN) command=IDOK;
if(key==VK_DELETE) command=quickRemove;
if(key==VK_F4) command=quickEdit;
if(key==VK_INSERT) command=quickInsert;
if((key==VK_UP || key==VK_DOWN) && (GetKeyState(VK_CONTROL)&0x8000))
command=key==VK_UP?quickMoveUp:quickMoveDown;
if(command)
{
if(!(flags & (1LL<<30))) SendMessageW(GetParent(list),WM_COMMAND,command,0);
return 0;
}
}
return scrollList(list,message,key,flags);
}
INT_PTR CALLBACK quickFolderDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
auto &view=*reinterpret_cast<FolderShortcutView*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
prepareCopyDialog(dialog);
layoutQuickFolders(dialog,view,0);
const auto list=GetDlgItem(dialog,shortcutList);
SetWindowSubclass(list,quickFolderKeys,1,0);
SetFocus(list);
return FALSE;
}
auto *view=reinterpret_cast<FolderShortcutView*>(GetWindowLongPtrW(dialog,DWLP_USER));
if(!view) return FALSE;
if(message==WM_DESTROY)
{
return FALSE;
}
if(message==WM_PAINT)
{
PAINTSTRUCT ps{};
const auto dc=BeginPaint(dialog,&ps);
RECT client{};
GetClientRect(dialog,&client);
const auto frame=CreateSolidBrush(colors[Text]);
const auto bounds=paintDialogFrame(dc,client,dialogFrameMetrics(GetDpiForWindow(dialog)),teal,frame);
DeleteObject(frame);
paintDialogFrameCaptions(dc,client,bounds,L"Folder shortcuts",teal,colors[Text],mono,
view->all?L"Del F4":view->digit<0?L"Enter Esc":quickFolderHints);
EndPaint(dialog,&ps);
return TRUE;
}
if(message==WM_DRAWITEM && wp==shortcutList)
{
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
if(item.itemID==UINT(-1)) return TRUE;
const auto length=SendMessageW(item.hwndItem,LB_GETTEXTLEN,item.itemID,0);
if(length<0) return TRUE;
std::wstring label(static_cast<size_t>(length)+1,L'\0');
SendMessageW(item.hwndItem,LB_GETTEXT,item.itemID,reinterpret_cast<LPARAM>(label.data()));
const auto background=colors[item.itemState&ODS_SELECTED?Selection:Dialog];
paintDialogListRow(item.hDC,item.rcItem,std::wstring_view(label.data(),static_cast<size_t>(length)),
mono,colors[Text],background,dialogRowTextInset(cellWidth));
return TRUE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
FilesModalDialog::finish(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND) return FALSE;
const auto action=LOWORD(wp);
if(action!=quickRemove && action!=quickEdit && action!=quickInsert && action!=quickMoveUp &&
action!=quickMoveDown && action!=IDOK && !(action==shortcutList && HIWORD(wp)==LBN_DBLCLK)) return FALSE;
const auto row=SendDlgItemMessageW(dialog,shortcutList,LB_GETCURSEL,0,0);
if(!view->all && view->digit<0)
{
if((action==IDOK || action==shortcutList) && row>=0 && static_cast<size_t>(row)<view->digits.size())
{
view->digit=view->digits[row];
layoutQuickFolders(dialog,*view,0);
}
return TRUE;
}
int digit=view->digit;
size_t index=static_cast<size_t>(row);
if(view->all)
{
if(index>=view->entries.size() || action==quickInsert ||
action==quickMoveUp || action==quickMoveDown) return TRUE;
const auto &entry=view->entries[index];
digit=entry.first;
index=entry.second;
}
const auto &group=folderShortcuts[digit];
if(action==quickInsert)
{
const auto &panel=*view->panel;
FolderShortcutEdit edit{{panel.directory,panel.peer?panel.peer->id:"",
panel.peer?panel.peer->name:L""},view->digit,{}};
if(!panel.directory.empty() && editFolderShortcut(dialog,edit))
layoutQuickFolders(dialog,*view,static_cast<int>(group.size())-1);
SetFocus(GetDlgItem(dialog,shortcutList));
return TRUE;
}
if(index>=group.size()) return TRUE;
if(action==quickMoveUp || action==quickMoveDown)
{
const auto target=row+(action==quickMoveUp?-1:1);
if(target<0 || static_cast<size_t>(target)>=group.size()) return TRUE;
auto updated=folderShortcuts;
std::swap(updated[view->digit][row],updated[view->digit][target]);
if(!saveFolderShortcuts(updated))
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
else
{
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
layoutQuickFolders(dialog,*view,static_cast<int>(target));
}
return TRUE;
}
if(action==quickEdit)
{
FolderShortcutEdit edit{group[index],digit,index};
if(editFolderShortcut(dialog,edit)) layoutQuickFolders(dialog,*view,static_cast<int>(row));
SetFocus(GetDlgItem(dialog,shortcutList));
return TRUE;
}
if(action==quickRemove)
{
auto updated=folderShortcuts;
updated[digit].erase(updated[digit].begin()+index);
if(!saveFolderShortcuts(updated))
{
showFilesNotice(dialog,L"Could not save folder shortcuts.",L"Folder shortcuts");
return TRUE;
}
folderShortcuts=std::move(updated);
notifyFilesPreferences(GetAncestor(dialog,GA_ROOTOWNER));
if(view->all?view->entries.size()==1:folderShortcuts[digit].empty()) FilesModalDialog::finish(dialog,IDCANCEL);
else layoutQuickFolders(dialog,*view,static_cast<int>(row));
return TRUE;
}
view->chosen=group[index];
FilesModalDialog::finish(dialog,IDOK);
return TRUE;
}
void showFolderShortcuts(Panel &panel,int digit,bool all)
{
if(digit>=0)
{
const auto &group=folderShortcuts[digit];
if(group.empty())
{
setStatus(L"No folders assigned to Ctrl+"+std::to_wstring(digit)+L".");
return;
}
if(group.size()==1)
{
openFolderShortcut(panel,group.front());
return;
}
}
if(digit<0 && std::all_of(folderShortcuts.begin(),folderShortcuts.end(),[](const auto &group){return group.empty();}))
{
setStatus(L"No folder shortcuts.");
return;
}
FolderShortcutView view{digit};
view.panel=&panel;
view.all=all;
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(109),GetParent(panel.list),
quickFolderDialog,reinterpret_cast<LPARAM>(&view));
SetFocus(panel.list);
if(result==IDOK && view.chosen) openFolderShortcut(panel,*view.chosen);
else if(result==-1) setStatus(L"Could not open Folder shortcuts.");
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <windows.h>
#include <string>
namespace ks::files
{
inline std::wstring historyDate()
{
SYSTEMTIME now{};
GetLocalTime(&now);
wchar_t date[24]{};
GetDateFormatEx(LOCALE_NAME_USER_DEFAULT,0,&now,L"dd.MM.yyyy",date,24,nullptr);
return date;
}
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <windows.h>
#include <filesystem>
#include <string_view>
inline constexpr ULONG_PTR FilesLanPeers = 0x4b53010d;
inline constexpr ULONG_PTR FilesLanPeersReply = 0x4b53010e;
inline constexpr DWORD FilesLanPeersLimit = 64 * 1024;
namespace ks
{
inline bool isFilesPeerImage(const std::filesystem::path &image, const std::filesystem::path &install)
{
if (_wcsicmp(image.filename().c_str(), L"KSwitcher.Files.exe") != 0) return false;
const auto directory = image.parent_path();
if (_wcsicmp(directory.c_str(), install.c_str()) == 0) return true;
// Updating moves a still-running Files executable into the transaction backup.
// Windows then reports the moved path for its process image.
if (_wcsicmp(directory.filename().c_str(), L"backup") != 0) return false;
const auto transaction = directory.parent_path();
if (_wcsicmp(transaction.parent_path().c_str(), install.c_str()) != 0) return false;
constexpr std::wstring_view prefix = L".kswitcher-update-";
const auto name = transaction.filename().wstring();
if (name.size() != prefix.size() + 32 || _wcsnicmp(name.c_str(), prefix.data(), prefix.size()) != 0)
return false;
return std::wstring_view(name).substr(prefix.size()).find_first_not_of(L"0123456789abcdefABCDEF")
== std::wstring_view::npos;
}
}
+3761 -670
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include <windows.h>
#include <string_view>
namespace ks::files
{
inline void paintMenuAccessKey(HDC dc, const RECT &clip, POINT origin, std::wstring_view title,
size_t access, COLORREF ink)
{
if (access >= title.size()) return;
SIZE prefix{};
GetTextExtentPoint32W(dc, title.data(), static_cast<int>(access), &prefix);
const auto background = SetBkMode(dc, TRANSPARENT);
const auto text = SetTextColor(dc, ink);
const auto alignment = SetTextAlign(dc, TA_LEFT | TA_TOP);
ExtTextOutW(dc, origin.x + prefix.cx, origin.y, ETO_CLIPPED, &clip, title.data() + access, 1, nullptr);
SetTextAlign(dc, alignment);
SetTextColor(dc, text);
SetBkMode(dc, background);
}
}
+939
View File
@@ -0,0 +1,939 @@
constexpr int optionsSearch=270,optionsTree=271,optionsTitle=272,optionsApply=283,optionsReset=284,optionsProperty=285,optionsResetProperty=286,
optionsRemoteTerminal=370,optionsRemoteHibernate=371,optionsRemoteUpdate=372;
enum OptionsPage { PanelModes, PanelColors, Appearance, CommandLine, TextViewer, TextEditor, allOptions };
constexpr const wchar_t *optionsTitles[]{L"File panel modes",L"Colors",L"Appearance",L"Command line",L"Viewer",L"Editor"};
constexpr const wchar_t *colorNames[]{L"Panel background",L"Text",L"Frame",L"Popup background",
L"Menu selection",L"Command bar",L"File cursor"};
enum PanelSection { PanelLayout, WideColumns, StatusColumns };
constexpr const wchar_t *panelSectionNames[]{L"Panel layout",L"Wide panel columns",L"Status line columns"};
constexpr int panelLayoutControls[]{273,274};
constexpr int wideColumnControls[]{275,276,201,202,204};
constexpr int statusColumnControls[]{277,203,205,206,207,208};
constexpr int colorOptionControls[]{279,280};
constexpr int appearanceControls[]{281,282};
enum AppearanceSection { Font, DialogFrame };
constexpr int outerFramePadding=303,innerFramePadding=305,rowTextPadding=307;
constexpr int dialogFrameControls[]{302,outerFramePadding,304,innerFramePadding,306,rowTextPadding};
constexpr int textTabWidth=357,textLineEnding=359,
textTheme=363,textColor=365,textChangeColor=366,textColorValue=367,textSaveScheme=368;
constexpr int textControls[]{350,351,352,353,356,textTabWidth,
362,textTheme,364,textColor,textChangeColor,textColorValue,textSaveScheme};
constexpr int editingControls[]{354,355,358,textLineEnding};
constexpr struct
{
int id;
bool ks::files::TextPreferences::*value;
const wchar_t *name;
} textChecks[]{
{350,&ks::files::TextPreferences::wrap,L"Wrap lines"},
{351,&ks::files::TextPreferences::numbers,L"Line numbers"},
{352,&ks::files::TextPreferences::whitespace,L"Show whitespace and line endings"},
{353,&ks::files::TextPreferences::caretLine,L"Highlight current line"},
{354,&ks::files::TextPreferences::tabsAsSpaces,L"Insert spaces for tabs"},
{355,&ks::files::TextPreferences::autoIndent,L"Auto indent"}};
bool textOptionsPage(int page) { return page==TextViewer || page==TextEditor; }
constexpr struct
{
int id;
DWORD DialogFramePadding::*value;
DWORD maximum;
} frameOptions[]{
{outerFramePadding,&DialogFramePadding::outer,maxDialogFramePadding},
{innerFramePadding,&DialogFramePadding::inner,maxDialogFramePadding},
{rowTextPadding,&DialogFramePadding::rowText,maxDialogRowTextPadding}};
enum class OptionsAnchor { StretchWidth, StretchHeight, BottomRight };
struct OptionsControl
{
int id;
OptionsAnchor anchor;
RECT bounds{};
};
struct PanelProperty
{
int section, control;
const wchar_t *name;
DWORD defaultValue;
};
constexpr PanelProperty panelProperties[]{
{PanelLayout,273,L"Left panel: Wide",defaultWide},{PanelLayout,274,L"Right panel: Wide",defaultWide},
{WideColumns,201,L"Size column width",defaultSizeWidth},
{WideColumns,202,L"Align file extensions",defaultAlignExtensions},
{WideColumns,204,L"Align folder extensions",defaultAlignFolders},
{StatusColumns,203,L"Show details",defaultShowDetails},
{StatusColumns,205,L"Name",defaultStatusFields&1},{StatusColumns,206,L"Size",(defaultStatusFields>>1)&1},
{StatusColumns,207,L"Date",(defaultStatusFields>>2)&1},{StatusColumns,208,L"Time",(defaultStatusFields>>3)&1}};
void resetPanelProperty(HWND dialog,const PanelProperty &property)
{
if(property.control==201) SetDlgItemInt(dialog,property.control,property.defaultValue,FALSE);
else CheckDlgButton(dialog,property.control,property.defaultValue);
}
struct OptionsBaseline
{
LOGFONTW font=displayFont;
int points=fontPoints;
DialogFramePadding padding=dialogFramePadding;
std::array<COLORREF,ColorCount> palette=colors;
std::vector<FileStyle> styles=fileStyles;
std::array<WORD,2> keys=commandHistoryKeys;
bool remoteTerminal=ks::lan::remoteTerminalAllowed();
bool remoteHibernate=ks::lan::remoteHibernateAllowed();
bool remoteUpdate=ks::lan::remoteUpdateAllowed();
DWORD width=static_cast<DWORD>(sizeWidth), fields=statusFields;
bool extensions=alignExtensions, folders=alignFolders, details=showDetails;
std::array<bool,2> wide{panels[0].wide,panels[1].wide};
ks::files::TextSettings text=ks::files::loadTextPreferences();
};
struct OptionsDraft
{
OptionsBaseline baseline;
ks::files::TextSettings text=baseline.text;
HWND filesWindow{};
LOGFONTW font=displayFont;
int points=fontPoints;
DialogFramePadding framePadding=dialogFramePadding;
std::array<COLORREF,ColorCount> palette{};
std::array<COLORREF,16> custom{};
std::vector<FileStyle> styles=fileStyles;
int styleIndex=0;
bool styleSuggestionsLoaded{};
int page=PanelModes, section=PanelLayout;
HTREEITEM wideColumnsNode{};
RECT initialClient{};
UINT initialDpi=96;
OptionsControl controls[8]{
{optionsTree,OptionsAnchor::StretchHeight},
{optionsTitle,OptionsAnchor::StretchWidth},
{275,OptionsAnchor::StretchWidth},{277,OptionsAnchor::StretchWidth},
{281,OptionsAnchor::StretchWidth},
{IDOK,OptionsAnchor::BottomRight},{IDCANCEL,OptionsAnchor::BottomRight},
{optionsApply,OptionsAnchor::BottomRight}};
};
#include "files_style_options.inl"
#include "files_options_preview.inl"
bool optionsFontChanged(const OptionsDraft &draft)
{
auto pending=draft.font, applied=draft.baseline.font;
// Height follows the selected point size and DPI. applyFont always uses natural width.
pending.lfHeight=applied.lfHeight=0;
pending.lfWidth=applied.lfWidth=0;
return draft.points!=draft.baseline.points ||
memcmp(&pending,&applied,offsetof(LOGFONTW,lfFaceName))!=0 ||
_wcsicmp(pending.lfFaceName,applied.lfFaceName)!=0;
}
struct OptionsWriteLock
{
HANDLE mutex=CreateMutexW(nullptr,FALSE,L"Local\\KSwitcher.Files.OptionsWrite");
DWORD result=mutex?WaitForSingleObject(mutex,2000):WAIT_FAILED;
bool acquired() const { return result==WAIT_OBJECT_0 || result==WAIT_ABANDONED; }
~OptionsWriteLock()
{
if(acquired()) ReleaseMutex(mutex);
if(mutex) CloseHandle(mutex);
}
};
void updateOptionsApply(HWND dialog,const OptionsDraft &draft)
{
BOOL valid{};
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
DWORD fields=0;
for(int i=0;i<4;++i) if(checked(205+i)) fields|=1u<<i;
const auto &base=draft.baseline;
const bool dirty=!valid || width!=base.width ||
checked(273)!=base.wide[0] || checked(274)!=base.wide[1] ||
checked(202)!=base.extensions || checked(204)!=base.folders || checked(203)!=base.details ||
fields!=base.fields || draft.palette!=base.palette || draft.styles!=base.styles ||
draft.framePadding!=base.padding || optionsFontChanged(draft) ||
optionHistoryKeys(dialog)!=base.keys || checked(optionsRemoteTerminal)!=base.remoteTerminal ||
checked(optionsRemoteHibernate)!=base.remoteHibernate ||
checked(optionsRemoteUpdate)!=base.remoteUpdate ||
draft.text!=base.text;
EnableWindow(GetDlgItem(dialog,optionsApply),dirty);
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
}
LRESULT CALLBACK optionsGroupBackground(HWND group,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(message==WM_ERASEBKGND)
{
const auto dc=reinterpret_cast<HDC>(wp);
RECT client{};
GetClientRect(group,&client);
FillRect(dc,&client,GetSysColorBrush(COLOR_3DFACE));
return TRUE;
}
return DefSubclassProc(group,message,wp,lp);
}
void layoutOptionsPreview(HWND dialog)
{
const auto preview=GetDlgItem(dialog,fileStylePreview);
if(!(GetWindowLongPtrW(preview,GWL_STYLE)&WS_VISIBLE)) return;
RECT gap{0,0,6,5};
MapDialogRect(dialog,&gap);
const auto title=dialogControlBounds(dialog,optionsTitle);
const auto buttons=dialogControlBounds(dialog,IDOK);
LONG top=title.bottom;
for(auto child=GetWindow(dialog,GW_CHILD);child;child=GetWindow(child,GW_HWNDNEXT))
{
const auto id=GetDlgCtrlID(child);
if(child==preview || id==IDOK || id==IDCANCEL || id==optionsApply ||
!(GetWindowLongPtrW(child,GWL_STYLE)&WS_VISIBLE)) continue;
const auto bounds=dialogControlBounds(dialog,id);
if(id!=-1 && bounds.left>=title.left-4) top=std::max(top,bounds.bottom);
}
top+=gap.bottom;
SetWindowPos(preview,nullptr,title.left,top,title.right-title.left,
std::max(1L,buttons.top-gap.bottom-top),SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
}
void layoutOptions(HWND dialog,const OptionsDraft &draft)
{
const auto dpi=GetDpiForWindow(dialog);
const auto scale=[&](LONG value){return MulDiv(value,dpi,draft.initialDpi);};
RECT client{};
GetClientRect(dialog,&client);
const auto dx=std::max(0L,client.right-scale(draft.initialClient.right));
const auto dy=std::max(0L,client.bottom-scale(draft.initialClient.bottom));
for(const auto &control:draft.controls)
{
const auto &bounds=control.bounds;
auto x=scale(bounds.left),y=scale(bounds.top);
auto width=scale(bounds.right)-x,height=scale(bounds.bottom)-y;
switch(control.anchor)
{
case OptionsAnchor::StretchWidth: width+=dx;break;
case OptionsAnchor::StretchHeight: height+=dy;break;
case OptionsAnchor::BottomRight: x+=dx;y+=dy;break;
}
SetWindowPos(GetDlgItem(dialog,control.id),nullptr,x,y,width,height,
SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW);
}
groupDialogButtons(dialog,{IDOK,IDCANCEL,optionsApply},true);
if(draft.page==PanelColors && draft.section==fileStyleSection) layoutFileStyleFields(dialog,false);
layoutOptionsPreview(dialog);
// Paint the final layout together, including the areas vacated by moved controls.
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
}
void refreshTextColor(HWND dialog,const ks::files::TextPreferences &profile)
{
const auto index=SendDlgItemMessageW(dialog,textColor,CB_GETCURSEL,0,0);
if(index<0 || static_cast<size_t>(index)>=profile.colors.size()) return;
const auto color=profile.colors[index];
wchar_t hex[8]{};
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
SetDlgItemTextW(dialog,textColorValue,hex);
}
bool sameSchemeName(const std::wstring &left,const wchar_t *right)
{
return CompareStringOrdinal(left.c_str(),-1,right,-1,TRUE)==CSTR_EQUAL;
}
void populateTextSchemes(HWND dialog,const ks::files::TextSettings &settings)
{
SendDlgItemMessageW(dialog,textTheme,CB_RESETCONTENT,0,0);
const auto add=[&](const wchar_t *name)
{
SendDlgItemMessageW(dialog,textTheme,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(name));
};
add(L"Custom");
for(const auto name:ks::files::textPaletteNames) add(name);
for(const auto &scheme:settings.schemes) add(scheme.name.c_str());
}
size_t currentTextScheme(const ks::files::TextSettings &settings,const ks::files::TextPreferences &profile)
{
for(size_t index=0;index<std::size(ks::files::textPalettes);++index)
if(profile.scheme==ks::files::textPaletteNames[index] && profile.colors==ks::files::textPalettes[index])
return index+1;
for(size_t index=0;index<settings.schemes.size();++index)
{
const auto &scheme=settings.schemes[index];
if(profile.scheme==scheme.name && profile.colors==scheme.colors)
return std::size(ks::files::textPalettes)+index+1;
}
return 0;
}
INT_PTR CALLBACK schemeNameDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
SetWindowLongPtrW(dialog,DWLP_USER,lp);
SendDlgItemMessageW(dialog,100,EM_SETLIMITTEXT,ks::files::maxTextPaletteName,0);
groupDialogButtons(dialog,{IDOK,IDCANCEL},true);
return TRUE;
}
if(message!=WM_COMMAND) return FALSE;
if(LOWORD(wp)==IDCANCEL) { FilesModalDialog::finish(dialog,IDCANCEL); return TRUE; }
if(LOWORD(wp)!=IDOK) return FALSE;
wchar_t text[ks::files::maxTextPaletteName+1]{};
GetDlgItemTextW(dialog,100,text,static_cast<int>(std::size(text)));
std::wstring name=text;
const auto first=name.find_first_not_of(L" \t");
if(first==name.npos) { MessageBeep(MB_ICONWARNING); return TRUE; }
*reinterpret_cast<std::wstring *>(GetWindowLongPtrW(dialog,DWLP_USER))=
name.substr(first,name.find_last_not_of(L" \t")-first+1);
FilesModalDialog::finish(dialog,IDOK);
return TRUE;
}
void saveTextSchemeDraft(HWND dialog,ks::files::TextSettings &settings,ks::files::TextPreferences &profile)
{
std::wstring name;
if(FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(608),dialog,schemeNameDialog,
reinterpret_cast<LPARAM>(&name))!=IDOK) return;
if(sameSchemeName(name,L"Custom") || std::any_of(std::begin(ks::files::textPaletteNames),
std::end(ks::files::textPaletteNames),[&](const wchar_t *preset){return sameSchemeName(name,preset);}))
{
showFilesNotice(dialog,L"Choose a different name for your color scheme.",L"Save color scheme");
return;
}
const auto existing=std::find_if(settings.schemes.begin(),settings.schemes.end(),
[&](const auto &scheme){return sameSchemeName(name,scheme.name.c_str());});
if(existing!=settings.schemes.end())
{
if(showFilesNotice(dialog,L"Replace the saved scheme with the current colors?",L"Save color scheme",
{.confirm=true,.tone=FilesNoticeTone::neutral})!=IDYES) return;
existing->colors=profile.colors;
profile.scheme=existing->name;
}
else
{
if(settings.schemes.size()>=ks::files::maxTextPalettes)
{
showFilesNotice(dialog,L"The color scheme list is full.",L"Save color scheme");
return;
}
settings.schemes.push_back({name,profile.colors});
profile.scheme=std::move(name);
}
populateTextSchemes(dialog,settings);
}
void showTextOptions(HWND dialog,const ks::files::TextSettings &settings,const ks::files::TextPreferences &profile)
{
for(const auto &check:textChecks) CheckDlgButton(dialog,check.id,profile.*check.value?BST_CHECKED:BST_UNCHECKED);
SendDlgItemMessageW(dialog,textTabWidth,CB_SETCURSEL,profile.tabWidth-1,0);
SendDlgItemMessageW(dialog,textLineEnding,CB_SETCURSEL,profile.lineEnding,0);
SendDlgItemMessageW(dialog,textTheme,CB_SETCURSEL,currentTextScheme(settings,profile),0);
refreshTextColor(dialog,profile);
}
bool changeTextOption(HWND dialog,OptionsDraft &draft,WPARAM command)
{
if(!textOptionsPage(draft.page)) return false;
auto &profile=draft.text.profiles[draft.page==TextEditor];
const auto id=LOWORD(command);
const auto row=[&](int control){return SendDlgItemMessageW(dialog,control,CB_GETCURSEL,0,0);};
if(const auto check=std::find_if(std::begin(textChecks),std::end(textChecks),
[id](const auto &entry){return entry.id==id;});check!=std::end(textChecks))
profile.*check->value=IsDlgButtonChecked(dialog,id)==BST_CHECKED;
else if(id==textSaveScheme) saveTextSchemeDraft(dialog,draft.text,profile);
else if(id==textChangeColor)
{
const auto index=row(textColor);
if(index>=0 && static_cast<size_t>(index)<profile.colors.size())
choosePanelColor(dialog,profile.colors[index],draft.custom.data());
}
else if(HIWORD(command)==CBN_SELCHANGE)
{
const auto selected=row(id);
if(selected<0) return false;
switch(id)
{
case textColor: break;
case textTabWidth: profile.tabWidth=static_cast<int>(selected)+1; break;
case textLineEnding: profile.lineEnding=static_cast<int>(selected); break;
case textTheme:
if(selected==0) profile.scheme.clear();
else if(const auto scheme=static_cast<size_t>(selected-1);scheme<std::size(ks::files::textPalettes))
{
profile.scheme=ks::files::textPaletteNames[scheme];
profile.colors=ks::files::textPalettes[scheme];
}
else
{
const auto index=scheme-std::size(ks::files::textPalettes);
if(index>=draft.text.schemes.size()) return false;
profile.scheme=draft.text.schemes[index].name;
profile.colors=draft.text.schemes[index].colors;
}
break;
default: return false;
}
}
else return false;
showTextOptions(dialog,draft.text,profile);
updateOptionsApply(dialog,draft);
return true;
}
void showOptionsPage(HWND dialog,OptionsDraft &draft,int page,int section)
{
draft.page=page;
draft.section=section;
const wchar_t *title=L"No matching settings";
if(page==allOptions) title=L"All settings";
else if(page>=0)
{
title=optionsTitles[page];
if(section>=0 && !textOptionsPage(page))
title=page==PanelModes?panelSectionNames[section]:page==PanelColors?
(section==fileStyleSection?L"File type styles":colorNames[section]):
page==CommandLine?L"Command history":section==DialogFrame?L"Dialog frame":L"Font";
}
SetDlgItemTextW(dialog,optionsTitle,title);
const auto show=[&](auto &controls,bool visible)
{
for(const auto id:controls) ShowWindow(GetDlgItem(dialog,id),visible?SW_SHOW:SW_HIDE);
};
EnableWindow(GetDlgItem(dialog,optionsReset),page>=0);
const bool individual=page==PanelModes && section>=0;
ShowWindow(GetDlgItem(dialog,optionsProperty),individual?SW_SHOW:SW_HIDE);
ShowWindow(GetDlgItem(dialog,optionsResetProperty),individual?SW_SHOW:SW_HIDE);
if(individual)
{
SendDlgItemMessageW(dialog,optionsProperty,CB_RESETCONTENT,0,0);
for(size_t i=0;i<std::size(panelProperties);++i)
{
const auto &property=panelProperties[i];
if(property.section!=section) continue;
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_ADDSTRING,0,
reinterpret_cast<LPARAM>(property.name));
if(row>=0) SendDlgItemMessageW(dialog,optionsProperty,CB_SETITEMDATA,row,i);
}
SendDlgItemMessageW(dialog,optionsProperty,CB_SETCURSEL,0,0);
}
show(panelLayoutControls,page==PanelModes && section==PanelLayout);
show(wideColumnControls,page==PanelModes && section==WideColumns);
show(statusColumnControls,page==PanelModes && section==StatusColumns);
show(appearanceControls,page==Appearance && section==Font);
show(dialogFrameControls,page==Appearance && section==DialogFrame);
if(page==Appearance && section==DialogFrame)
{
for(const auto &option:frameOptions)
SendDlgItemMessageW(dialog,option.id,CB_SETCURSEL,draft.framePadding.*option.value,0);
}
constexpr int commandLineControls[]{320,321,322,323,optionsRemoteTerminal,optionsRemoteHibernate,optionsRemoteUpdate};
show(commandLineControls,page==CommandLine && section==0);
show(textControls,textOptionsPage(page));
show(editingControls,page==TextEditor);
if(textOptionsPage(page)) showTextOptions(dialog,draft.text,draft.text.profiles[page==TextEditor]);
const bool fileTypes=page==PanelColors && section==fileStyleSection;
constexpr int fileStyleControls[]{287,288,289,290,291,292,293,294,295,296,297,298,300,fileStyleState};
show(fileStyleControls,fileTypes);
const auto preview=GetDlgItem(dialog,fileStylePreview);
ShowWindow(preview,page>=0 && page<CommandLine && section>=0?SW_SHOW:SW_HIDE);
layoutOptionsPreview(dialog);
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
show(colorOptionControls,page==PanelColors && section>=0 && !fileTypes);
if(fileTypes)
{
if(!draft.styleSuggestionsLoaded)
{
fillFileStyleSuggestions(dialog);
draft.styleSuggestionsLoaded=true;
}
updateFileStyleEditor(dialog,draft,true);
}
if(page==PanelColors && section>=0 && !fileTypes)
{
const auto color=draft.palette[section];
wchar_t hex[8]{};
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(color)),unsigned(GetGValue(color)),unsigned(GetBValue(color)));
SetDlgItemTextW(dialog,280,hex);
}
if(page==Appearance)
{
const auto description=std::wstring(draft.font.lfFaceName)+L" · "+std::to_wstring(draft.points/10)+
(draft.points%10?L"."+std::to_wstring(draft.points%10):L"")+L" pt";
SetDlgItemTextW(dialog,281,description.c_str());
}
}
void filterOptions(HWND dialog,OptionsDraft &draft)
{
wchar_t text[256]{};
GetDlgItemTextW(dialog,optionsSearch,text,std::size(text));
std::wstring query=text;
if(!query.empty()) CharLowerBuffW(query.data(),static_cast<DWORD>(query.size()));
const auto matches=[&](const wchar_t *label)
{
std::wstring lower=label;
CharLowerBuffW(lower.data(),static_cast<DWORD>(lower.size()));
return lower.find(query)!=std::wstring::npos;
};
const HWND tree=GetDlgItem(dialog,optionsTree);
const auto add=[&](const wchar_t *label,int page,int option,HTREEITEM parent)
{
TVINSERTSTRUCTW insert{};
insert.hParent=parent;
insert.hInsertAfter=TVI_LAST;
insert.item.mask=TVIF_TEXT|TVIF_PARAM;
insert.item.pszText=const_cast<wchar_t*>(label);
insert.item.lParam=(page<<8)|(option+1);
return TreeView_InsertItem(tree,&insert);
};
const int previousPage=draft.page,previousSection=draft.section;
TreeView_DeleteAllItems(tree);
draft.wideColumnsNode=nullptr;
const auto root=add(L"All settings",allOptions,-1,TVI_ROOT);
HTREEITEM first{},selected{};
for(int page=PanelModes;page<allOptions;++page)
{
HTREEITEM category{};
const bool wholeCategory=matches(optionsTitles[page]) || (!query.empty() && matches(L"All settings"));
const auto addSection=[&](const wchar_t *label,int section,bool aliasMatches=false)
{
if(!wholeCategory && !aliasMatches && !matches(label)) return;
if(!category) category=add(optionsTitles[page],page,-1,root);
const auto node=add(label,page,section,category);
if(!first) first=node;
if(page==previousPage && section==previousSection) selected=node;
if(page==PanelModes && section==WideColumns) draft.wideColumnsNode=node;
};
if(page==PanelModes)
{
for(int section=PanelLayout;section<=StatusColumns;++section)
{
const bool propertyMatches=std::any_of(std::begin(panelProperties),std::end(panelProperties),
[&](const auto &property){return property.section==section && matches(property.name);});
addSection(panelSectionNames[section],section,propertyMatches);
}
}
else if(page==PanelColors)
{
for(int color=0;color<ColorCount;++color) addSection(colorNames[color],color);
const bool extensionMatches=std::any_of(draft.styles.begin(),draft.styles.end(),
[&](const auto &style){return matches(style.extension.c_str());});
addSection(L"File type styles",fileStyleSection,extensionMatches || matches(L"System and hidden") ||
matches(L"Underline") || matches(L"Strikethrough") || matches(L"Foreground"));
}
else if(page==CommandLine) addSection(L"Command history",0,
matches(L"Previous command") || matches(L"Allow paired PCs to run PowerShell commands here") ||
matches(L"Allow paired PCs to hibernate this PC") || matches(L"Allow paired PCs to update KSwitcher here"));
else if(textOptionsPage(page))
{
const bool propertyMatches=matches(L"Tab width") ||
matches(L"Color scheme") || matches(L"Save scheme") || matches(L"Customize colors") ||
(page==TextEditor && matches(L"Line endings for new lines")) ||
std::any_of(std::begin(textChecks),std::end(textChecks),[&](const auto &check)
{return (page==TextEditor || check.id<354) && matches(check.name);}) ||
std::any_of(std::begin(ks::files::textColorNames),std::end(ks::files::textColorNames),matches);
addSection(page==TextEditor?L"Editing and appearance":L"Viewing and appearance",0,propertyMatches);
}
else
{
addSection(L"Font",Font,matches(L"Display font"));
addSection(L"Dialog frame",DialogFrame,
matches(L"Outer padding") || matches(L"Inner padding") || matches(L"Row text padding"));
}
if(category) TreeView_Expand(tree,category,TVE_EXPAND);
}
TreeView_Expand(tree,root,TVE_EXPAND);
TreeView_SelectItem(tree,query.empty() && previousPage==allOptions?root:selected?selected:first?first:root);
if(!first) SetDlgItemTextW(dialog,optionsTitle,L"No matching settings");
}
INT_PTR CALLBACK optionsDialog(HWND dialog,UINT message,WPARAM wp,LPARAM lp)
{
if(message==WM_INITDIALOG)
{
auto &draft=*reinterpret_cast<OptionsDraft*>(lp);
SetWindowLongPtrW(dialog,DWLP_USER,lp);
draft.initialDpi=GetDpiForWindow(dialog);
GetClientRect(dialog,&draft.initialClient);
for(auto &control:draft.controls) control.bounds=dialogControlBounds(dialog,control.id);
// Group boxes are behind their sibling controls. Erase their full client
// area on resize while clipping out the controls inside the frame.
for(const auto id:{275,277})
{
const auto group=GetDlgItem(dialog,id);
SetWindowLongPtrW(group,GWL_STYLE,GetWindowLongPtrW(group,GWL_STYLE)|WS_CLIPSIBLINGS);
SetWindowPos(group,HWND_BOTTOM,0,0,0,0,SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE);
SetWindowSubclass(group,optionsGroupBackground,1,0);
}
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
SendDlgItemMessageW(dialog,201,EM_SETLIMITTEXT,2,0);
SendDlgItemMessageW(dialog,optionsSearch,EM_SETLIMITTEXT,255,0);
for(const auto &option:frameOptions)
for(DWORD value=0;value<=option.maximum;++value)
SendDlgItemMessageW(dialog,option.id,CB_ADDSTRING,0,
reinterpret_cast<LPARAM>(std::to_wstring(value).c_str()));
for(int width=1;width<=16;++width)
SendDlgItemMessageW(dialog,textTabWidth,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(std::to_wstring(width).c_str()));
for(const auto label:{L"Automatic",L"Windows (CRLF)",L"Unix (LF)",L"Classic Mac (CR)"})
SendDlgItemMessageW(dialog,textLineEnding,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
populateTextSchemes(dialog,draft.text);
for(const auto label:ks::files::textColorNames)
SendDlgItemMessageW(dialog,textColor,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
SendDlgItemMessageW(dialog,textColor,CB_SETCURSEL,0,0);
CheckDlgButton(dialog,202,alignExtensions);
CheckDlgButton(dialog,203,showDetails);
CheckDlgButton(dialog,204,alignFolders);
CheckDlgButton(dialog,273,panels[0].wide);
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
CheckDlgButton(dialog,optionsRemoteHibernate,ks::lan::remoteHibernateAllowed());
CheckDlgButton(dialog,optionsRemoteUpdate,ks::lan::remoteUpdateAllowed());
filterOptions(dialog,draft);
updateOptionsApply(dialog,draft);
return TRUE;
}
auto *draft=reinterpret_cast<OptionsDraft*>(GetWindowLongPtrW(dialog,DWLP_USER));
if(!draft) return FALSE;
if(message==WM_DRAWITEM && wp==fileStylePreview)
{
const auto &item=*reinterpret_cast<const DRAWITEMSTRUCT*>(lp);
if(draft->page==PanelColors && draft->section==fileStyleSection) paintFileStylePreview(item,*draft);
else paintOptionsPreview(dialog,item,*draft);
return TRUE;
}
if(message==WM_GETMINMAXINFO)
{
const auto dpi=GetDpiForWindow(dialog);
RECT minimum{0,0,MulDiv(draft->initialClient.right,dpi,draft->initialDpi),
MulDiv(draft->initialClient.bottom,dpi,draft->initialDpi)};
AdjustWindowRectExForDpi(&minimum,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_STYLE)),
FALSE,static_cast<DWORD>(GetWindowLongPtrW(dialog,GWL_EXSTYLE)),dpi);
auto &limits=*reinterpret_cast<MINMAXINFO*>(lp);
limits.ptMinTrackSize.x=minimum.right-minimum.left;
limits.ptMinTrackSize.y=minimum.bottom-minimum.top;
return TRUE;
}
if(message==WM_SIZE && wp!=SIZE_MINIMIZED)
{
layoutOptions(dialog,*draft);
return TRUE;
}
if(message==WM_NOTIFY && wp==optionsTree)
{
const auto &change=*reinterpret_cast<const NMTREEVIEWW*>(lp);
if(change.hdr.code==TVN_SELCHANGEDW && change.itemNew.hItem)
showOptionsPage(dialog,*draft,static_cast<int>(change.itemNew.lParam>>8),static_cast<int>(change.itemNew.lParam&255)-1);
return FALSE;
}
if(message==WM_CLOSE || (message==WM_COMMAND && LOWORD(wp)==IDCANCEL))
{
FilesModalDialog::finish(dialog,IDCANCEL);
return TRUE;
}
if(message!=WM_COMMAND) return FALSE;
if(changeTextOption(dialog,*draft,wp)) return TRUE;
switch(LOWORD(wp))
{
case optionsSearch:
if(HIWORD(wp)==EN_CHANGE) filterOptions(dialog,*draft);
return TRUE;
case 279:
if(draft->page==PanelColors && draft->section>=0 && draft->section<ColorCount)
{
choosePanelColor(dialog,draft->palette[draft->section],draft->custom.data());
showOptionsPage(dialog,*draft,PanelColors,draft->section);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case 282:
chooseDisplayFont(dialog,draft->font,draft->points);
showOptionsPage(dialog,*draft,Appearance,Font);
updateOptionsApply(dialog,*draft);
return TRUE;
case outerFramePadding: case innerFramePadding: case rowTextPadding:
if(HIWORD(wp)==CBN_SELCHANGE)
{
const auto value=SendMessageW(reinterpret_cast<HWND>(lp),CB_GETCURSEL,0,0);
if(value>=0)
for(const auto &option:frameOptions)
if(option.id==LOWORD(wp)) draft->framePadding.*option.value=static_cast<DWORD>(value);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case fileStyleState:
if(HIWORD(wp)==CBN_SELCHANGE) updateFileStyleEditor(dialog,*draft,false);
return TRUE;
case fileStyleRules:
if(HIWORD(wp)==CBN_SELCHANGE)
{
draft->styleIndex=static_cast<int>(SendDlgItemMessageW(dialog,fileStyleRules,CB_GETCURSEL,0,0));
updateFileStyleEditor(dialog,*draft,false);
}
return TRUE;
case 292:
if(addFileStyle(dialog,*draft)) updateOptionsApply(dialog,*draft);
return TRUE;
case 289: case 293: case 294: case 295: case 297: case 298:
if(draft->styleIndex>=0)
{
auto &style=draft->styles[draft->styleIndex];
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
auto &foreground=state>0?style.states[state-1].foreground:style.foreground;
auto &background=state>0?style.states[state-1].background:style.background;
switch(LOWORD(wp))
{
case 289:
draft->styles.erase(draft->styles.begin()+draft->styleIndex);
break;
case 293: case 294:
{
auto &field=LOWORD(wp)==293?foreground:background;
auto color=field.value_or(draft->palette[LOWORD(wp)==293?Text:PanelBlue]);
if(choosePanelColor(dialog,color,draft->custom.data())) field=color;
break;
}
case 295: foreground.reset();background.reset();break;
case 297: style.underline=IsDlgButtonChecked(dialog,297)==BST_CHECKED;break;
case 298: style.strikeout=IsDlgButtonChecked(dialog,298)==BST_CHECKED;break;
}
updateFileStyleEditor(dialog,*draft,LOWORD(wp)==289);
updateOptionsApply(dialog,*draft);
}
return TRUE;
case optionsReset:
if(draft->page==allOptions || draft->page==CommandLine)
{
setOptionHistoryKeys(dialog,defaultHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,BST_UNCHECKED);
CheckDlgButton(dialog,optionsRemoteHibernate,BST_UNCHECKED);
CheckDlgButton(dialog,optionsRemoteUpdate,BST_UNCHECKED);
}
if(draft->page==allOptions || draft->page==PanelModes)
for(const auto &property:panelProperties)
if(draft->section<0 || property.section==draft->section) resetPanelProperty(dialog,property);
if(draft->page==allOptions || (draft->page==PanelColors && draft->section<0))
draft->palette=defaultColors;
else if(draft->page==PanelColors && draft->section< ColorCount)
draft->palette[draft->section]=defaultColors[draft->section];
if(draft->page==allOptions || (draft->page==PanelColors &&
(draft->section<0 || draft->section==fileStyleSection)))
draft->styles=defaultFileStyles;
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=DialogFrame))
{
draft->font=defaultFilesFont();
draft->points=defaultFontPoints;
}
if(draft->page==allOptions || (draft->page==Appearance && draft->section!=Font))
draft->framePadding=defaultDialogFramePadding;
if(draft->page==allOptions || textOptionsPage(draft->page))
for(int page=TextViewer;page<=TextEditor;++page)
if(draft->page==allOptions || draft->page==page)
draft->text.profiles[page==TextEditor]=ks::files::TextPreferences{};
showOptionsPage(dialog,*draft,draft->page,draft->section);
updateOptionsApply(dialog,*draft);
return TRUE;
case optionsResetProperty:
{
const auto row=SendDlgItemMessageW(dialog,optionsProperty,CB_GETCURSEL,0,0);
if(row>=0)
{
const auto property=SendDlgItemMessageW(dialog,optionsProperty,CB_GETITEMDATA,row,0);
if(property>=0 && static_cast<size_t>(property)<std::size(panelProperties))
resetPanelProperty(dialog,panelProperties[property]);
updateOptionsApply(dialog,*draft);
}
return TRUE;
}
case 201:
case 202: case 203: case 204: case 205: case 206: case 207: case 208:
case 273: case 274: case optionsRemoteTerminal: case optionsRemoteHibernate: case optionsRemoteUpdate:
updateOptionsApply(dialog,*draft);
return TRUE;
case 321: case 323:
updateOptionsApply(dialog,*draft);
return TRUE;
case optionsApply:
case IDOK:
{
BOOL valid{};
const auto width=GetDlgItemInt(dialog,201,&valid,FALSE);
if(!valid || width<6 || width>16)
{
SetDlgItemTextW(dialog,optionsSearch,L"");
TreeView_SelectItem(GetDlgItem(dialog,optionsTree),draft->wideColumnsNode);
showFilesNotice(dialog,L"Enter a width from 6 to 16 characters.",optionsTitles[PanelModes]);
SetFocus(GetDlgItem(dialog,201));
SendDlgItemMessageW(dialog,201,EM_SETSEL,0,-1);
return TRUE;
}
const auto owner=draft->filesWindow;
const OptionsWriteLock writeLock;
if(!writeLock.acquired())
{
setStatus(L"Another window is saving Files settings. Try Apply again.");
return TRUE;
}
const auto &base=draft->baseline;
const bool textChanged=draft->text!=base.text;
auto textSettings=ks::files::loadTextPreferences();
for(size_t i=0;i<textSettings.profiles.size();++i)
{
if(draft->text.profiles[i]==base.text.profiles[i]) continue;
if(textSettings.profiles[i]!=base.text.profiles[i] && textSettings.profiles[i]!=draft->text.profiles[i])
{
showFilesNotice(dialog,L"Text settings changed in another window. Reopen Options before editing them again.",
L"KSwitcher Files Options");
return TRUE;
}
textSettings.profiles[i]=draft->text.profiles[i];
}
if(draft->text.schemes!=base.text.schemes)
{
if(textSettings.schemes!=base.text.schemes && textSettings.schemes!=draft->text.schemes)
{
showFilesNotice(dialog,L"Color schemes changed in another window. Reopen Options before editing them again.",
L"KSwitcher Files Options");
return TRUE;
}
textSettings.schemes=draft->text.schemes;
}
auto keys=optionHistoryKeys(dialog);
loadCommandHistoryKeys();
for(size_t i=0;i<keys.size();++i)
if(keys[i]==base.keys[i]) keys[i]=commandHistoryKeys[i];
if(!validHistoryKeys(keys))
{
showFilesNotice(dialog,L"Choose distinct shortcuts using Ctrl or Alt that are free in Files.",L"Command history");
return TRUE;
}
const bool stylesChanged=draft->styles!=base.styles;
loadFileStyles();
if(stylesChanged && fileStyles!=base.styles && fileStyles!=draft->styles)
{
showFilesNotice(dialog,L"File type styles changed in another window. Reopen Options before editing them again.",
L"KSwitcher Files Options");
return TRUE;
}
if(stylesChanged && !saveFileStyles(draft->styles))
{
showFilesNotice(dialog,L"Could not save the file type styles.",L"KSwitcher Files Options");
return TRUE;
}
const bool fontChanged=optionsFontChanged(*draft);
if(fontChanged && !applyFont(owner,draft->font,draft->points))
{
if(stylesChanged) saveFileStyles(fileStyles);
showFilesNotice(dialog,L"Could not apply the selected font.",optionsTitles[Appearance]);
return TRUE;
}
if(textChanged)
{
try { ks::files::saveTextPreferences(textSettings); }
catch(const std::exception &error)
{
if(fontChanged) applyFont(owner,base.font,base.points);
if(stylesChanged) saveFileStyles(fileStyles);
const auto message=L"Could not save text settings. "+ks::wide(error.what());
showFilesNotice(dialog,message.c_str(),L"KSwitcher Files Options");
return TRUE;
}
}
const auto saveChanged=[](const wchar_t *name,DWORD value,DWORD initial)
{
if(value!=initial) savePreference(name,value);
};
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
const auto inputKeys=optionHistoryKeys(dialog);
saveChanged(L"PreviousCommandKey",inputKeys[0],base.keys[0]);
saveChanged(L"CommandHistoryKey",inputKeys[1],base.keys[1]);
saveChanged(ks::lan::remoteTerminalPreference,checked(optionsRemoteTerminal),base.remoteTerminal);
saveChanged(ks::lan::remoteHibernatePreference,checked(optionsRemoteHibernate),base.remoteHibernate);
saveChanged(ks::lan::remoteUpdatePreference,checked(optionsRemoteUpdate),base.remoteUpdate);
saveChanged(L"SizeWidth",width,base.width);
saveChanged(L"AlignExtensions",checked(202),base.extensions);
saveChanged(L"AlignFolders",checked(204),base.folders);
saveChanged(L"ShowDetails",checked(203),base.details);
DWORD fields=preference(L"StatusFields",defaultStatusFields)&15;
const auto initialFields=fields;
for(int i=0;i<4;++i)
{
const DWORD bit=1u<<i;
if(checked(205+i)!=((base.fields&bit)!=0)) fields=checked(205+i)?fields|bit:fields&~bit;
}
saveChanged(L"StatusFields",fields,initialFields);
for(int i=0;i<ColorCount;++i)
saveChanged(std::to_wstring(i).c_str(),draft->palette[i],base.palette[i]);
saveChanged(dialogFrameOuterKey,draft->framePadding.outer,base.padding.outer);
saveChanged(dialogFrameInnerKey,draft->framePadding.inner,base.padding.inner);
saveChanged(dialogRowTextKey,draft->framePadding.rowText,base.padding.rowText);
if(fontChanged)
{
savePreference(L"FontPoints",fontPoints);
RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,L"DisplayFont",REG_BINARY,&displayFont,sizeof(displayFont));
}
saveChanged(L"LeftWide",checked(273),base.wide[0]);
saveChanged(L"RightWide",checked(274),base.wide[1]);
loadFilesPreferences(owner);
notifyFilesPreferences(owner);
const auto dialogOwner=GetWindow(dialog,GW_OWNER);
if(dialogOwner!=owner)
SendMessageW(dialogOwner,filesPreferencesChanged,reinterpret_cast<WPARAM>(owner),0);
draft->baseline=OptionsBaseline{};
draft->text=draft->baseline.text;
populateTextSchemes(dialog,draft->text);
draft->font=displayFont;
draft->points=fontPoints;
draft->styles=fileStyles;
draft->palette=colors;
draft->framePadding=dialogFramePadding;
SetDlgItemInt(dialog,201,sizeWidth,FALSE);
CheckDlgButton(dialog,202,alignExtensions);
CheckDlgButton(dialog,203,showDetails);
CheckDlgButton(dialog,204,alignFolders);
CheckDlgButton(dialog,273,panels[0].wide);
CheckDlgButton(dialog,274,panels[1].wide);
for(int i=0;i<4;++i) CheckDlgButton(dialog,205+i,(statusFields>>i)&1u);
setOptionHistoryKeys(dialog,commandHistoryKeys);
CheckDlgButton(dialog,optionsRemoteTerminal,ks::lan::remoteTerminalAllowed());
CheckDlgButton(dialog,optionsRemoteHibernate,ks::lan::remoteHibernateAllowed());
CheckDlgButton(dialog,optionsRemoteUpdate,ks::lan::remoteUpdateAllowed());
if(draft->page==PanelColors && draft->section==fileStyleSection)
updateFileStyleEditor(dialog,*draft,true);
showOptionsPage(dialog,*draft,draft->page,draft->section);
layout(owner);
showFileDetails(panels[active]);
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN|RDW_UPDATENOW);
if(LOWORD(wp)==IDOK) FilesModalDialog::finish(dialog,IDOK);
else updateOptionsApply(dialog,*draft);
return TRUE;
}
}
return FALSE;
}
void runOptions(HWND owner,int page)
{
OptionsDraft draft;
draft.filesWindow=GetAncestor(owner,GA_ROOTOWNER);
draft.page=page;
draft.section=textOptionsPage(page)?0:PanelLayout;
std::copy(std::begin(colors),std::end(colors),draft.palette.begin());
const auto result=FilesModalDialog::run(GetModuleHandleW(nullptr),MAKEINTRESOURCEW(101),owner,optionsDialog,
reinterpret_cast<LPARAM>(&draft));
if(result==-1) setStatus(L"Could not open Files Options.");
}
void editOptions(HWND owner)
{
runOptions(owner,PanelModes);
SetFocus(panels[active].list);
}
void editTextOptions(HWND owner,bool edit)
{
runOptions(owner,edit?TextEditor:TextViewer);
}
+169
View File
@@ -0,0 +1,169 @@
void paintOptionsPreview(HWND dialog,const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
{
const auto dc=item.hDC;
const auto saved=SaveDC(dc);
if(!saved) return;
IntersectClipRect(dc,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
const auto dpi=GetDpiForWindow(item.hwndItem);
const auto px=[&](int value){return MulDiv(value,dpi,96);};
auto font=draft.font;
font.lfHeight=-MulDiv(draft.points,dpi,720);
font.lfWidth=0;
const auto previewFont=CreateFontIndirectW(&font);
SelectObject(dc,previewFont?previewFont:mono);
TEXTMETRICW metrics{};
GetTextMetricsW(dc,&metrics);
const auto rowHeight=std::max<LONG>(px(18),metrics.tmHeight+px(4));
const auto &palette=draft.palette;
const auto fill=[&](RECT bounds,COLORREF color)
{
SetBkColor(dc,color);
ExtTextOutW(dc,0,0,ETO_OPAQUE|ETO_CLIPPED,&bounds,nullptr,0,nullptr);
};
const auto row=[&](RECT bounds,std::wstring_view label,COLORREF fg,COLORREF bg,bool right=false)
{
fill(bounds,bg);
bounds.left+=px(5);
bounds.right-=px(5);
SetBkMode(dc,TRANSPARENT);
SetTextColor(dc,fg);
DrawTextW(dc,label.data(),static_cast<int>(label.size()),&bounds,
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS|(right?DT_RIGHT:DT_LEFT));
};
const auto checked=[&](int id){return IsDlgButtonChecked(dialog,id)==BST_CHECKED;};
auto bounds=item.rcItem;
fill(bounds,palette[PanelBlue]);
const auto frame=[&](RECT rect)
{
SetDCBrushColor(dc,palette[Frame]);
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
InflateRect(&rect,-px(2),-px(2));
FrameRect(dc,&rect,static_cast<HBRUSH>(GetStockObject(DC_BRUSH)));
};
const auto panel=[&](RECT panelBounds,bool wide,bool cursor,std::wstring_view path)
{
frame(panelBounds);
InflateRect(&panelBounds,-px(4),-px(4));
auto line=panelBounds;
line.bottom=line.top+rowHeight;
row(line,path,palette[Frame],palette[PanelBlue]);
OffsetRect(&line,0,rowHeight);
BOOL valid{};
const auto input=GetDlgItemInt(dialog,201,&valid,FALSE);
const auto columns=valid?std::clamp(input,6u,16u):static_cast<UINT>(sizeWidth);
const auto entry=[&](std::wstring_view stem,std::wstring_view ext,bool folder,bool highlight)
{
const auto bg=highlight?palette[PanelCursor]:palette[PanelBlue];
auto name=line;
if(wide)
{
name.right=std::max(name.left+px(20),name.right-static_cast<LONG>(columns+2)*metrics.tmAveCharWidth);
name.right=std::min(name.right,line.right);
auto amount=line;
amount.left=name.right;
row(amount,folder?L"Folder":formatSize(48382,columns),palette[Text],bg,true);
fill({amount.left,amount.top,amount.left+px(1),amount.bottom},palette[Frame]);
}
if(wide && checked(folder?204:202) && name.right-name.left>px(70))
{
auto extension=name;
extension.left=extension.right-std::min<LONG>(px(60),(extension.right-extension.left)/2);
name.right=extension.left;
row(extension,ext,palette[Text],bg,true);
row(name,stem,palette[Text],bg);
}
else row(name,std::wstring(stem)+L"."+std::wstring(ext),palette[Text],bg);
OffsetRect(&line,0,rowHeight);
};
entry(L"report",L"txt",false,cursor);
entry(L"examples",L"old",true,false);
};
if(draft.page==Appearance && draft.section==DialogFrame)
{
InflateRect(&bounds,-px(8),-px(8));
const auto framePadding=dialogFrameMetrics(dpi,draft.framePadding);
const auto surface=CreateSolidBrush(palette[Dialog]);
const auto ink=CreateSolidBrush(palette[Text]);
const auto frame=paintDialogFrame(dc,bounds,framePadding,surface,ink);
const auto titleFont=previewFont?previewFont:mono;
paintDialogFrameCaptions(dc,bounds,frame,L"Folder shortcuts",surface,palette[Text],titleFont);
DeleteObject(ink);
DeleteObject(surface);
auto content=bounds;
InflateRect(&content,-framePadding.content(),-dialogListInset(framePadding,metrics.tmHeight));
content.bottom=std::min(content.bottom,content.top+rowHeight);
if(content.bottom>content.top)
paintDialogListRow(dc,content,LR"(C:\Projects\Example\Documents\Archive)",titleFont,
palette[Text],palette[Selection],dialogRowTextInset(metrics.tmAveCharWidth,draft.framePadding));
}
else if(draft.page==PanelColors && (draft.section==Dialog || draft.section==Selection))
{
InflateRect(&bounds,-px(12),-px(10));
const auto maxWidth=px(340);
bounds.right=std::min(bounds.right,bounds.left+maxWidth);
fill(bounds,palette[Dialog]);
frame(bounds);
InflateRect(&bounds,-px(5),-px(5));
auto line=bounds;
line.bottom=line.top+rowHeight;
row(line,L"Change drive",palette[Text],palette[Dialog]);
OffsetRect(&line,0,rowHeight);
row(line,L"C: fixed",palette[Text],palette[Selection]);
OffsetRect(&line,0,rowHeight);
row(line,L"D: fixed",palette[Text],palette[Dialog]);
}
else if(draft.page==PanelColors && draft.section==Footer)
{
auto bar=bounds;
bar.top=bar.bottom-rowHeight;
row(bar,L"F2 Refresh F3 View F5 Copy F6 Move",palette[Text],palette[Footer]);
bounds.bottom=bar.top;
panel(bounds,false,false,L"C:\\Example");
}
else if(draft.page==PanelModes && draft.section==PanelLayout)
{
auto left=bounds,right=bounds;
left.right=(bounds.left+bounds.right)/2;
right.left=left.right+px(4);
panel(left,checked(273),true,L"C:\\Example");
panel(right,checked(274),false,L"D:\\Example");
}
else if(draft.page==PanelModes && draft.section==StatusColumns)
{
auto statusRow=bounds;
statusRow.top=statusRow.bottom-rowHeight;
bounds.bottom=statusRow.top;
panel(bounds,true,true,L"C:\\Example");
std::wstring label;
if(checked(203))
{
constexpr const wchar_t *values[]{L"report.txt",L"47 KiB",L"2026-11-14",L"10:30"};
for(int i=0;i<4;++i)
if(checked(205+i))
{
if(!label.empty()) label+=L" ";
label+=values[i];
}
}
row(statusRow,label,palette[Text],palette[PanelBlue]);
}
else panel(bounds,draft.page==PanelModes || checked(273),
draft.page==PanelColors && draft.section==PanelCursor,L"C:\\Example");
if(draft.page==PanelColors && (draft.section==Text || draft.section==Dialog))
{
auto field=item.rcItem;
InflateRect(&field,-px(12),-px(10));
field.right=std::min(field.right,field.left+px(340));
field.top+=px(5)+4*rowHeight;
field.bottom=field.top+rowHeight+px(6);
fill(field,RGB(192,192,192));
InflateRect(&field,-px(4),-px(3));
row(field,L"C:\\Example",palette[Text],palette[Dialog]);
}
RestoreDC(dc,saved);
if(previewFont) DeleteObject(previewFont);
}
+209
View File
@@ -0,0 +1,209 @@
constexpr int fileStyleSection=ColorCount;
constexpr int fileStyleRules=288,fileStyleSuggestion=291,fileStylePreview=299,fileStyleState=301;
constexpr const wchar_t *fileStyleStateNames[]{L"Normal",L"Marked (Insert)",L"Focused cursor",L"Unfocused cursor"};
void layoutFileStyleFields(HWND dialog,bool redraw)
{
RECT client{};
GetClientRect(dialog,&client);
RECT spacing{0,0,8,0};
MapDialogRect(dialog,&spacing);
const auto gap=spacing.right;
const auto dpi=GetDpiForWindow(dialog);
const auto arrow=GetSystemMetricsForDpi(SM_CXVSCROLL,dpi);
const auto border=GetSystemMetricsForDpi(SM_CXEDGE,dpi);
const auto flags=SWP_NOZORDER|SWP_NOACTIVATE|SWP_NOCOPYBITS|SWP_NOREDRAW;
const auto fit=[&](int id,int buttonId)
{
const auto combo=GetDlgItem(dialog,id);
const auto bounds=dialogControlBounds(dialog,id);
const auto dc=GetDC(combo);
if(!dc) return;
const auto previous=SelectObject(dc,reinterpret_cast<HFONT>(SendMessageW(combo,WM_GETFONT,0,0)));
SIZE extent{};
const std::wstring_view example=L".example";
GetTextExtentPoint32W(dc,example.data(),static_cast<int>(example.size()),&extent);
LONG longest=extent.cx;
const auto count=SendMessageW(combo,CB_GETCOUNT,0,0);
for(LRESULT row=0;row<count;++row)
{
const auto length=SendMessageW(combo,CB_GETLBTEXTLEN,row,0);
if(length<=0) continue;
std::wstring item(static_cast<size_t>(length)+1,L'\0');
if(SendMessageW(combo,CB_GETLBTEXT,row,reinterpret_cast<LPARAM>(item.data()))!=CB_ERR &&
GetTextExtentPoint32W(dc,item.data(),static_cast<int>(length),&extent))
longest=std::max(longest,extent.cx);
}
if(previous) SelectObject(dc,previous);
ReleaseDC(combo,dc);
const auto button=buttonId?dialogControlBounds(dialog,buttonId):RECT{};
const auto buttonWidth=button.right-button.left;
const auto available=client.right-bounds.left-gap-(buttonId?gap+buttonWidth:0);
const auto width=std::max(1L,std::min(available,longest+arrow+2*border+gap));
RECT dropped{};
SendMessageW(combo,CB_GETDROPPEDCONTROLRECT,0,reinterpret_cast<LPARAM>(&dropped));
SetWindowPos(combo,nullptr,bounds.left,bounds.top,width,dropped.bottom-dropped.top,flags);
if(buttonId)
SetWindowPos(GetDlgItem(dialog,buttonId),nullptr,bounds.left+width+gap,button.top,
buttonWidth,button.bottom-button.top,flags);
};
fit(fileStyleRules,289);
fit(fileStyleSuggestion,292);
fit(fileStyleState,0);
if(redraw)
RedrawWindow(dialog,nullptr,nullptr,RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN);
}
void fillFileStyleSuggestions(HWND dialog)
{
std::set<std::wstring> extensions;
for(DWORD i=0;;++i)
{
wchar_t name[256]{};
DWORD length=std::size(name);
const auto result=RegEnumKeyExW(HKEY_CLASSES_ROOT,i,name,&length,nullptr,nullptr,nullptr,nullptr);
if(result==ERROR_MORE_DATA) continue;
if(result!=ERROR_SUCCESS) break;
if(name[0]!=L'.') continue;
auto extension=fileStyleExtension(name);
if(!extension.empty()) extensions.insert(std::move(extension));
}
for(const auto &extension:extensions)
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(extension.c_str()));
SendDlgItemMessageW(dialog,fileStyleSuggestion,CB_LIMITTEXT,255,0);
for(const auto label:fileStyleStateNames)
SendDlgItemMessageW(dialog,fileStyleState,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
SendDlgItemMessageW(dialog,fileStyleState,CB_SETCURSEL,0,0);
}
void updateFileStyleEditor(HWND dialog,OptionsDraft &draft,bool rebuild)
{
const auto combo=GetDlgItem(dialog,fileStyleRules);
if(rebuild)
{
SendMessageW(combo,CB_RESETCONTENT,0,0);
for(const auto &style:draft.styles)
{
const auto label=style.extension==L"@system-hidden"?L"System and hidden":style.extension.c_str();
SendMessageW(combo,CB_ADDSTRING,0,reinterpret_cast<LPARAM>(label));
}
draft.styleIndex=draft.styles.empty()?-1:std::clamp(draft.styleIndex,0,static_cast<int>(draft.styles.size())-1);
SendMessageW(combo,CB_SETCURSEL,draft.styleIndex,0);
layoutFileStyleFields(dialog,true);
}
InvalidateRect(GetDlgItem(dialog,fileStylePreview),nullptr,FALSE);
const bool selected=draft.styleIndex>=0;
for(const int id:{289,293,294,295,297,298})
EnableWindow(GetDlgItem(dialog,id),selected);
if(!selected)
{
SetDlgItemTextW(dialog,296,L"");
CheckDlgButton(dialog,297,BST_UNCHECKED);
CheckDlgButton(dialog,298,BST_UNCHECKED);
return;
}
const auto &style=draft.styles[draft.styleIndex];
const auto colorLabel=[](std::optional<COLORREF> color)->std::wstring
{
if(!color) return L"Default";
wchar_t hex[8]{};
swprintf_s(hex,L"#%02X%02X%02X",unsigned(GetRValue(*color)),unsigned(GetGValue(*color)),unsigned(GetBValue(*color)));
return hex;
};
const auto state=SendDlgItemMessageW(dialog,fileStyleState,CB_GETCURSEL,0,0);
const auto fg=state>0?style.states[state-1].foreground:style.foreground;
const auto bg=state>0?style.states[state-1].background:style.background;
const auto label=L"Foreground: "+colorLabel(fg)+L" Background: "+colorLabel(bg);
SetDlgItemTextW(dialog,296,label.c_str());
CheckDlgButton(dialog,297,style.underline);
CheckDlgButton(dialog,298,style.strikeout);
}
bool addFileStyle(HWND dialog,OptionsDraft &draft)
{
wchar_t input[256]{};
GetDlgItemTextW(dialog,fileStyleSuggestion,input,std::size(input));
const auto extension=fileStyleExtension(input);
if(extension.empty())
{
showFilesNotice(dialog,L"Enter a file extension such as .txt.",L"File type styles");
SetFocus(GetDlgItem(dialog,fileStyleSuggestion));
return false;
}
const auto existing=std::find_if(draft.styles.begin(),draft.styles.end(),
[&](const auto &style){return sameFileExtension(style.extension,extension);});
if(existing!=draft.styles.end()) draft.styleIndex=static_cast<int>(existing-draft.styles.begin());
else
{
if(draft.styles.size()>=64)
{
showFilesNotice(dialog,L"The profile can contain up to 64 file type rules.",L"File type styles");
return false;
}
draft.styles.push_back({extension});
draft.styleIndex=static_cast<int>(draft.styles.size())-1;
}
SetDlgItemTextW(dialog,fileStyleSuggestion,L"");
updateFileStyleEditor(dialog,draft,true);
return true;
}
void paintFileStylePreview(const DRAWITEMSTRUCT &item,const OptionsDraft &draft)
{
const auto saved=SaveDC(item.hDC);
const bool selected=draft.styleIndex>=0 && static_cast<size_t>(draft.styleIndex)<draft.styles.size();
const auto extension=selected?std::wstring_view(draft.styles[draft.styleIndex].extension):std::wstring_view{};
const bool systemHidden=extension==L"@system-hidden";
SetBkColor(item.hDC,draft.palette[PanelBlue]);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&item.rcItem,nullptr,0,nullptr);
if(selected)
{
auto font=draft.font;
font.lfHeight=-MulDiv(draft.points,GetDpiForWindow(item.hwndItem),720);
font.lfWidth=0;
const auto fontHandle=CreateFontIndirectW(&font);
if(fontHandle) SelectObject(item.hDC,fontHandle);
TEXTMETRICW metrics{};
GetTextMetricsW(item.hDC,&metrics);
const int height=metrics.tmHeight+MulDiv(4,GetDpiForWindow(item.hwndItem),96);
if(fontHandle)
{
SelectObject(item.hDC,mono?mono:GetStockObject(SYSTEM_FONT));
DeleteObject(fontHandle);
}
IntersectClipRect(item.hDC,item.rcItem.left,item.rcItem.top,item.rcItem.right,item.rcItem.bottom);
for(int i=0;i<4;++i)
{
const auto state=static_cast<FileRowState>(i);
const auto background=draft.palette[state==FileRowState::Focused?PanelCursor:PanelBlue];
const auto style=resolveFileStyle(extension,!systemHidden,systemHidden,
state==FileRowState::Focused || state==FileRowState::Marked,
draft.palette[Text],background,draft.styles,state);
auto decorated=font;
if(style.decorations)
{
decorated.lfUnderline=(style.decorations&1)!=0;
decorated.lfStrikeOut=(style.decorations&2)!=0;
}
const auto previewFont=CreateFontIndirectW(&decorated);
const auto previous=SelectObject(item.hDC,previewFont?previewFont:mono);
auto row=item.rcItem;
row.top+=i*height;
row.bottom=row.top+height;
SetBkColor(item.hDC,style.background);
ExtTextOutW(item.hDC,0,0,ETO_OPAQUE,&row,nullptr,0,nullptr);
constexpr const wchar_t *samples[]{L"normal-sample",L"marked-sample",L"focused-cursor",L"unfocused-cursor"};
const auto label=std::wstring(samples[i])+(systemHidden?L" (hidden)":std::wstring(extension));
row.left+=5;
row.right-=5;
SetBkMode(item.hDC,TRANSPARENT);
SetTextColor(item.hDC,style.foreground);
DrawTextW(item.hDC,label.c_str(),static_cast<int>(label.size()),&row,
DT_SINGLELINE|DT_VCENTER|DT_NOPREFIX|DT_END_ELLIPSIS);
SelectObject(item.hDC,previous);
if(previewFont) DeleteObject(previewFont);
}
}
RestoreDC(item.hDC,saved);
}
+180
View File
@@ -0,0 +1,180 @@
enum class FileRowState { Normal, Marked, Focused, Unfocused };
struct FileStyleColors
{
std::optional<COLORREF> foreground,background;
bool operator==(const FileStyleColors &) const = default;
};
struct FileStyle
{
std::wstring extension;
std::optional<COLORREF> foreground,background;
bool underline{},strikeout{};
std::array<FileStyleColors,3> states{};
bool operator==(const FileStyle &) const = default;
};
const std::vector<FileStyle> defaultFileStyles{
{L"@system-hidden"},{L".exe",RGB(85,255,85)},{L".md",RGB(231,192,247)},
{L".7z",RGB(255,210,128)},{L".zip",RGB(255,155,170)},{L".rar",RGB(255,210,128)},
{L".tar",RGB(255,210,128)},{L".gz",RGB(255,210,128)},
{L".png",RGB(165,230,255)},{L".jpg",RGB(165,230,255)},{L".jpeg",RGB(165,230,255)},
{L".gif",RGB(165,230,255)},{L".webp",RGB(165,230,255)},{L".svg",RGB(165,230,255)},
{L".mp3",RGB(255,185,216)},{L".wav",RGB(255,185,216)},{L".flac",RGB(255,185,216)},
{L".mp4",RGB(255,185,216)},{L".mkv",RGB(255,185,216)},{L".mov",RGB(255,185,216)},
{L".pdf",RGB(255,229,168)},{L".docx",RGB(255,229,168)},{L".xlsx",RGB(255,229,168)},
{L".pptx",RGB(255,229,168)},{L".odt",RGB(255,229,168)},
{L".cpp",RGB(168,247,190)},{L".h",RGB(168,247,190)},{L".cs",RGB(168,247,190)},
{L".py",RGB(168,247,190)},{L".js",RGB(168,247,190)},{L".ts",RGB(168,247,190)},
{L".ps1",RGB(168,247,190)},{L".bat",RGB(168,247,190)},{L".cmd",RGB(168,247,190)}};
std::vector<FileStyle> fileStyles=defaultFileStyles;
constexpr wchar_t fileStyleKey[]=L"FileStyles";
std::wstring fileStyleExtension(std::wstring_view text)
{
const auto first=text.find_first_not_of(L" \t");
if(first==text.npos) return {};
auto extension=std::wstring(text.substr(first,text.find_last_not_of(L" \t")-first+1));
if(extension.front()!=L'.') extension.insert(0,1,L'.');
if(extension.size()<2 || extension.size()>255 ||
extension.find_first_of(L". *?\\/:<>|\";\r\n\t",1)!=extension.npos) return {};
CharLowerBuffW(extension.data(),static_cast<DWORD>(extension.size()));
return extension;
}
bool sameFileExtension(std::wstring_view left,std::wstring_view right)
{
return !left.empty() && left.size()==right.size() &&
CompareStringOrdinal(left.data(),static_cast<int>(left.size()),
right.data(),static_cast<int>(right.size()),TRUE)==CSTR_EQUAL;
}
void loadFileStyles()
{
DWORD bytes{};
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,nullptr,&bytes)!=ERROR_SUCCESS ||
!bytes || bytes>256*1024) return;
std::string data(bytes,'\0');
if(RegGetValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,RRF_RT_REG_BINARY,nullptr,data.data(),&bytes)!=ERROR_SUCCESS)
return;
try
{
const auto rows=Json::parse(data.begin(),data.begin()+bytes);
if(!rows.is_array() || rows.size()>64) return;
std::vector<FileStyle> parsed;
for(const auto &row:rows)
{
FileStyle style{ks::wide(row.at("extension").get<std::string>())};
if(style.extension!=L"@system-hidden" && fileStyleExtension(style.extension)!=style.extension) return;
if(std::any_of(parsed.begin(),parsed.end(),[&](const auto &existing){return existing.extension==style.extension;}))
return;
const auto color=[&](const char *key)->std::optional<COLORREF>
{
if(!row.contains(key)) return {};
const auto value=row.at(key).get<DWORD>();
if(value>0xffffff) throw std::runtime_error("Invalid file style color");
return value;
};
style.foreground=color("foreground");
style.background=color("background");
style.underline=row.value("underline",false);
style.strikeout=row.value("strikeout",false);
if(row.contains("states"))
{
const auto &states=row.at("states");
if(!states.is_array() || states.size()!=style.states.size()) return;
for(size_t i=0;i<style.states.size();++i)
{
const auto read=[&](const char *key)->std::optional<COLORREF>
{
if(!states[i].contains(key)) return {};
const auto value=states[i].at(key).get<DWORD>();
if(value>0xffffff) throw std::runtime_error("Invalid state color");
return value;
};
style.states[i]={read("foreground"),read("background")};
}
}
parsed.push_back(std::move(style));
}
fileStyles=std::move(parsed);
}
catch(const std::exception &) {}
}
bool saveFileStyles(const std::vector<FileStyle> &styles)
{
auto rows=Json::array();
for(const auto &style:styles)
{
Json row{{"extension",ks::utf8(style.extension)},{"underline",style.underline},{"strikeout",style.strikeout}};
if(style.foreground) row["foreground"]=*style.foreground;
if(style.background) row["background"]=*style.background;
row["states"]=Json::array();
for(const auto &state:style.states)
{
auto entry=Json::object();
if(state.foreground) entry["foreground"]=*state.foreground;
if(state.background) entry["background"]=*state.background;
row["states"].push_back(std::move(entry));
}
rows.push_back(std::move(row));
}
const auto data=rows.dump();
return RegSetKeyValueW(HKEY_CURRENT_USER,schemeKey,fileStyleKey,REG_BINARY,data.data(),
static_cast<DWORD>(data.size()))==ERROR_SUCCESS;
}
struct FileRowStyle
{
COLORREF foreground,background;
unsigned decorations{};
};
FileRowStyle resolveFileStyle(std::wstring_view extension,bool file,bool dimmed,bool protectedRow,
COLORREF foreground,COLORREF background,const std::vector<FileStyle> &styles=fileStyles,
FileRowState state=FileRowState::Normal,bool transferred=false)
{
FileRowStyle result{foreground,background};
const FileStyle *extensionRule=nullptr,*attributeRule=nullptr;
for(const auto &style:styles)
{
if(!file || !sameFileExtension(style.extension,extension)) continue;
extensionRule=&style;
result.foreground=style.foreground.value_or(result.foreground);
if(!protectedRow) result.background=style.background.value_or(result.background);
result.decorations=(style.underline?1u:0u)|(style.strikeout?2u:0u);
break;
}
if(dimmed)
for(const auto &style:styles)
{
if(style.extension!=L"@system-hidden") continue;
attributeRule=&style;
if(style.foreground) result.foreground=*style.foreground;
else
{
const auto fg=result.foreground,bg=result.background;
result.foreground=RGB((3*GetRValue(fg)+2*GetRValue(bg))/5,
(3*GetGValue(fg)+2*GetGValue(bg))/5,(3*GetBValue(fg)+2*GetBValue(bg))/5);
}
if(!protectedRow) result.background=style.background.value_or(result.background);
result.decorations|=(style.underline?1u:0u)|(style.strikeout?2u:0u);
break;
}
if(transferred && state!=FileRowState::Marked)
return {RGB(255,223,115),background,result.decorations};
if(state==FileRowState::Marked) result.foreground=RGB(255,255,0);
if(state!=FileRowState::Normal)
{
const auto index=static_cast<size_t>(state)-1;
for(const auto *rule:{extensionRule,attributeRule})
if(rule)
{
result.foreground=rule->states[index].foreground.value_or(result.foreground);
result.background=rule->states[index].background.value_or(result.background);
}
}
if(result.foreground==result.background && state!=FileRowState::Normal)
result.foreground=GetRValue(result.background)+GetGValue(result.background)+GetBValue(result.background)>382?RGB(0,0,0):RGB(255,255,255);
return result;
}
+110
View File
@@ -0,0 +1,110 @@
#pragma once
#include <mutex>
#include <string>
#include <thread>
#include <vector>
// The reader keeps draining the output pipe while ClosePseudoConsole shuts down.
class FilesTerminal
{
public:
~FilesTerminal() { close(); }
bool start(const wchar_t *directory, COORD size)
{
if(alive()) return true;
close();
pending.clear();
HANDLE inputRead{}, outputWrite{};
if(!CreatePipe(&inputRead,&input,nullptr,0)) return false;
if(!CreatePipe(&output,&outputWrite,nullptr,0))
{
CloseHandle(inputRead);
close();
return false;
}
const auto created=CreatePseudoConsole(size,inputRead,outputWrite,0,&console);
CloseHandle(inputRead);
CloseHandle(outputWrite);
if(FAILED(created)) { close(); return false; }
reader=std::thread([this]
{
char buffer[4096];
DWORD count{};
while(ReadFile(output,buffer,sizeof(buffer),&count,nullptr) && count)
{
std::lock_guard lock(mutex);
pending.append(buffer,count);
if(pending.size()>1024*1024) pending.erase(0,pending.size()-1024*1024);
}
});
SIZE_T bytes{};
InitializeProcThreadAttributeList(nullptr,1,0,&bytes);
std::vector<unsigned char> storage(bytes);
auto attributes=reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(storage.data());
if(!InitializeProcThreadAttributeList(attributes,1,0,&bytes)) { close(); return false; }
STARTUPINFOEXW startup{};
startup.StartupInfo.cb=sizeof(startup);
startup.lpAttributeList=attributes;
PROCESS_INFORMATION info{};
wchar_t command[]=L"powershell.exe -NoLogo -NoProfile -NoExit -Command \"Remove-Module PSReadLine -ErrorAction SilentlyContinue; function global:prompt { $location = Get-Location; $path = if ($location.Provider.Name -eq 'FileSystem') { [Uri]::EscapeDataString($location.ProviderPath) }; ([string][char]27) + ']633;ks-directory:' + $path + [char]7 + [char]27 + '[?25l' }; Clear-Host\"";
wchar_t system[MAX_PATH]{};
const auto length=GetSystemDirectoryW(system,MAX_PATH);
const auto shell=std::wstring(system)+L"\\WindowsPowerShell\\v1.0\\powershell.exe";
const bool started=length && length<MAX_PATH &&
UpdateProcThreadAttribute(attributes,0,PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
console,sizeof(console),nullptr,nullptr) &&
CreateProcessW(shell.c_str(),command,nullptr,nullptr,FALSE,EXTENDED_STARTUPINFO_PRESENT,
nullptr,directory,&startup.StartupInfo,&info);
DeleteProcThreadAttributeList(attributes);
if(!started) { close(); return false; }
process=info.hProcess;
CloseHandle(info.hThread);
return true;
}
bool alive() const { return process && WaitForSingleObject(process,0)==WAIT_TIMEOUT; }
bool write(const std::wstring &value)
{
if(!alive()) return false;
const int length=WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
nullptr,0,nullptr,nullptr);
if(!length) return false;
std::string utf8(length,'\0');
WideCharToMultiByte(CP_UTF8,0,value.data(),static_cast<int>(value.size()),
utf8.data(),length,nullptr,nullptr);
DWORD written{};
return WriteFile(input,utf8.data(),length,&written,nullptr) && written==utf8.size();
}
void resize(COORD size) { if(console) ResizePseudoConsole(console,size); }
std::string takeOutput()
{
std::lock_guard lock(mutex);
std::string result;
result.swap(pending);
return result;
}
void close()
{
if(input) CloseHandle(input);
if(console) ClosePseudoConsole(console);
if(reader.joinable()) reader.join();
if(output) CloseHandle(output);
if(process) CloseHandle(process);
input=output=process=nullptr;
console=nullptr;
}
private:
HPCON console{};
HANDLE input{},output{},process{};
std::thread reader;
std::mutex mutex;
std::string pending;
};
+189
View File
@@ -0,0 +1,189 @@
#pragma once
#include <WebView2.h>
#ifdef __MINGW32__
__CRT_UUID_DECL(ICoreWebView2_3, 0xA0D6DF20, 0x3B92, 0x416D, 0xAA, 0x0C, 0x43, 0x7A, 0x9C, 0x72, 0x78, 0x57)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler, 0x4e8a3389, 0xc9d8, 0x4bd2, 0xb6, 0xb5, 0x12, 0x4f, 0xee, 0x6c, 0xc1, 0x4d)
__CRT_UUID_DECL(ICoreWebView2CreateCoreWebView2ControllerCompletedHandler, 0x6c4819f3, 0xc9b7, 0x4260, 0x81, 0x27, 0xc9, 0xf5, 0xbd, 0xe7, 0xf6, 0x8c)
__CRT_UUID_DECL(ICoreWebView2WebMessageReceivedEventHandler, 0x57213f19, 0x00e6, 0x49fa, 0x8e, 0x07, 0x89, 0x8e, 0xa0, 0x1e, 0xcb, 0xd2)
__CRT_UUID_DECL(ICoreWebView2NewWindowRequestedEventHandler, 0xd4c185fe, 0xc81c, 0x4989, 0x97, 0xaf, 0x2d, 0x3f, 0xa7, 0xab, 0x56, 0x51)
__CRT_UUID_DECL(ICoreWebView2NavigationStartingEventHandler, 0x9adbe429, 0xf36d, 0x432b, 0x9d, 0xdc, 0xf8, 0x88, 0x1f, 0xbd, 0x76, 0xe3)
#endif
#include <wrl/client.h>
#include <functional>
#include <memory>
#include <utility>
template<class Interface, class... Args>
class TerminalCallback final : public Interface
{
ULONG refs=1;
std::function<HRESULT(Args...)> invoke;
public:
explicit TerminalCallback(std::function<HRESULT(Args...)> fn):invoke(std::move(fn)) {}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid,void **value) override
{
if(iid!=IID_IUnknown && iid!=__uuidof(Interface)) { *value=nullptr; return E_NOINTERFACE; }
*value=static_cast<Interface*>(this);
AddRef();
return S_OK;
}
ULONG STDMETHODCALLTYPE AddRef() override { return ++refs; }
ULONG STDMETHODCALLTYPE Release() override
{
const auto remaining=--refs;
if(!remaining) delete this;
return remaining;
}
HRESULT STDMETHODCALLTYPE Invoke(Args... args) override { return invoke(args...); }
};
template<class Interface,class... Args,class Fn>
auto terminalCallback(Fn fn)
{
Microsoft::WRL::ComPtr<Interface> result;
result.Attach(new TerminalCallback<Interface,Args...>(std::move(fn)));
return result;
}
class FilesTerminalView
{
Microsoft::WRL::ComPtr<ICoreWebView2Controller> controller;
Microsoft::WRL::ComPtr<ICoreWebView2> web;
HMODULE loader{};
bool ready{};
bool visible{};
int viewportHeight{};
public:
bool initialized() const { return ready; }
void enableTrace(bool output)
{
if(ready) web->PostWebMessageAsString(output?L"trace:output":L"trace:on");
}
void setFont(const LOGFONTW &font,int points)
{
if(!ready) return;
const auto message=L"font:"+std::to_wstring(points)+L":"+
std::to_wstring(font.lfWeight?font.lfWeight:FW_NORMAL)+L":"+
std::to_wstring(font.lfItalic!=0)+L":"+font.lfFaceName;
web->PostWebMessageAsString(message.c_str());
}
void layout(HWND host,bool show,int fullHeight)
{
visible=show;
viewportHeight=fullHeight;
if(!controller) return;
RECT bounds{};
GetClientRect(host,&bounds);
bounds.top=bounds.bottom-viewportHeight;
RECT current{};
if(FAILED(controller->get_Bounds(&current)) || !EqualRect(&current,&bounds))
controller->put_Bounds(bounds);
BOOL currentVisibility{};
if(FAILED(controller->get_IsVisible(&currentVisibility)) || !!currentVisibility!=show)
controller->put_IsVisible(show);
}
void close()
{
if(controller) controller->Close();
web.Reset();
controller.Reset();
ready=false;
}
bool send(const std::string &bytes)
{
if(!ready) return false;
constexpr wchar_t hex[]=L"0123456789abcdef";
std::wstring message;
message.reserve(bytes.size()*2);
for(unsigned char c:bytes)
{
message+=hex[c>>4];
message+=hex[c&15];
}
return SUCCEEDED(web->PostWebMessageAsString(message.c_str()));
}
bool create(HWND host,std::function<void(const std::wstring&)> receive,std::function<void()> failed)
{
wchar_t exe[MAX_PATH]{};
if(!GetModuleFileNameW(nullptr,exe,MAX_PATH)) return false;
const auto folder=std::filesystem::path(exe).parent_path();
loader=LoadLibraryExW((folder/L"WebView2Loader.dll").c_str(),nullptr,LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|LOAD_LIBRARY_SEARCH_SYSTEM32);
if(!loader) return false;
using Create=HRESULT (STDAPICALLTYPE*)(PCWSTR,PCWSTR,ICoreWebView2EnvironmentOptions*,
ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler*);
const auto createEnvironment=reinterpret_cast<Create>(
reinterpret_cast<void*>(GetProcAddress(loader,"CreateCoreWebView2EnvironmentWithOptions")));
if(!createEnvironment) return false;
wchar_t local[MAX_PATH]{};
const auto length=GetEnvironmentVariableW(L"LOCALAPPDATA",local,MAX_PATH);
if(!length || length>=MAX_PATH) return false;
const auto profile=std::filesystem::path(local)/L"KSwitcher"/L"TerminalWebView";
const auto assets=folder/L"terminal";
auto environment=terminalCallback<ICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler,
HRESULT,ICoreWebView2Environment*>([=,this](HRESULT hr,ICoreWebView2Environment *env)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(hr) || !env) { failed(); return S_OK; }
auto completed=terminalCallback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler,
HRESULT,ICoreWebView2Controller*>([=,this](HRESULT result,ICoreWebView2Controller *view)
{
if(!IsWindow(host)) return S_OK;
if(FAILED(result) || !view) { failed(); return S_OK; }
controller=view;
view->get_CoreWebView2(&web);
Microsoft::WRL::ComPtr<ICoreWebView2_3> mapped;
if(!web || FAILED(web.As(&mapped)) || FAILED(mapped->SetVirtualHostNameToFolderMapping(
L"kswitcher-terminal.invalid",assets.c_str(),COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY)))
{ failed(); return S_OK; }
Microsoft::WRL::ComPtr<ICoreWebView2Settings> settings;
if(SUCCEEDED(web->get_Settings(&settings)))
{
settings->put_AreDevToolsEnabled(FALSE);
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_AreHostObjectsAllowed(FALSE);
}
EventRegistrationToken token{};
auto navigation=terminalCallback<ICoreWebView2NavigationStartingEventHandler,
ICoreWebView2*,ICoreWebView2NavigationStartingEventArgs*>([](auto*,auto *args)
{
PWSTR uri{};
if(SUCCEEDED(args->get_Uri(&uri)))
{
args->put_Cancel(wcscmp(uri,L"https://kswitcher-terminal.invalid/index.html")!=0);
CoTaskMemFree(uri);
}
return S_OK;
});
auto newWindow=terminalCallback<ICoreWebView2NewWindowRequestedEventHandler,
ICoreWebView2*,ICoreWebView2NewWindowRequestedEventArgs*>([](auto*,auto *args)
{ args->put_Handled(TRUE); return S_OK; });
auto messages=terminalCallback<ICoreWebView2WebMessageReceivedEventHandler,
ICoreWebView2*,ICoreWebView2WebMessageReceivedEventArgs*>([=,this](auto*,auto *args)
{
PWSTR source{}, value{};
if(SUCCEEDED(args->get_Source(&source)) &&
wcscmp(source,L"https://kswitcher-terminal.invalid/index.html")==0 &&
SUCCEEDED(args->TryGetWebMessageAsString(&value)))
{
if(wcscmp(value,L"ready")==0) ready=true;
if(ready) receive(value);
}
CoTaskMemFree(value);
CoTaskMemFree(source);
return S_OK;
});
if(FAILED(web->add_NavigationStarting(navigation.Get(),&token)) ||
FAILED(web->add_NewWindowRequested(newWindow.Get(),&token)) ||
FAILED(web->add_WebMessageReceived(messages.Get(),&token)) ||
FAILED(web->Navigate(L"https://kswitcher-terminal.invalid/index.html")))
failed();
layout(host,visible,viewportHeight);
return S_OK;
});
if(FAILED(env->CreateCoreWebView2Controller(host,completed.Get()))) failed();
return S_OK;
});
return SUCCEEDED(createEnvironment(nullptr,profile.c_str(),nullptr,environment.Get()));
}
};
+323
View File
@@ -0,0 +1,323 @@
#include "files_text_document.h"
#include <objbase.h>
#include <algorithm>
#include <stdexcept>
#include <system_error>
#include <utility>
#include <vector>
namespace ks::files
{
namespace
{
[[noreturn]] void fail(const char *operation)
{
throw std::system_error(static_cast<int>(GetLastError()), std::system_category(), operation);
}
struct File
{
HANDLE handle = INVALID_HANDLE_VALUE;
~File() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); }
HANDLE release() { return std::exchange(handle, INVALID_HANDLE_VALUE); }
};
struct PendingSave
{
std::filesystem::path path;
~PendingSave() { if (!path.empty()) DeleteFileW(path.c_str()); }
};
HANDLE openSource(const std::filesystem::path &path, bool editable)
{
const auto sharing = FILE_SHARE_READ | FILE_SHARE_DELETE | (editable ? 0 : FILE_SHARE_WRITE);
const auto result = CreateFileW(path.c_str(), GENERIC_READ, sharing,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (result == INVALID_HANDLE_VALUE) fail("Open file");
return result;
}
uint64_t fileSize(HANDLE file)
{
LARGE_INTEGER size{};
if (!GetFileSizeEx(file, &size)) fail("Read file size");
return size.QuadPart;
}
void seek(HANDLE file, uint64_t offset)
{
LARGE_INTEGER position{};
position.QuadPart = static_cast<LONGLONG>(offset);
if (!SetFilePointerEx(file, position, nullptr, FILE_BEGIN)) fail("Seek");
}
std::string read(HANDLE file, uint64_t offset, size_t length)
{
if (!length) return {};
seek(file, offset);
std::string result(length, '\0');
DWORD count{};
if (!ReadFile(file, result.data(), static_cast<DWORD>(length), &count, nullptr)) fail("Read file");
if (count != length) throw std::runtime_error("The file ended unexpectedly.");
return result;
}
void write(HANDLE file, std::string_view bytes)
{
if (bytes.empty()) return;
DWORD written{};
if (!WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr))
fail("Write file");
if (written != bytes.size()) throw std::runtime_error("The complete block could not be written.");
}
bool sameFile(HANDLE a, HANDLE b)
{
BY_HANDLE_FILE_INFORMATION first{}, second{};
if (!GetFileInformationByHandle(a, &first) || !GetFileInformationByHandle(b, &second))
fail("Check file identity");
return first.dwVolumeSerialNumber == second.dwVolumeSerialNumber &&
first.nFileIndexHigh == second.nFileIndexHigh && first.nFileIndexLow == second.nFileIndexLow;
}
std::filesystem::path temporaryPath(const std::filesystem::path &directory)
{
GUID id{};
wchar_t name[40]{};
if (FAILED(CoCreateGuid(&id)) || !StringFromGUID2(id, name, 40))
throw std::runtime_error("Cannot create a temporary file name.");
return directory / (std::wstring(L".kswitcher-") + name + L".tmp");
}
std::filesystem::path createTemporary(const std::filesystem::path &directory, File &file, DWORD flags)
{
auto path = temporaryPath(directory);
file.handle = CreateFileW(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, CREATE_NEW, flags, nullptr);
if (file.handle == INVALID_HANDLE_VALUE) fail("Create temporary file");
return path;
}
}
TextDocument::TextDocument(std::filesystem::path path, bool editable) : path_(std::move(path)), editable_(editable)
{
File source{openSource(path_, editable)};
if (editable)
{
std::wstring resolved(32768, L'\0');
const auto length = GetFinalPathNameByHandleW(source.handle, resolved.data(),
static_cast<DWORD>(resolved.size()), FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
if (length && length < resolved.size())
{
resolved.resize(length);
path_ = resolved;
}
}
size_ = fileSize(source.handle);
const auto prefix = read(source.handle, 0, static_cast<size_t>(std::min<uint64_t>(3, size_)));
if (prefix.starts_with("\xef\xbb\xbf")) bom_ = 3;
else if (prefix.starts_with("\xff\xfe") || prefix.starts_with("\xfe\xff"))
{
encoding_ = prefix.starts_with("\xff") ? Encoding::utf16le : Encoding::utf16be;
bom_ = 2;
if (size_ % 2) throw std::runtime_error("The UTF-16 file has an incomplete code unit.");
}
source_ = source.release();
}
TextDocument::~TextDocument()
{
if (source_ != INVALID_HANDLE_VALUE) CloseHandle(source_);
if (spool_ != INVALID_HANDLE_VALUE) CloseHandle(spool_);
}
const wchar_t *TextDocument::encodingName() const
{
switch (encoding_)
{
case Encoding::utf16le: return L"UTF-16 LE";
case Encoding::utf16be: return L"UTF-16 BE";
default: return bom_ ? L"UTF-8 BOM" : L"UTF-8";
}
}
uint64_t TextDocument::pages() const
{
return std::max<uint64_t>(1, (size_ - bom_ + pageBytes - 1) / pageBytes);
}
uint64_t TextDocument::boundary(uint64_t page) const
{
if (!page) return bom_;
if (page >= pages()) return size_;
const auto offset = bom_ + page * pageBytes;
const auto unit = encoding_ == Encoding::utf8 ? 1u : 2u;
const auto sample = read(source_, offset - unit,
static_cast<size_t>(std::min<uint64_t>(4096 + unit, size_ - offset + unit)));
const auto getUnit = [&](size_t i) -> unsigned {
const auto first = static_cast<unsigned char>(sample[i]);
if (unit == 1) return first;
const auto second = static_cast<unsigned char>(sample[i + 1]);
return encoding_ == Encoding::utf16le ? first | (second << 8) : (first << 8) | second;
};
if (getUnit(0) == '\n') return offset;
for (size_t i = unit; i + unit < sample.size(); i += unit)
if (getUnit(i) == '\n') return offset + i;
size_t i = unit;
if (unit == 1)
{
// Advance past at most one UTF-8 code point, even on malformed input.
while (i < sample.size() && i <= 3 && (getUnit(i) & 0xc0) == 0x80) ++i;
}
else if (const auto value = getUnit(i); value >= 0xdc00 && value <= 0xdfff) i += unit;
if (i == unit && getUnit(0) == '\r' && getUnit(i) == '\n') i += unit;
return offset + i - unit;
}
std::string TextDocument::decode(std::string bytes) const
{
if (encoding_ == Encoding::utf8 || bytes.empty()) return bytes;
std::wstring units;
units.resize(bytes.size() / 2);
for (size_t i = 0; i < units.size(); ++i)
{
const auto a = static_cast<unsigned char>(bytes[2 * i]);
const auto b = static_cast<unsigned char>(bytes[2 * i + 1]);
units[i] = static_cast<wchar_t>(encoding_ == Encoding::utf16le ? a | (b << 8) : (a << 8) | b);
}
const auto length = static_cast<int>(units.size());
const int required = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), length, nullptr, 0, nullptr, nullptr);
if (!required) fail("Decode UTF-16");
std::string result(required, '\0');
if (!WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, units.data(), length, result.data(), required, nullptr, nullptr))
fail("Decode UTF-16");
return result;
}
std::string TextDocument::encode(std::string_view utf8) const
{
if (encoding_ == Encoding::utf8 || utf8.empty()) return std::string(utf8);
const auto length = static_cast<int>(utf8.size());
const int required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.data(), length, nullptr, 0);
if (!required) fail("Encode UTF-16");
std::wstring units(required, L'\0');
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8.data(), length, units.data(), required))
fail("Encode UTF-16");
std::string bytes(units.size() * 2, '\0');
const int low = encoding_ == Encoding::utf16le ? 0 : 1;
for (size_t i = 0; i < units.size(); ++i)
{
bytes[2 * i + low] = static_cast<char>(units[i] & 0xff);
bytes[2 * i + 1 - low] = static_cast<char>(units[i] >> 8);
}
return bytes;
}
std::string TextDocument::readPage(uint64_t page) const
{
if (const auto it = changes_.find(page); it != changes_.end())
return read(spool_, it->second.offset, it->second.size);
return readOriginal(page);
}
std::string TextDocument::readOriginal(uint64_t page) const
{
const auto start = boundary(page);
return decode(read(source_, start, static_cast<size_t>(boundary(page + 1) - start)));
}
bool TextDocument::modifiedWith(uint64_t page, std::string_view current) const
{
const size_t changesOnPage = changes_.contains(page) ? 1 : 0;
return changes_.size() > changesOnPage || current != readOriginal(page);
}
void TextDocument::stage(uint64_t page, std::string_view utf8)
{
if (!editable_) throw std::logic_error("The file is open for viewing.");
if (utf8.size() > editLimit) throw std::runtime_error("The edit exceeds 8 MiB on this page.");
if (utf8 == readOriginal(page))
{
changes_.erase(page);
return;
}
if (spool_ == INVALID_HANDLE_VALUE)
{
wchar_t directory[32768]{};
const auto length = GetTempPathW(static_cast<DWORD>(std::size(directory)), directory);
if (!length || length >= std::size(directory)) fail("Locate temporary directory");
File file;
createTemporary(directory, file, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE);
spool_ = file.release();
}
seek(spool_, spoolSize_);
write(spool_, utf8);
changes_.insert_or_assign(page, Change{spoolSize_, utf8.size()});
spoolSize_ += utf8.size();
}
bool TextDocument::save()
{
if (!modified()) return true;
PendingSave pending;
File output;
pending.path = createTemporary(path_.parent_path(), output, FILE_ATTRIBUTE_NORMAL);
uint64_t written{};
const auto append = [&](std::string_view bytes) {
write(output.handle, bytes);
written += bytes.size();
};
const auto copyUntil = [&](uint64_t &offset, uint64_t end) {
while (offset < end)
{
const auto block = read(source_, offset, static_cast<size_t>(std::min(pageBytes, end - offset)));
append(block);
offset += block.size();
}
};
uint64_t offset{};
for (const auto &[page, change] : changes_)
{
copyUntil(offset, boundary(page));
append(encode(read(spool_, change.offset, change.size)));
offset = boundary(page + 1);
}
copyUntil(offset, size_);
if (!FlushFileBuffers(output.handle)) fail("Flush saved file");
CloseHandle(output.release());
// A metadata-only handle survives the rename without blocking ReplaceFile's exclusive open.
File replacement{CreateFileW(pending.path.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
if (replacement.handle == INVALID_HANDLE_VALUE) fail("Track replacement file");
File current{openSource(path_, true)};
if (!sameFile(source_, current.handle)) throw std::runtime_error("The file was replaced outside KSwitcher.");
const auto backup = temporaryPath(path_.parent_path());
if (!ReplaceFileW(path_.c_str(), pending.path.c_str(), backup.c_str(), 0, nullptr, nullptr))
{
const auto error = GetLastError();
if (GetFileAttributesW(backup.c_str()) != INVALID_FILE_ATTRIBUTES &&
!MoveFileExW(backup.c_str(), path_.c_str(), MOVEFILE_WRITE_THROUGH))
{
const auto location = backup.u8string();
throw std::runtime_error("Could not restore the original name after a save error. The original is at " +
std::string(location.begin(), location.end()));
}
SetLastError(error);
fail("Replace original file");
}
pending.path.clear();
const auto newSource = ReOpenFile(replacement.handle, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, 0);
DeleteFileW(backup.c_str());
CloseHandle(source_);
source_ = newSource;
size_ = written;
changes_.clear();
if (spool_ != INVALID_HANDLE_VALUE) CloseHandle(std::exchange(spool_, INVALID_HANDLE_VALUE));
spoolSize_ = 0;
return source_ != INVALID_HANDLE_VALUE;
}
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <windows.h>
#include <cstdint>
#include <filesystem>
#include <map>
#include <string>
#include <string_view>
namespace ks::files
{
class TextDocument
{
public:
static constexpr uint64_t pageBytes = 1024 * 1024;
static constexpr size_t editLimit = 8 * pageBytes;
explicit TextDocument(std::filesystem::path path, bool editable = true);
~TextDocument();
TextDocument(const TextDocument &) = delete;
TextDocument &operator=(const TextDocument &) = delete;
uint64_t pages() const;
uint64_t size() const { return size_; }
uint64_t boundary(uint64_t page) const;
std::string readPage(uint64_t page) const;
void stage(uint64_t page, std::string_view utf8);
bool modified() const { return !changes_.empty(); }
bool modifiedWith(uint64_t page, std::string_view current) const;
bool save();
const wchar_t *encodingName() const;
private:
enum class Encoding { utf8, utf16le, utf16be };
struct Change { uint64_t offset; size_t size; };
std::filesystem::path path_;
HANDLE source_ = INVALID_HANDLE_VALUE;
HANDLE spool_ = INVALID_HANDLE_VALUE;
uint64_t size_{}, spoolSize_{};
size_t bom_{};
Encoding encoding_ = Encoding::utf8;
bool editable_;
std::map<uint64_t, Change> changes_;
std::string decode(std::string bytes) const;
std::string readOriginal(uint64_t page) const;
std::string encode(std::string_view utf8) const;
};
}
+270
View File
@@ -0,0 +1,270 @@
#include "files_text_language.h"
#include "core.h"
#include <Sci_Position.h>
#include <ILexer.h>
#include <Lexilla.h>
#include <Scintilla.h>
#include <SciLexer.h>
#include <algorithm>
#include <cwctype>
namespace ks::files
{
namespace
{
constexpr char cpp[] = "alignas alignof asm auto bool break case catch char char8_t char16_t char32_t class concept const "
"consteval constexpr constinit const_cast continue co_await co_return co_yield decltype default delete do double "
"dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace "
"new noexcept nullptr operator private protected public register reinterpret_cast requires return short signed "
"sizeof static static_assert static_cast struct switch template this thread_local throw true try typedef typeid "
"typename union unsigned using virtual void volatile wchar_t while import module";
constexpr char csharp[] = "abstract as async await base bool break byte case catch char checked class const continue "
"decimal default delegate do double else enum event explicit extern false file finally fixed float for foreach from "
"get global goto if implicit in init int interface internal is let lock long namespace new not null object operator "
"or out override partial private protected public readonly record ref required return sbyte sealed select set short "
"sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unmanaged unsafe "
"ushort using value var virtual void volatile when where while with yield";
constexpr char javascript[] = "abstract any as async await boolean break case catch class const continue debugger declare "
"default delete do else enum export extends false finally for from function get if implements import in infer "
"instanceof interface keyof let module namespace never new null number object of override private protected public "
"readonly return satisfies set static string super switch symbol this throw true try type typeof undefined unknown "
"using var void while with yield";
constexpr char java[] = "abstract assert boolean break byte case catch char class const continue default do double else "
"enum exports extends final finally float for if implements import instanceof int interface long module native new "
"non-sealed null open opens package permits private protected public record requires return sealed short static super "
"switch synchronized this throw throws transient true try var void volatile while yield false";
constexpr char sql[] = "add alter and any as asc begin between by case check column commit constraint create cross "
"database declare default delete desc distinct drop else end except exists fetch foreign from full function grant "
"group having in index inner insert intersect into is join left like limit merge not null offset on or order outer "
"over partition primary procedure references returning right rollback row select set table then top transaction "
"truncate union unique update using values view when where window with";
constexpr char python[] = "and as assert async await break class continue def del elif else except False finally for from "
"global if import in is lambda match case None nonlocal not or pass raise return True try while with yield";
constexpr char ps[] = "begin break catch class continue data define do dynamicparam else elseif end enum exit filter finally "
"for foreach from function if in param process return switch throw trap try until using var while workflow";
constexpr char shell[] = "alias bg break case cd continue declare do done echo elif else esac eval exec exit export "
"false fi for function if in local read readonly return select set shift source test then time trap true type "
"typeset ulimit umask unalias unset until wait while";
constexpr char rust[] = "as async await break const continue crate dyn else enum extern false fn for if impl in let loop "
"match mod move mut pub ref return self Self static struct super trait true type unsafe use where while";
constexpr char go[] = "break case chan const continue default defer else fallthrough for func go goto if import interface "
"map package range return select struct switch type var nil false true";
constexpr char lua[] = "and break do else elseif end false for function goto if in local nil not or repeat return then true until while";
constexpr char ruby[] = "BEGIN END alias and begin break case class def defined do else elsif end ensure false for if in "
"module next nil not or redo rescue retry return self super then true undef unless until when while yield";
constexpr char html[] = "a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption "
"cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset figcaption figure footer "
"form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label legend li link main map mark "
"menu meta meter nav noscript object ol optgroup option output p picture pre progress q rp rt ruby s samp script "
"search section select slot small source span strong style sub summary sup table tbody td template textarea tfoot "
"th thead time title tr track u ul var video wbr accept action alt aria-label async cellpadding charset checked cite "
"class content controls defer disabled for height href http-equiv id lang maxlength method multiple name placeholder "
"readonly rel required role rows scope selected src srcset style tabindex target type value width";
struct Mapping
{
std::wstring_view names;
const char *lexer;
const char *keywords = "";
};
constexpr Mapping languages[] = {
{L".c .h .cc .cpp .cxx .hh .hpp .hxx .inl .ipp .ixx .cppm .cu .cuh .m .mm .rc .idl .ino", "cpp", cpp},
{L".cs .csx .razor.cs", "cpp", csharp},
{L".js .jsx .mjs .cjs .ts .tsx .mts .cts .es", "cpp", javascript},
{L".java .pde", "cpp", java},
{L".kt .kts", "cpp", "abstract actual as break by catch class companion const constructor continue data do else enum "
"expect false finally for fun if import in init interface internal is lateinit null object open operator out "
"override package private protected public return sealed super suspend this throw true try typealias val var when while"},
{L".glsl .vert .frag .geom .hlsl .fx .metal", "cpp", cpp},
{L".html .htm .xhtml .shtml .vue .svelte .astro .cshtml .razor .aspx .jsp .php .phtml", "hypertext", html},
{L".xml .xsl .xslt .xsd .svg .resx .xaml .csproj .fsproj .vbproj .vcxproj .props .targets .config .nuspec "
L".wixproj .wxs .plist .rss .atom .gpx .kml .ui .iml .wsdl .manifest .slnx .axaml", "xml"},
{L".css .scss .sass .less", "css", "color background display position width height margin padding border font "
"grid flex content gap box-shadow transform animation opacity overflow text-align align-items justify-content"},
{L".sql .psql .ddl .dml .mysql", "sql", sql},
{L".py .pyw .pyi .rpy sconstruct sconscript", "python", python},
{L".json .jsonc .json5 .ipynb .webmanifest .geojson .topojson .code-workspace .sublime-settings .sublime-project",
"json", "false null true Infinity NaN"},
{L".yaml .yml .eyaml .clang-format .clang-tidy", "yaml", "true false null yes no on off"},
{L".toml pipfile uv.lock cargo.lock poetry.lock", "toml", "true false"},
{L".md .markdown .mdown .mkd .mdx readme", "markdown"},
{L".adoc .asciidoc", "asciidoc"},
{L".ini .cfg .conf .properties .url .inf .reg .editorconfig .gitconfig .gitattributes .gitmodules .npmrc .env", "props"},
{L".sh .bash .zsh .ksh .bats .bashrc .bash_profile .zshrc .profile configure gradlew mvnw", "bash", shell},
{L".ps1 .psm1 .psd1", "powershell", ps},
{L".bat .cmd", "batch", "call cd chdir cls copy del dir echo endlocal exit for goto if md mkdir move path pause popd "
"pushd rd rem ren rename set setlocal shift start title type"},
{L".cmake cmakelists.txt", "cmake", "add_executable add_library add_subdirectory cmake_minimum_required "
"configure_file else elseif endforeach endfunction endif endwhile file find_package foreach function if include "
"install list message option project set string target_compile_definitions target_include_directories "
"target_link_libraries unset while"},
{L".mk .mak makefile gnumakefile", "makefile"},
{L".rs", "rust", rust}, {L".go", "cpp", go}, {L".lua", "lua", lua}, {L".rb .rake .gemspec gemfile rakefile", "ruby", ruby},
{L".dart", "dart", "abstract as assert async await break case class const continue default do else enum extends "
"false final finally for if implements import in interface late mixin new null on override return sealed static "
"super switch this throw true try typedef var void while with yield"},
{L".swift", "cpp", "actor as associatedtype await break case catch class continue default defer deinit do else "
"enum extension false for func guard if import in init let nil private protocol public return self some "
"static struct super switch throw throws true try typealias var where while"},
{L".zig", "zig"}, {L".d .di", "d"}, {L".nim .nims .nimble", "nim"},
{L".fs .fsi .fsx", "fsharp"}, {L".ml .mli", "caml"}, {L".sml .sig", "SML"},
{L".vb .bas .frm .cls", "vb", "as byref byval class const dim each else elseif end enum for function if implements "
"imports inherits interface module new next nothing private property public return select shared sub then try while"},
{L".vbs", "vbscript"}, {L".pl .pm .t", "perl"}, {L".r .rprofile", "r"},
{L".jl", "julia"}, {L".matlab", "matlab"}, {L".oct", "octave"},
{L".tex .sty .ltx", "latex"}, {L".bib", "bib"}, {L".hs", "haskell"}, {L".lhs", "literatehaskell"},
{L".lisp .lsp .cl .el .scm .ss .rkt", "lisp"},
{L".s .asm .nasm .inc", "asm"}, {L".a68k", "a68k"},
{L".v .vh .sv .svh", "verilog"}, {L".vhd .vhdl", "vhdl"},
{L".f .for .f77", "f77"}, {L".f90 .f95 .f03 .f08", "fortran"},
{L".ada .adb .ads", "ada"}, {L".pas .pp .dpr", "pascal"}, {L".cob .cbl .cpy", "COBOL"},
{L".tcl .tk", "tcl"}, {L".coffee .litcoffee", "coffeescript"}, {L".au3", "au3"},
{L".st", "smalltalk"}, {L".erl .hrl", "erlang"},
{L".purs", "haskell"}, {L".diff .patch", "diff"}, {L".po .pot", "po"},
{L".iss", "inno"}, {L".nsi .nsh", "nsis"}, {L".idl3 .odl", "cpp", cpp},
{L".dmap", "DMAP"}, {L".dns .zone", "props"}, {L".ihex .hex", "ihex"}, {L".srec .s19 .s28 .s37", "srec"},
{L".edifact .edi", "edifact"}, {L".x12", "x12"}, {L".g4", "cpp", java},
{L".nix", "nix"}, {L".gd", "gdscript", python}, {L".il", "cil"}, {L".as", "cpp", javascript},
{L".frt .fth", "forth"}, {L".e .eif", "eiffel"}, {L".pov .povray", "pov"},
{L".rebol .reb", "rebol"}, {L".asn .asn1", "asn1"}, {L".asy", "asy"},
{L".raku .rakumod .rakutest .p6 .pm6", "raku"}, {L".bb", "blitzbasic"}, {L".bi", "freebasic"},
{L".t2t", "txt2tags"}, {L".troff .roff .man", "troff"},
{L".clw", "clarion"}, {L".spice .cir", "spice"},
};
bool containsWord(std::wstring_view words, std::wstring_view key)
{
if (key.empty()) return false;
for (size_t start = 0; start < words.size();)
{
const auto end = words.find(L' ', start);
if (words.substr(start, end == words.npos ? end : end - start) == key) return true;
if (end == words.npos) break;
start = end + 1;
}
return false;
}
}
TextLanguage detectLanguage(const std::filesystem::path &path, std::string_view firstPage)
{
auto filename = path.filename().wstring();
auto extension = path.extension().wstring();
for (auto *value : {&filename, &extension})
std::transform(value->begin(), value->end(), value->begin(), [](wchar_t ch) { return std::towlower(ch); });
for (const auto &language : languages)
if (containsWord(language.names, filename) || containsWord(language.names, extension))
return {language.lexer, language.keywords};
if (filename.starts_with(L".env.") || extension == L".env") return {"props"};
if (firstPage.starts_with("<?xml")) return {"xml"};
if (firstPage.starts_with("#!"))
{
const auto line = firstPage.substr(0, firstPage.find_first_of("\r\n"));
for (const auto &[needle, language] : {std::pair{"python", TextLanguage{"python", python}},
{"ruby", {"ruby", ruby}}, {"node", {"cpp", javascript}}, {"perl", {"perl"}},
{"pwsh", {"powershell", ps}}, {"lua", {"lua", lua}}, {"sh", {"bash", shell}}})
if (line.find(needle) != line.npos) return language;
}
return {};
}
std::wstring textLanguageLabel(const TextLanguage &language)
{
if (language.keywords == csharp) return L"C#";
if (language.keywords == javascript) return L"JavaScript / TypeScript";
if (language.keywords == java) return L"Java";
if (language.keywords == go) return L"Go";
if (language.lexer == "cpp") return L"C-family";
if (language.lexer == "hypertext") return L"HTML";
return language.lexer.empty() ? L"Plain text" : wide(language.lexer);
}
Scintilla::ILexer5 *createTextLexer(const TextLanguage &language)
{
if (language.lexer.empty()) return nullptr;
auto *lexer = CreateLexer(language.lexer.c_str());
if (!lexer) return nullptr;
const auto *keywords = language.keywords;
if (!*keywords)
for (const auto &mapping : languages)
if (language.lexer == mapping.lexer && *mapping.keywords)
{
keywords = mapping.keywords;
break;
}
if (*keywords) lexer->WordListSet(0, keywords);
if (language.lexer == "hypertext") lexer->WordListSet(1, javascript);
if (language.lexer == "json") lexer->PropertySet("lexer.json.allow.comments", "1");
if (language.lexer == "cpp" && (language.keywords == javascript || language.keywords == go))
{
lexer->PropertySet("lexer.cpp.backquoted.strings", language.keywords == javascript ? "2" : "1");
if (language.keywords == javascript) lexer->PropertySet("lexer.cpp.regex", "1");
}
if (language.lexer == "cpp" && language.keywords == csharp)
lexer->PropertySet("lexer.cpp.triplequoted.strings", "1");
return lexer;
}
TextColor syntaxColor(Scintilla::ILexer5 &lexer, std::string_view name, int style)
{
if (!style || (style >= STYLE_DEFAULT && style <= STYLE_LASTPREDEFINED)) return TextColor::text;
const auto *styleTags = lexer.TagsOfStyle(style);
const auto *styleName = lexer.NameOfStyle(style);
const std::string_view tags = styleTags ? styleTags : "";
const std::string_view token = styleName ? styleName : "";
const auto has = [&](std::string_view word) {
return tags.find(word) != tags.npos || token.find(word) != token.npos;
};
if (has("comment")) return TextColor::comment;
if (has("string") || has("character") || has("quoted") || has("regex")) return TextColor::string;
if (has("number") || has("numeric")) return TextColor::number;
if (has("keyword") || has("command") || has("tag") || has("heading") || has("header") ||
has("preprocessor") || has("keywordclass")) return TextColor::keyword;
if (has("operator") || has("attribute") || has("property") || has("variable") || has("label"))
return TextColor::symbol;
if (name == "markdown")
{
if (style >= SCE_MARKDOWN_HEADER1 && style <= SCE_MARKDOWN_HEADER6) return TextColor::keyword;
if (style >= SCE_MARKDOWN_CODE && style <= SCE_MARKDOWN_CODEBK) return TextColor::string;
return TextColor::symbol;
}
if (name == "diff")
{
if (style == SCE_DIFF_ADDED) return TextColor::string;
if (style == SCE_DIFF_DELETED) return TextColor::number;
if (style == SCE_DIFF_COMMENT) return TextColor::comment;
return TextColor::keyword;
}
if (name == "sql")
{
if (style >= SCE_SQL_COMMENT && style <= SCE_SQL_COMMENTDOC) return TextColor::comment;
if (style == SCE_SQL_NUMBER) return TextColor::number;
if (style == SCE_SQL_WORD || style == SCE_SQL_WORD2) return TextColor::keyword;
if (style == SCE_SQL_STRING || style == SCE_SQL_CHARACTER) return TextColor::string;
}
if (name == "json")
{
if (style == SCE_JSON_LINECOMMENT || style == SCE_JSON_BLOCKCOMMENT) return TextColor::comment;
if (style == SCE_JSON_STRING || style == SCE_JSON_STRINGEOL) return TextColor::string;
if (style == SCE_JSON_NUMBER) return TextColor::number;
if (style == SCE_JSON_KEYWORD || style == SCE_JSON_LDKEYWORD) return TextColor::keyword;
}
if (name == "css")
{
if (style == SCE_CSS_COMMENT) return TextColor::comment;
if (style == SCE_CSS_DOUBLESTRING || style == SCE_CSS_SINGLESTRING || style == SCE_CSS_VALUE)
return TextColor::string;
if (style == SCE_CSS_TAG || style == SCE_CSS_DIRECTIVE) return TextColor::keyword;
}
if (name == "yaml" || name == "toml")
{
// The two lexers assign the same IDs to these categories.
if (style == SCE_YAML_COMMENT) return TextColor::comment;
if (style == SCE_YAML_KEYWORD) return TextColor::keyword;
if (style == SCE_YAML_NUMBER) return TextColor::number;
}
if (token.empty()) return TextColor::symbol;
return TextColor::text;
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
namespace Scintilla { class ILexer5; }
namespace ks::files
{
enum class TextColor { background, text, chromebg, chrome, selection, comment, keyword, string, number, symbol,
menuText, menuHighlight, menuHighlightText, menuAccessKey, count };
struct TextLanguage
{
std::string lexer;
const char *keywords = "";
};
TextLanguage detectLanguage(const std::filesystem::path &path, std::string_view firstPage);
std::wstring textLanguageLabel(const TextLanguage &language);
Scintilla::ILexer5 *createTextLexer(const TextLanguage &language);
TextColor syntaxColor(Scintilla::ILexer5 &lexer, std::string_view name, int style);
}
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <windows.h>
#include <array>
#include <string>
#include <vector>
namespace ks::files
{
inline constexpr size_t documentColorCount = 10;
using TextPalette = std::array<COLORREF, 14>;
inline constexpr const wchar_t *textPaletteNames[] = {L"FAR (blue)", L"Dark", L"Light"};
inline constexpr TextPalette textPalettes[] = {
{RGB(0,0,128), RGB(215,230,255), RGB(0,128,128), RGB(255,255,255), RGB(0,115,115),
RGB(143,195,157), RGB(255,235,118), RGB(165,235,255), RGB(255,181,228), RGB(155,214,255),
RGB(0,0,0), RGB(0,0,0), RGB(255,255,255), RGB(255,255,0)},
{RGB(29,33,38), RGB(234,237,241), RGB(45,60,70), RGB(255,255,255), RGB(42,86,127),
RGB(155,185,150), RGB(145,201,255), RGB(241,186,143), RGB(208,170,255), RGB(152,226,218),
RGB(255,255,255), RGB(42,86,127), RGB(255,255,255), RGB(255,255,0)},
{RGB(255,255,255), RGB(28,35,46), RGB(221,231,237), RGB(25,38,54), RGB(74,104,146),
RGB(45,111,70), RGB(103,40,154), RGB(152,52,30), RGB(35,82,156), RGB(0,99,109),
RGB(0,0,0), RGB(189,207,223), RGB(0,0,0), RGB(111,77,0)},
};
inline constexpr const wchar_t *textColorNames[] = {L"Background", L"Text", L"Status background", L"Status text",
L"Selection", L"Comment", L"Keyword", L"String", L"Number", L"Symbol",
L"Top menu text", L"Selected menu background", L"Selected menu text", L"Menu access key"};
struct TextPreferences
{
bool wrap{}, numbers{true}, whitespace{}, tabsAsSpaces{}, autoIndent{true}, caretLine{true};
int tabWidth{4}, lineEnding{};
std::wstring scheme = textPaletteNames[0];
TextPalette colors = textPalettes[0];
bool operator==(const TextPreferences &) const = default;
};
// Index 0 is Viewer and index 1 is Editor.
using TextPreferencesSet = std::array<TextPreferences, 2>;
struct NamedTextPalette
{
std::wstring name;
TextPalette colors;
bool operator==(const NamedTextPalette &) const = default;
};
inline constexpr size_t maxTextPalettes = 48;
inline constexpr size_t maxTextPaletteName = 48;
struct TextSettings
{
TextPreferencesSet profiles;
std::vector<NamedTextPalette> schemes;
bool operator==(const TextSettings &) const = default;
};
TextSettings loadTextPreferences();
void saveTextPreferences(const TextSettings &settings);
}
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <windows.h>
#include <filesystem>
#include <vector>
namespace ks::lan { struct FilesPeer; }
namespace ks::files
{
inline constexpr int searchOptionsDivider = 104;
inline constexpr int searchButtonsDivider = 105;
inline constexpr int searchHistoryButton = 108;
inline constexpr wchar_t farDropDownProperty[]=L"KSwitcher.Files.ActiveDropDown";
struct TextWindowFont
{
LOGFONTW font;
int tenthsOfPoint;
};
struct FarDropDownPalette
{
COLORREF surface, text, selection;
};
enum class FarHistoryKind { search, folder };
struct FarHistoryEntry { std::wstring date,text; };
std::vector<FarHistoryEntry> loadFarHistory(FarHistoryKind kind);
void rememberFarHistory(FarHistoryKind kind,std::wstring_view text);
int chooseFarDropDown(HWND anchor,const std::vector<FarHistoryEntry> &entries,int selected,
FarDropDownPalette palette,int rightInset=0);
void showFarHistory(HWND dialog,int fieldId,const std::vector<FarHistoryEntry> &history,
FarDropDownPalette palette);
void installFarHistoryKey(HWND field,int arrowId);
struct TextDialogs
{
INT_PTR (*confirmSave)(HWND);
INT_PTR (*paint)(HWND, UINT, WPARAM, LPARAM, const wchar_t *);
INT_PTR (*paintSearch)(HWND, UINT, WPARAM, LPARAM);
void (*prepare)(HWND);
void (*options)(HWND, bool);
void (*message)(HWND, const wchar_t *);
TextWindowFont (*readFont)();
FarDropDownPalette (*dropDownPalette)();
INT_PTR (*run)(HINSTANCE,LPCWSTR,HWND,DLGPROC,LPARAM);
void (*finish)(HWND,INT_PTR);
bool (*dismissOwned)(HWND);
bool (*confirmExisting)(HWND);
};
void openTextWindow(HWND owner,const std::filesystem::path &path,bool edit,TextDialogs dialogs,
const lan::FilesPeer *peer=nullptr);
void textWindowOwnerClosed();
}
+27 -5
View File
@@ -2,6 +2,7 @@
#include <ws2tcpip.h>
#include <iphlpapi.h>
#include "lan.h"
#include "lan_files.h"
#include "lan_pairing.h"
#include "lan_mesh.h"
#include "lan_clipboard.h"
@@ -48,7 +49,7 @@ std::vector<ULONG> broadcasts()
}
struct LanService::Impl
{
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; };
struct Peer { std::string name,epoch; sockaddr_in address{}; uint64_t seen{},verified{}; bool files{}; };
std::filesystem::path path;
std::string id,epoch=lan::randomHex(16),name=hostName(),status="Disabled.";
std::map<std::string,Peer> peers;
@@ -61,7 +62,7 @@ struct LanService::Impl
std::optional<LogDownload> logDownload;
bool shareLogs{};
bool enabled{};
std::atomic<bool> stopping{false};
std::atomic<bool> stopping{false}, refreshFiles{false};
std::mutex mutex;
std::thread worker;
bool shareInput{};
@@ -225,6 +226,7 @@ struct LanService::Impl
if(peer.address.sin_addr.s_addr!=address.sin_addr.s_addr) peer.verified=0;
clipboard->peerSession(other,j.value("clipboardSession",""));
transfers->peerSession(other,j.value("fileSession",""));
peer.files=j.value("filesBrowse",false);
peer.name=display; peer.epoch=session; peer.address=address; peer.seen=now;
if(trusted.contains(other))
{
@@ -289,12 +291,29 @@ struct LanService::Impl
if(bind(socket,(sockaddr*)&bound,sizeof(bound))) throw std::runtime_error("UDP port 45883 is in use. Only one LAN-enabled KSwitcher may run per computer.");
uint64_t nextHello=0,nextOffer=0;
lan::InputHub hub(id);
lan::FilesListener files(id,[&](const std::string &peer)
{
std::lock_guard lock(mutex);
return !stopping && trusted.contains(peer)?trusted.at(peer).at("key").get<std::string>():std::string{};
},lan::FilesPort,lan::remoteTerminalAllowed,lan::hibernateFromPairedPC,[this]() -> DWORD
{
if(!lan::remoteUpdateAllowed()) return ERROR_ACCESS_DENIED;
if(!inputWindow) return ERROR_INVALID_WINDOW_HANDLE;
return PostMessageW(inputWindow,LanUpdateRequestedMessage,0,0)?ERROR_SUCCESS:GetLastError();
});
hub.datagrams([&](const auto &target,const auto &packet){
std::lock_guard lock(mutex);
if(shareInput&&trusted.contains(target)&&peers.contains(target))send(socket,peers.at(target).address,packet);
});
bool filesListening=files.port()!=0;
while(!stopping)
{
files.poll();
if(filesListening!=(files.port()!=0))
{
filesListening=files.port()!=0;
nextHello=0;
}
auto now=GetTickCount64();bool clipboardActive=false;
std::vector<lan::InputEndpoint> endpoints;
{
@@ -308,11 +327,12 @@ struct LanService::Impl
clipboardActive=clipboard->active()||transfers->active();
if(shareInput)for(auto &[peer,entry]:trusted.items())
endpoints.push_back({peer,entry.at("key"),peers.contains(peer)?peers.at(peer).address.sin_addr.s_addr:0});
if(now>=nextHello)
if(refreshFiles.exchange(false) || now>=nextHello)
{
epoch=lan::randomHex(16); // Fresh challenge prevents replayed presence from staying online.
auto hello=lan::envelope("hello",id); hello["name"]=name; hello["epoch"]=epoch;hello["clipboardSession"]=clipboard->session();
hello["fileSession"]=transfers->session();
hello["filesBrowse"]=filesListening;
for(auto ip:broadcasts()) { auto address=bound; address.sin_addr.s_addr=ip; send(socket,address,hello); }
nextHello=now+3000;
}
@@ -385,6 +405,7 @@ struct LanService::Impl
}
std::lock_guard lock(mutex);
auto now=GetTickCount64();
if(action=="filesPeers") refreshFiles=true;
if(action=="shareLogs")
{
bool previous=shareLogs;
@@ -480,7 +501,7 @@ struct LanService::Impl
auto exe=utf8(executable());std::string quoted;
for(char c:exe){quoted+=c;if(c=='\'')quoted+='\'';}
auto script="$ksPath = '"+quoted+"'\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884 -RemoteAddress LocalSubnet -Profile Any\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN TCP' -Direction Inbound -Action Allow -Program $ksPath -Protocol TCP -LocalPort 45884,45885 -RemoteAddress LocalSubnet -Profile Any\r\n"
"New-NetFirewallRule -DisplayName 'KSwitcher LAN UDP' -Direction Inbound -Action Allow -Program $ksPath -Protocol UDP -LocalPort 45883 -RemoteAddress LocalSubnet -Profile Any";
return encoded({{"script",script}});
}
@@ -524,10 +545,11 @@ struct LanService::Impl
? (lan::PairLifetime-(now-pending->started)+999)/1000 : 0;
for(const auto &[peer,p]:peers)
{
if(action=="filesPeers" && !trusted.contains(peer)) continue;
char ip[INET_ADDRSTRLEN]{}; InetNtopA(AF_INET,(void*)&p.address.sin_addr,ip,sizeof(ip));
result["peers"].push_back({{"id",peer},{"name",p.name},{"address",ip},{"paired",trusted.contains(peer)},
{"verified",p.verified && now-p.verified<lan::PeerLifetime},{"online",true},
{"fileTransfer",transfers->hasSession(peer)}});
{"fileTransfer",transfers->hasSession(peer)},{"filesBrowse",p.files}});
}
for(const auto &[peer,p]:trusted.items()) if(!peers.contains(peer))
result["peers"].push_back({{"id",peer},{"name",p.at("name")},{"address",""},{"paired",true},{"verified",false},{"online",false}});
+1
View File
@@ -6,6 +6,7 @@ namespace ks
{
inline constexpr UINT LanInputReadyMessage=WM_APP+67, LanClipboardReadyMessage=WM_APP+68, LanClipboardPublishMessage=WM_APP+69;
inline constexpr UINT LanFilesChangedMessage=WM_APP+70;
inline constexpr UINT LanUpdateRequestedMessage=WM_APP+90;
struct LanClipboard {std::string peer,name,app;Bytes data;DWORD sequence{};};
// Discovery, pairing and optional encrypted input within the resident process.
class LanService
+4 -4
View File
@@ -50,14 +50,14 @@ struct InputCipher::Impl
};
InputCipher::InputCipher(const std::string &key):impl(std::make_unique<Impl>(key)){}
InputCipher::~InputCipher()=default;
Bytes InputCipher::encryptPayload(const Bytes &plain)
Bytes InputCipher::encryptPayload(const Bytes &plain,size_t limit)
{
if(plain.empty()||plain.size()>1024)throw std::runtime_error("Invalid file packet size.");
if(plain.empty()||plain.size()>limit)throw std::runtime_error("Invalid file packet size.");
Bytes tag(16);auto result=impl->crypt(plain,tag,true);result.insert(result.end(),tag.begin(),tag.end());return result;
}
Bytes InputCipher::decryptPayload(const Bytes &packet)
Bytes InputCipher::decryptPayload(const Bytes &packet,size_t limit)
{
if(packet.size()<17||packet.size()>1040)throw std::runtime_error("Invalid file packet size.");
if(packet.size()<17||packet.size()-16>limit)throw std::runtime_error("Invalid file packet size.");
Bytes tag(packet.end()-16,packet.end());return impl->crypt(Bytes(packet.begin(),packet.end()-16),tag,false);
}
Bytes InputCipher::encrypt(const InputFrame &f)
+2 -2
View File
@@ -39,8 +39,8 @@ class InputCipher
~InputCipher();
Bytes encrypt(const InputFrame &);
InputFrame decrypt(const Bytes &);
Bytes encryptPayload(const Bytes &);
Bytes decryptPayload(const Bytes &);
Bytes encryptPayload(const Bytes &,size_t limit=1024);
Bytes decryptPayload(const Bytes &,size_t limit=1024);
};
std::string inputSessionKey(const std::string &secret,const std::string &from,const std::string &to,
const std::string &fromNonce,const std::string &toNonce,int fromEdge=-1);
+1135
View File
File diff suppressed because it is too large. Load diff
+64
View File
@@ -0,0 +1,64 @@
#pragma once
#include "lan_pairing.h"
#include <functional>
#include <stop_token>
namespace ks::lan
{
inline constexpr uint16_t FilesPort=45885;
struct FilesPeer
{
std::string id, address;
std::wstring name;
uint16_t port=FilesPort;
bool filesBrowse{};
};
using FileProgress = std::function<bool(uint64_t, uint64_t)>;
struct FileRevision { uint64_t size, modified; };
inline constexpr size_t FileChunk = 64 * 1024;
// One connection owns its enumeration, file handles and uncommitted upload.
class FilesConnection
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
explicit FilesConnection(const FilesPeer *peer = nullptr);
FilesConnection(const FilesPeer &peer,const std::string &local,const std::string &key);
~FilesConnection();
Json call(const Json &request, const FileProgress &progress = {});
};
void streamFile(FilesConnection &source,FilesConnection &target,const std::string &sourceId,
const std::filesystem::path &from,const std::filesystem::path &to,uint64_t size,uint64_t modified,
bool replace,bool append,const FileProgress &progress,const FileRevision *expectedDestination=nullptr);
DWORD runRemoteCommand(FilesConnection &host,const std::filesystem::path &directory,std::wstring_view command,
const std::function<void(std::string_view)> &output,std::stop_token stop={});
class FilesListener
{
struct Impl;
std::unique_ptr<Impl> impl;
public:
using PairKey = std::function<std::string(const std::string &)>;
using TerminalPermission = std::function<bool()>;
using HostAction = std::function<DWORD()>;
FilesListener(std::string local, PairKey key,uint16_t port=FilesPort,TerminalPermission terminalPermission={},
HostAction hibernate={},HostAction update={});
uint16_t port() const;
~FilesListener();
void poll();
};
Json fileRequest(std::string_view action, const std::filesystem::path &path);
std::filesystem::path filePath(const Json &path);
void checkFileReply(const Json &reply);
bool recycleFile(HWND owner, const std::filesystem::path &path);
inline constexpr wchar_t remoteTerminalPreference[]=L"AllowRemoteTerminal";
bool remoteTerminalAllowed();
inline constexpr wchar_t remoteHibernatePreference[]=L"AllowRemoteHibernate";
bool remoteHibernateAllowed();
DWORD hibernateFromPairedPC();
inline constexpr wchar_t remoteUpdatePreference[]=L"AllowRemoteUpdate";
bool remoteUpdateAllowed();
}
+7 -1
View File
@@ -737,6 +737,7 @@ namespace
{
std::recursive_mutex logGate;
bool loggingEnabled = false;
std::wstring logFilename = L"kswitcher.log";
DiagnosticBuffer diagnosticBuffer;
}
size_t DiagnosticBuffer::expire(ULONGLONG now)
@@ -832,6 +833,11 @@ void diagnosticOperation(const std::string &json)
record["context"] = utf8(diagnosticBuffer.record(L"Operation complete", true, GetTickCount64()));
log(L"Operation " + wide(record.dump(-1, ' ', true, nlohmann::json::error_handler_t::replace)));
}
void setLogFilename(std::wstring filename)
{
std::lock_guard lock(logGate);
logFilename = std::move(filename);
}
void log(const std::wstring &message)
{
std::lock_guard lock(logGate);
@@ -839,7 +845,7 @@ void log(const std::wstring &message)
return;
try
{
auto path = dataDirectory() / L"kswitcher.log";
auto path = dataDirectory() / logFilename;
std::error_code ec;
auto size = std::filesystem::file_size(path, ec);
if (!ec && size > 256 * 1024)
+1
View File
@@ -94,6 +94,7 @@ class DiagnosticBuffer
void clear() { entries.clear(); }
};
void setLoggingEnabled(bool enabled);
void setLogFilename(std::wstring filename);
void expireDiagnostics(ULONGLONG now);
void diagnosticEvent(const std::wstring &message, bool problem = false);
void diagnosticOperation(const std::string &json);
+3
View File
@@ -205,6 +205,7 @@ void validateSettings(const Settings &s)
throw std::runtime_error("Use Tesseract language codes separated by +, for example ukr+eng+rus");
std::vector<Hotkey> used;
std::vector<std::wstring> keys{s.toggle, s.casing, s.translit, s.undo, s.clipboard, s.screenshot};
if (s.filesHotkey) keys.push_back(*s.filesHotkey);
keys.insert(keys.end(), s.direct.begin(), s.direct.end());
for (auto &text : keys)
{
@@ -370,6 +371,7 @@ Settings decodeSettings(const Bytes &bytes, const std::vector<Layout> &layouts)
s.undo = str(j, "undoHotkey", s.undo);
s.clipboard = str(j, "clipboardHotkey", s.clipboard);
s.screenshot = str(j, "screenshotHotkey", s.screenshot);
if (j.contains("filesHotkey")) s.filesHotkey = wide(j.at("filesHotkey").get<std::string>());
s.screenshotAutoOpenEditor = j.value("screenshotAutoOpenEditor", false);
if (j.contains("layouts"))
{
@@ -529,6 +531,7 @@ Bytes encodeSettings(const Settings &s)
{"clipboardHotkey", utf8(s.clipboard)},
{"screenshotHotkey", utf8(s.screenshot)},
{"screenshotAutoOpenEditor", s.screenshotAutoOpenEditor}};
if (s.filesHotkey) j["filesHotkey"] = utf8(*s.filesHotkey);
j["layouts"] = Json::array();
j["appLanguageSets"] = Json::object();
for (const auto &[name, set] : s.appLanguageSets)
+6 -1
View File
@@ -119,7 +119,7 @@ std::wstring fieldLabel(const std::string &key)
static const std::map<std::string, std::wstring> labels{
{"toggleHotkey", L"Next layout shortcut"}, {"caseHotkey", L"Change case shortcut"},
{"translitHotkey", L"Transliteration shortcut"}, {"undoHotkey", L"Undo shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"},
{"clipboardHotkey", L"Clipboard shortcut"}, {"screenshotHotkey", L"Screenshot shortcut"}, {"filesHotkey", L"Files shortcut"},
{"layouts", L"Layout cycle order"}, {"layoutHotkeys", L"Layout shortcuts"},
{"chatCycles", L"Chat languages"}, {"mouseBindings", L"Mouse buttons"},
{"convertLastWord", L"Convert recent word"}, {"switchSystemLayout", L"Switch system layout"},
@@ -213,6 +213,7 @@ void collisionPaths(const Json &j, std::set<std::string> &paths)
if (!inserted) { paths.insert(path); paths.insert(it->second); }
};
for (const auto *key : {"toggleHotkey", "caseHotkey", "translitHotkey", "undoHotkey", "clipboardHotkey", "screenshotHotkey"}) add(j.at(key), std::string("/") + key);
if (j.contains("filesHotkey")) add(j.at("filesHotkey"), "/filesHotkey");
for (const auto &[id, key] : j.at("layoutHotkeys").items()) add(key, "/layoutHotkeys/" + pointerKey(id));
}
}
@@ -223,6 +224,10 @@ SyncMerge mergeSyncProfile(const Bytes &base, const Bytes &local, const Bytes &r
// client's chat profiles until both copies understand this field.
if (!remoteProfile.contains("chatCycles")) remoteProfile["chatCycles"] = localProfile.value("chatCycles", Json::object());
if (!localProfile.contains("chatCycles")) localProfile["chatCycles"] = remoteProfile.at("chatCycles");
if (!remoteProfile.contains("filesHotkey") && localProfile.contains("filesHotkey"))
remoteProfile["filesHotkey"] = localProfile.at("filesHotkey");
if (!localProfile.contains("filesHotkey") && remoteProfile.contains("filesHotkey"))
localProfile["filesHotkey"] = remoteProfile.at("filesHotkey");
// Older server backups omit the newly synchronized fields. An explicit
// empty value from a current client still means the user cleared it.
preserveMissingSettings(remoteProfile, localProfile);
+8 -4
View File
@@ -1460,6 +1460,7 @@ void fillSettings(HWND w, SettingsView *v)
value(w, 213, s.undo);
value(w, 214, s.clipboard);
value(w, 215, s.screenshot);
value(w, 216, s.filesHotkey.value_or(L""));
value(w, 240, std::to_wstring(s.maxItems));
value(w, 241, std::to_wstring(s.maxMiB));
value(w, 242, std::to_wstring(s.maxImageMiB));
@@ -1513,6 +1514,9 @@ Settings collectSettings(HWND w, SettingsView *v)
s.undo = value(w, 213);
s.clipboard = value(w, 214);
s.screenshot = value(w, 215);
auto filesHotkey = value(w, 216);
if (s.filesHotkey || !filesHotkey.empty())
s.filesHotkey = std::move(filesHotkey);
s.maxItems = integer(w, 240);
s.maxMiB = integer(w, 241);
s.maxImageMiB = integer(w, 242);
@@ -2522,13 +2526,13 @@ void showSettings(App &app, bool visible)
page();
label(w, L"Click a shortcut field and press the desired keys", 24, 76, 660);
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture"};
for (int i = 0; i < 6; ++i)
const wchar_t *names[] = {L"Next layout", L"Change case", L"Transliterate", L"Undo", L"Clipboard history", L"Screen capture", L"Open KSwitcher Files"};
for (int i = 0; i < static_cast<int>(std::size(names)); ++i)
{
label(w, names[i], 24, 116 + i * 40, 180);
hotkeyEdit(app, w, 210 + i, 230, 112 + i * 40, 454);
}
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 378, 660);
checkbox(w, L"Open editor after screen capt&ure", 209, 24, 400, 660);
page();
checkbox(w, L"Record clip&board history", 203, 24, 80, 660);
@@ -2623,7 +2627,7 @@ void showShortcuts(App &app)
auto &s = app.settings;
std::wstring text = L"Next layout: " + s.toggle + L"\nChange case: " + s.casing + L"\nTransliterate: " +
s.translit + L"\nUndo: " + s.undo + L"\nClipboard history: " + s.clipboard +
L"\nScreen capture: " + s.screenshot + L"\n";
L"\nScreen capture: " + s.screenshot + L"\nOpen KSwitcher Files: " + s.filesHotkey.value_or(L"") + L"\n";
for (size_t i = 0; i < std::min(app.layouts.size(), s.direct.size()); ++i)
if (!s.direct[i].empty())
text += L"\n" + app.layouts[i].name + L": " + s.direct[i];
+6 -1
View File
@@ -55,7 +55,12 @@ UpdateResult runUpdate(bool install, const std::wstring &expectedVersion, bool s
L" -CurrentVersion " + quoteArgument(AppVersion) + L" -WorkDirectory " + quoteArgument(work.wstring()) +
L" -InstallDirectory " + quoteArgument(std::filesystem::path(executable()).parent_path().wstring()) +
L" -ParentProcessId " + std::to_wstring(GetCurrentProcessId()) + L" -ReadyEvent " + quoteArgument(readyName);
if (install) command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
if (install && expectedVersion.empty())
{
command += L" -Unattended -Journal " + quoteArgument((dataDirectory() / L"kswitcher.log").wstring());
}
else if (install)
command += L" -ExpectedVersion " + quoteArgument(expectedVersion);
if (showProgress) command += L" -ShowProgress";
STARTUPINFOW startup{sizeof(startup)};
PROCESS_INFORMATION process{};
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.77";
inline constexpr wchar_t AppVersion[] = L"0.6.91";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+7 -2
View File
@@ -292,11 +292,16 @@ int main()
HWND picker = GetDlgItem(dialog, 728);
RECT dialogBounds{};
GetWindowRect(dialog, &dialogBounds);
MONITORINFO monitor{sizeof(monitor)};
require(GetMonitorInfoW(MonitorFromWindow(dialog, MONITOR_DEFAULTTONEAREST), &monitor),
"monitor work area available");
const auto targetX = std::clamp(dialogBounds.left + 32, monitor.rcWork.left, monitor.rcWork.right - 120);
const auto targetY = std::clamp(dialogBounds.top + 100, monitor.rcWork.top, monitor.rcWork.bottom - 48);
HWND target = CreateWindowExW(WS_EX_TOPMOST | WS_EX_NOACTIVATE, L"EDIT", L"Picker fixture",
WS_POPUP | WS_VISIBLE, dialogBounds.left + 32, dialogBounds.top + 100, 120, 48,
WS_POPUP | WS_VISIBLE, targetX, targetY, 120, 48,
nullptr, nullptr, GetModuleHandleW(nullptr), nullptr);
require(picker && target, "window picker and target available");
POINT drop{dialogBounds.left + 33, dialogBounds.top + 101};
POINT drop{targetX + 1, targetY + 1};
require(GetAncestor(WindowFromPoint(drop), GA_ROOT) == target, "fixture window is under the drop point");
ScreenToClient(picker, &drop);
SendMessageW(picker, WM_LBUTTONDOWN, MK_LBUTTON, MAKELPARAM(8, 8));
+323
View File
@@ -0,0 +1,323 @@
param(
[Parameter(Mandatory)][string]$Executable,
[Parameter(Mandatory)][string]$Fixture
)
$ErrorActionPreference = 'Stop'
Add-Type @"
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class FilesActivationUi {
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)]
struct GuiInfo {
public int Size, Flags;
public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret;
public Rect CaretRect;
}
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string type, string title);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] static extern bool SetForegroundWindow(IntPtr window);
[DllImport("user32.dll")] static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr window, IntPtr process);
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread, ref GuiInfo info);
[DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr Send(IntPtr window, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll", EntryPoint="SendMessageW")] static extern IntPtr SendRect(IntPtr window, uint msg, IntPtr w, out Rect rect);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr Read(IntPtr window, uint msg, IntPtr w, StringBuilder text);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr Write(IntPtr window, uint msg, IntPtr w, string text);
[DllImport("shell32.dll")] public static extern void SHChangeNotify(int eventId, uint flags, IntPtr first, IntPtr second);
public static IntPtr Focus(IntPtr window) {
var info = new GuiInfo { Size = Marshal.SizeOf(typeof(GuiInfo)) };
if (!GetGUIThreadInfo(GetWindowThreadProcessId(window, IntPtr.Zero), ref info))
throw new InvalidOperationException("Could not read Files focus.");
return info.Focus;
}
public static bool Activate(IntPtr window) {
if (GetForegroundWindow() == window) return true;
keybd_event(0x12, 0, 0, UIntPtr.Zero);
keybd_event(0x12, 0, 2, UIntPtr.Zero);
SetForegroundWindow(window);
return GetForegroundWindow() == window;
}
public static void ClickRow(IntPtr list, int row, bool doubleClick) {
Rect rect;
if (SendRect(list, 0x198, new IntPtr(row), out rect).ToInt32() == -1)
throw new InvalidOperationException("Missing listbox row.");
var point = new IntPtr(((rect.Top + rect.Bottom) / 2 << 16) | (rect.Left + 6));
PostMessage(list, 0x201, new IntPtr(1), point);
PostMessage(list, 0x202, IntPtr.Zero, point);
if (doubleClick) {
PostMessage(list, 0x203, new IntPtr(1), point);
PostMessage(list, 0x202, IntPtr.Zero, point);
}
}
}
"@
function Wait-Until([string]$Description, [scriptblock]$Condition) {
$timer = [Diagnostics.Stopwatch]::StartNew()
while ($timer.Elapsed.TotalSeconds -lt 15) {
if (& $Condition) { return }
if ($process -and $process.HasExited) { throw "Files exited while waiting for $Description" }
Start-Sleep -Milliseconds 50
}
throw "Timed out waiting for $Description"
}
function Read-Text($Control, $Message = 0x0D, $Parameter = 32768) {
$buffer = [Text.StringBuilder]::new(32768)
[void][FilesActivationUi]::Read($Control, $Message, [IntPtr]$Parameter, $buffer)
$buffer.ToString()
}
function Find-Row($List, [string]$Name) {
$count = [FilesActivationUi]::Send($List, 0x18B, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32()
for ($row = 0; $row -lt $count; $row++) {
if ((Read-Text $List 0x189 $row) -ceq $Name) { return $row }
}
-1
}
function Select-Row($List, [string]$Name, [switch]$DoubleClick) {
Wait-Until "row $Name" { (Find-Row $List $Name) -ge 0 }
$row = Find-Row $List $Name
[void][FilesActivationUi]::Send($List, 0x197, [IntPtr]$row, [IntPtr]::Zero)
[FilesActivationUi]::ClickRow($List, $row, $DoubleClick.IsPresent)
if (!$DoubleClick) {
Wait-Until "selection of $Name" {
[FilesActivationUi]::Focus($window) -eq $inputBox -and
[FilesActivationUi]::Send($List, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq (Find-Row $List $Name)
}
}
}
function Press-Enter($Control) {
[void][FilesActivationUi]::PostMessage($Control, 0x100, [IntPtr]13, [IntPtr]::Zero)
}
function Invoke-Row($List, [string]$Name, [bool]$DoubleClick) {
Select-Row $List $Name -DoubleClick:$DoubleClick
if (!$DoubleClick) { Press-Enter $inputBox }
}
function Send-Terminal([string]$Command) {
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, $Command)
Press-Enter $inputBox
}
function Assert-Open($List, $Target, [bool]$DoubleClick) {
$report = "$Target.opened"
Remove-Item -LiteralPath $report -ErrorAction SilentlyContinue
Invoke-Row $List ([IO.Path]::GetFileName($Target)) $DoubleClick
Wait-Until "launch of $Target (double click: $DoubleClick)" {
(Test-Path -LiteralPath $report) -and [IO.File]::ReadAllText($report) -eq 'opened'
}
}
$unique = [Guid]::NewGuid().ToString('N')
$root = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher test $unique"
$profile = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\KSwitcher\Files')
$classes = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Classes')
$extension = ".ksfiles$unique"
$progId = "KSwitcher.Files.Test.$unique"
$saved = @{}
$profileValues = 'LeftPath', 'RightPath', 'LeftCursor', 'RightCursor', 'LeftWide', 'RightWide',
'ShowDetails', 'StatusFields', 'CommandHistory', 'WindowPlacement'
$process = $null
$trace = $null
try {
foreach ($name in $profileValues) {
if ($name -in $profile.GetValueNames()) {
$saved[$name] = @($profile.GetValue($name), $profile.GetValueKind($name))
}
}
$leftDir = Join-Path $root 'Left panel'
$rightDir = Join-Path $root 'Right panel'
$app = Join-Path $root 'Application'
New-Item -ItemType Directory -Path $leftDir, $rightDir, $app, "$leftDir\Test folder", "$rightDir\Test folder" | Out-Null
$source = Split-Path (Resolve-Path -LiteralPath $Executable)
$testExe = Join-Path $app 'KSwitcher.Files.exe'
Copy-Item -LiteralPath $Executable -Destination $testExe
Copy-Item -LiteralPath "$source\WebView2Loader.dll", "$source\terminal" -Destination $app -Recurse
$program = Join-Path $leftDir 'Test program.exe'
Copy-Item -LiteralPath $Fixture -Destination $program
$document = Join-Path $rightDir "Test document$extension"
[IO.File]::WriteAllText($document, 'Document fixture')
$association = $classes.CreateSubKey($extension)
$association.SetValue('', $progId)
$association.Dispose()
$handler = $classes.CreateSubKey("$progId\shell\open\command")
$handler.SetValue('', ('"{0}" "%1"' -f (Resolve-Path -LiteralPath $Fixture).Path))
$handler.Dispose()
[FilesActivationUi]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero)
$batch = Join-Path $leftDir "Test's script.BAT"
$cmd = Join-Path $rightDir 'Test script.CMD'
[IO.File]::WriteAllLines($batch, @('@echo off', 'echo BAT=%KS_TEST_STATE%'))
[IO.File]::WriteAllLines($cmd, @('@echo off', 'echo CMD=%KS_TEST_STATE%'))
$profile.SetValue('LeftPath', $leftDir)
$profile.SetValue('RightPath', $rightDir)
foreach ($name in 'LeftCursor', 'RightCursor') { $profile.DeleteValue($name, $false) }
foreach ($name in 'LeftWide', 'RightWide', 'ShowDetails') { $profile.SetValue($name, 1) }
$profile.SetValue('StatusFields', 3)
$process = Start-Process -FilePath $testExe -ArgumentList '--terminal-output-trace' -PassThru
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
Wait-Until 'Files window' { $process.Refresh(); $process.MainWindowHandle -ne [IntPtr]::Zero }
$window = $process.MainWindowHandle
$left = [FilesActivationUi]::GetDlgItem($window, 102)
$right = [FilesActivationUi]::GetDlgItem($window, 103)
$status = [FilesActivationUi]::GetDlgItem($window, 144)
$output = [FilesActivationUi]::GetDlgItem($window, 145)
$inputBox = [FilesActivationUi]::FindWindowEx($window, [IntPtr]::Zero, 'Edit', $null)
Select-Row $left '..'
[void][FilesActivationUi]::Send($right, 0x186, [IntPtr]::Zero, [IntPtr]::Zero)
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, 'draft command')
[void][FilesActivationUi]::PostMessage($inputBox, 0x100, [IntPtr]9, [IntPtr]::Zero)
[void][FilesActivationUi]::PostMessage($inputBox, 0x100, [IntPtr]40, [IntPtr]::Zero)
Wait-Until 'Tab and Down navigating the right pane with command-line focus' {
[FilesActivationUi]::Focus($window) -eq $inputBox -and
[FilesActivationUi]::Send($right, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq 1 -and
[FilesActivationUi]::Send($left, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -eq (Find-Row $left '..')
}
if ((Read-Text $inputBox) -cne 'draft command') { throw 'Pane navigation changed the command line.' }
$shell = New-Object -ComObject WScript.Shell
$fileName = [IO.Path]::GetFileName($document)
foreach ($name in $fileName, 'Test folder') {
$label = if ($name -eq $fileName) { $name } else { "[$name]" }
Select-Row $right $label
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, 'draft command')
[void][FilesActivationUi]::Send($inputBox, 0xB1, [IntPtr]6, [IntPtr]13)
Wait-Until 'Files activation for Ctrl+Enter' {
[FilesActivationUi]::Activate($window) -and
[FilesActivationUi]::Focus($window) -eq $inputBox
}
$shell.SendKeys('^{ENTER}')
$expected = 'draft "{0}"' -f $name
Wait-Until "Ctrl+Enter inserting $name" { (Read-Text $inputBox) -ceq $expected }
}
[void][FilesActivationUi]::Write($inputBox, 0x0C, [IntPtr]::Zero, '')
Write-Output 'Tab and Down navigated the panes while preserving command-line focus and text.'
foreach ($double in $false, $true) {
Assert-Open $left $program $double
Assert-Open $right $document $double
}
Invoke-Row $left '[Test folder]' $false
Wait-Until 'folder navigation by Enter' {
(Read-Text ([FilesActivationUi]::GetDlgItem($window, 142))) -eq "$leftDir\Test folder"
}
Invoke-Row $left '..' $false
Invoke-Row $right '[Test folder]' $true
Wait-Until 'folder navigation by double click' {
(Read-Text ([FilesActivationUi]::GetDlgItem($window, 143))) -eq "$rightDir\Test folder"
}
Invoke-Row $right '..' $false
Write-Output 'Enter and double-click opened folders, the registered document, and the standalone EXE.'
$sessionValue = $unique.Substring(0, 8)
Send-Terminal ('$global:ksSession = "{0}"; $env:KS_TEST_STATE = $ksSession; "READY=" + $ksSession' -f $sessionValue)
Wait-Until 'initialized terminal session and rendered test marker' {
(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue) -contains "READY=$sessionValue"
}
Invoke-Row $left ([IO.Path]::GetFileName($batch)) $false
Wait-Until 'visible BAT output with the existing session environment' {
[FilesActivationUi]::IsWindowVisible($output) -and
(Get-Content -LiteralPath $trace) -contains "BAT=$sessionValue"
}
Invoke-Row $right ([IO.Path]::GetFileName($cmd)) $true
Wait-Until 'visible CMD output with the existing session environment' {
[FilesActivationUi]::IsWindowVisible($output) -and
(Get-Content -LiteralPath $trace) -contains "CMD=$sessionValue"
}
Send-Terminal '"AFTER=" + $global:ksSession'
Wait-Until 'unchanged PowerShell session state' {
(Get-Content -LiteralPath $trace) -contains "AFTER=$sessionValue"
}
Assert-Open $left $program $false
Write-Output 'BAT and CMD ran in the existing terminal and rendered their output.'
$external = 'External change.txt'
Select-Row $left ([IO.Path]::GetFileName($program))
foreach ($dir in $leftDir, $rightDir) { [IO.File]::WriteAllText((Join-Path $dir $external), 'one') }
Wait-Until 'external creation in both panels' {
(Find-Row $left $external) -ge 0 -and (Find-Row $right $external) -ge 0
}
if ([FilesActivationUi]::Focus($window) -ne $inputBox -or
[FilesActivationUi]::Send($left, 0x188, [IntPtr]::Zero, [IntPtr]::Zero).ToInt32() -ne
(Find-Row $left ([IO.Path]::GetFileName($program)))) { throw 'Creation moved focus or selection.' }
foreach ($panel in @(@{ List = $left; Dir = $leftDir }, @{ List = $right; Dir = $rightDir })) {
Select-Row $panel.List $external
Wait-Until 'initial file size' { (Read-Text $status) -match ' 3 B$' }
[IO.File]::AppendAllText((Join-Path $panel.Dir $external), 'two!')
Wait-Until 'updated file size' { (Read-Text $status) -match ' 7 B$' }
if ([FilesActivationUi]::Focus($window) -ne $inputBox) { throw 'Modification moved focus.' }
}
foreach ($dir in $leftDir, $rightDir) { [IO.File]::Delete((Join-Path $dir $external)) }
Wait-Until 'external deletion in both panels' {
(Find-Row $left $external) -eq -1 -and (Find-Row $right $external) -eq -1
}
if ([FilesActivationUi]::Focus($window) -ne $inputBox) { throw 'Deletion moved focus.' }
Write-Output 'Both panels refreshed external creation, modification and deletion while preserving focus.'
Add-Type -AssemblyName System.Windows.Forms
$savedClipboard=[Windows.Forms.Clipboard]::GetDataObject()
$programName=[IO.Path]::GetFileName($program)
$expectedNames="Test folder`r`n$programName"
try {
Select-Row $left '[Test folder]'
[void][FilesActivationUi]::Send($left,0x100,[IntPtr]45,[IntPtr]::Zero)
Select-Row $left $programName
[void][FilesActivationUi]::Send($left,0x100,[IntPtr]45,[IntPtr]::Zero)
Wait-Until 'Files activation for Ctrl+Insert' {
[FilesActivationUi]::Activate($window) -and [FilesActivationUi]::Focus($window) -eq $inputBox
}
$shell.SendKeys('^{INSERT}')
Wait-Until 'Ctrl+Insert copying marked names in panel order' {
[Windows.Forms.Clipboard]::GetText() -ceq $expectedNames
}
Select-Row $right $fileName
$shell.SendKeys('^{INSERT}')
Wait-Until 'Ctrl+Insert copying the current item in the other pane' {
[Windows.Forms.Clipboard]::GetText() -ceq $fileName
}
Write-Output 'Ctrl+Insert copied marked names in order and used the cursor in the other pane.'
} finally {
if ([Windows.Forms.Clipboard]::GetText() -cin @($expectedNames,$fileName)) {
if ($savedClipboard) { [Windows.Forms.Clipboard]::SetDataObject($savedClipboard,$true) }
else { [Windows.Forms.Clipboard]::Clear() }
}
}
} finally {
if ($process -and !$process.HasExited) {
[void]$process.CloseMainWindow()
if (!$process.WaitForExit(4000)) { $process.Kill(); $process.WaitForExit() }
}
foreach ($name in $profileValues) {
if ($saved.ContainsKey($name)) { $profile.SetValue($name, $saved[$name][0], $saved[$name][1]) }
else { $profile.DeleteValue($name, $false) }
}
$profile.Dispose()
$classes.DeleteSubKeyTree($extension, $false)
$classes.DeleteSubKeyTree($progId, $false)
$classes.Dispose()
[FilesActivationUi]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero)
if ($trace) { Remove-Item -LiteralPath $trace -ErrorAction SilentlyContinue }
$resolvedRoot = Resolve-Path -LiteralPath $root -ErrorAction SilentlyContinue
$tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if ($resolvedRoot -and $resolvedRoot.Path.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $resolvedRoot.Path -Recurse -Force -ErrorAction SilentlyContinue
}
}
+19
View File
@@ -0,0 +1,19 @@
#include <windows.h>
#include <filesystem>
#include <fstream>
int wmain(int argc,wchar_t **argv)
{
// A launch through the embedded terminal would inherit its console.
DWORD consoleProcesses[1]{};
if(GetConsoleProcessList(consoleProcesses,1)>1) return 1;
wchar_t executable[32768]{};
const auto length=GetModuleFileNameW(nullptr,executable,static_cast<DWORD>(std::size(executable)));
if(!length || length>=std::size(executable)) return 1;
const std::filesystem::path target=argc==2?argv[1]:executable;
auto report=target;
report+=L".opened";
std::ofstream result(report,std::ios::binary);
result<<"opened";
return result?0:1;
}
+68
View File
@@ -0,0 +1,68 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type @'
using System;
using System.Text;
using System.Runtime.InteropServices;
public static class FilesDriveShortcutTest {
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr window, uint msg, IntPtr w, IntPtr l);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
public static extern IntPtr SendMessage(IntPtr window, uint msg, IntPtr w, StringBuilder text);
}
'@
function Wait-Until([scriptblock]$Condition) {
for ($attempt = 0; $attempt -lt 60; $attempt++) {
if (& $Condition) { return }
Start-Sleep -Milliseconds 50
}
throw "Timed out waiting for: $Condition"
}
function Read-Text($Control, $Message = 13, $Parameter = 32768) {
$buffer = [Text.StringBuilder]::new(32768)
[void][FilesDriveShortcutTest]::SendMessage($Control, $Message, [IntPtr]$Parameter, $buffer)
$buffer.ToString()
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
try {
Wait-Until { $process.Refresh(); $process.MainWindowHandle -ne [IntPtr]::Zero }
$window = $process.MainWindowHandle
$picker = [FilesDriveShortcutTest]::GetDlgItem($window, 121)
$path = [FilesDriveShortcutTest]::GetDlgItem($window, 142)
function Open-Picker {
[void][FilesDriveShortcutTest]::PostMessage($window, 260, [IntPtr]112, [IntPtr]::Zero)
Wait-Until { [FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
function Press-Shortcut([char]$Key) {
[void][FilesDriveShortcutTest]::PostMessage($picker, 258, [IntPtr][int]$Key, [IntPtr]::Zero)
Wait-Until { ![FilesDriveShortcutTest]::IsWindowVisible($picker) }
}
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
$disk = $rows | Where-Object { $_ -match '^[A-Z]: fixed\t' } | Select-Object -First 1
if (!$disk) { throw 'No fixed drive available for the shortcut test.' }
Press-Shortcut ([char]::ToLowerInvariant($disk[0]))
$root = $disk.Substring(0, 2) + '\'
Wait-Until { (Read-Text $path) -eq $root }
Write-Output 'Drive letter opened the drive immediately.'
Open-Picker
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$rows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
for ($round = 1; $round -le 2; $round++) {
Wait-Until {
$count = [FilesDriveShortcutTest]::SendMessage($picker, 395, [IntPtr]::Zero, $null).ToInt32()
$script:remoteRows = @(for ($i = 0; $i -lt $count; $i++) { Read-Text $picker 393 $i })
[bool]($script:remoteRows | Where-Object { $_ -match '^ [A-Z]: ' })
}
if ((Read-Text $path) -ne $root) { throw 'Loading the remote drives changed the local path.' }
[void][FilesDriveShortcutTest]::PostMessage($picker, 256, [IntPtr]27, [IntPtr]::Zero)
Wait-Until { ![FilesDriveShortcutTest]::IsWindowVisible($picker) }
if ($round -eq 1) { Open-Picker }
}
Write-Output 'Remote drives appeared automatically on both opens.'
} finally {
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+60
View File
@@ -0,0 +1,60 @@
#include <windows.h>
#include <commctrl.h>
#include <algorithm>
#include <iostream>
#include <string_view>
#include <utility>
#include <vector>
#include "files_text_window.h"
namespace
{
#include "files_dialog_input.inl"
bool cancelled(HWND dialog)
{
MSG message{};
return PeekMessageW(&message,dialog,WM_COMMAND,WM_COMMAND,PM_REMOVE) &&
LOWORD(message.wParam)==IDCANCEL;
}
}
int main()
{
const auto instance=GetModuleHandleW(nullptr);
const auto owner=CreateWindowW(L"STATIC",L"Files",WS_OVERLAPPEDWINDOW,
0,0,300,200,nullptr,nullptr,instance,nullptr);
const auto dialog=CreateWindowW(L"STATIC",L"Search",WS_POPUP,
0,0,200,100,owner,nullptr,instance,nullptr);
const auto external=CreateWindowW(L"STATIC",L"Other",WS_OVERLAPPEDWINDOW,
0,0,300,200,nullptr,nullptr,instance,nullptr);
if(!owner || !dialog || !external)
{
std::cerr << "Could not create modal test windows.\n";
return 1;
}
POINT client{10,10},frame{10,-1};
ClientToScreen(owner,&client);
ClientToScreen(owner,&frame);
if(handleFilesDialogClick(dialog,external,client) ||
handleFilesDialogClick(dialog,GetDesktopWindow(),client) ||
handleFilesDialogClick(dialog,nullptr,client) || cancelled(dialog))
{
std::cerr << "A click outside Files cancelled the dialog.\n";
return 1;
}
if(handleFilesDialogClick(dialog,owner,frame) || cancelled(dialog))
{
std::cerr << "A click on the owner's frame cancelled the dialog.\n";
return 1;
}
if(!handleFilesDialogClick(dialog,owner,client) || !cancelled(dialog))
{
std::cerr << "A click in the Files client area did not cancel the dialog.\n";
return 1;
}
DestroyWindow(owner);
DestroyWindow(external);
std::cout << "Verified Files modal handling of external, frame, and client-area clicks.\n";
}
+78
View File
@@ -0,0 +1,78 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe")
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class PanelFrameWindow {
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr window, int id);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
}
'@
function Get-Bounds($Window) {
$bounds = [PanelFrameWindow+Rect]::new()
if (![PanelFrameWindow]::GetWindowRect($Window, [ref]$bounds)) { throw 'Missing panel control.' }
$bounds
}
function Read-Strip($X, $Y, $Width, $Height) {
$bitmap = [Drawing.Bitmap]::new($Width, $Height)
try {
$graphics = [Drawing.Graphics]::FromImage($bitmap)
try { $graphics.CopyFromScreen($X, $Y, 0, 0, $bitmap.Size) }
finally { $graphics.Dispose() }
for ($row = 0; $row -lt $Height; $row++) {
for ($column = 0; $column -lt $Width; $column++) {
$bitmap.GetPixel($column, $row).ToArgb()
}
}
} finally { $bitmap.Dispose() }
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -PassThru
$previousDpi = [PanelFrameWindow]::SetThreadDpiAwarenessContext([IntPtr](-4))
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50; $attempt++) {
$process.Refresh()
$window = $process.MainWindowHandle
if ($window -and $keyboard.AppActivate($process.Id)) { break }
Start-Sleep -Milliseconds 100
}
Start-Sleep -Milliseconds 300
if (!$window -or [PanelFrameWindow]::GetForegroundWindow() -ne $window) {
throw 'The test window must be visible and in the foreground.'
}
foreach ($panel in 0, 1) {
$heading = [PanelFrameWindow]::GetDlgItem($window, 140 + $panel)
if (![PanelFrameWindow]::IsWindowVisible($heading)) {
throw 'Both panels must use Wide mode for the frame test.'
}
$header = Get-Bounds $heading
$list = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 102 + $panel))
$path = Get-Bounds ([PanelFrameWindow]::GetDlgItem($window, 142 + $panel))
$middle = [int](($header.Top + $header.Bottom) / 2)
$side = @(Read-Strip ($list.Left - 4) ($header.Top - 2) 4 1)
$atHeading = @(Read-Strip ($list.Left - 4) $middle 4 1)
if ($side[0] -ne $side[3] -or $side[1] -ne $side[2] -or $side[0] -eq $side[1] -or
($atHeading -join ',') -ne ($side -join ',')) {
throw "Panel $panel has a broken double frame beside the column header."
}
$x = $list.Left + 16
$top = @(Read-Strip $x ($path.Top + [Math]::Floor(($path.Bottom - $path.Top) / 2) - 2) 1 4)
if (($top -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent top frame." }
$bottom = @(Read-Strip $x ($list.Bottom + 4) 1 ($header.Bottom - $header.Top + 6))
$bottom = $bottom[($bottom.Count - 4)..($bottom.Count - 1)]
if (($bottom -join ',') -ne ($side -join ',')) { throw "Panel $panel has an inconsistent bottom frame." }
Write-Output "Panel $panel double frame verified on screen."
}
} finally {
if ($previousDpi) { [void][PanelFrameWindow]::SetThreadDpiAwarenessContext($previousDpi) }
if (!$process.HasExited) { [void]$process.CloseMainWindow() }
}
+692
View File
@@ -0,0 +1,692 @@
param([Parameter(Mandatory)][string]$Executable)
$ErrorActionPreference='Stop'
Add-Type -AssemblyName System.Drawing
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class SearchArrowTest {
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string name, string title);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr window, out uint pid);
[DllImport("user32.dll")] public static extern IntPtr GetDlgItem(IntPtr hwnd, int id);
[DllImport("user32.dll", EntryPoint="SendMessageW")] public static extern IntPtr Send(IntPtr hwnd, uint message, IntPtr w, IntPtr l);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr SendText(IntPtr hwnd, uint message, IntPtr w, string l);
[DllImport("user32.dll", EntryPoint="SendMessageW", CharSet=CharSet.Unicode)] public static extern IntPtr GetText(IntPtr hwnd, uint message, IntPtr w, StringBuilder l);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hwnd, uint message, IntPtr w, IntPtr l);
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hwnd, IntPtr after, int x, int y, int w, int h, uint flags);
[DllImport("user32.dll")] public static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
[DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hwnd, IntPtr dc, uint flags);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hwnd, out Rect rect);
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hwnd);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hwnd);
[DllImport("user32.dll")] public static extern IntPtr GetWindow(IntPtr hwnd,uint command);
[DllImport("user32.dll",EntryPoint="GetWindowLongPtrW")] public static extern IntPtr GetWindowStyle(IntPtr hwnd,int index);
[StructLayout(LayoutKind.Sequential)]
struct PropertyKey { public Guid Format; public uint Id; }
[StructLayout(LayoutKind.Explicit, Size=24)]
struct PropertyValue {
[FieldOffset(0)] public ushort Type;
[FieldOffset(8)] public IntPtr String;
}
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface WindowProperties {
[PreserveSig] int GetCount(out uint count);
[PreserveSig] int GetAt(uint index, out PropertyKey key);
[PreserveSig] int GetValue(ref PropertyKey key, out PropertyValue value);
}
[DllImport("shell32.dll")] static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out WindowProperties properties);
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PropertyValue value);
public static string TaskbarIdentity(IntPtr window) {
var iid = typeof(WindowProperties).GUID;
if (SHGetPropertyStoreForWindow(window, ref iid, out var properties) < 0) return null;
try {
var key = new PropertyKey { Format = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), Id = 5 };
if (properties.GetValue(ref key, out var value) < 0) return null;
try { return value.Type == 31 ? Marshal.PtrToStringUni(value.String) : null; }
finally { PropVariantClear(ref value); }
} finally { Marshal.ReleaseComObject(properties); }
}
[DllImport("user32.dll")] public static extern bool IsWindowEnabled(IntPtr hwnd);
[StructLayout(LayoutKind.Sequential)]
struct GuiThreadInfo {
public int Size, Flags;
public IntPtr Active, Focus, Capture, MenuOwner, MoveSize, Caret;
public Rect CaretBounds;
}
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread, ref GuiThreadInfo info);
public static IntPtr FocusedControl(IntPtr window) {
var info = new GuiThreadInfo { Size = Marshal.SizeOf<GuiThreadInfo>() };
return GetGUIThreadInfo(GetWindowThreadProcessId(window, out _), ref info) ? info.Focus : IntPtr.Zero;
}
public static bool HasCheckboxCaret(IntPtr checkbox) {
var info = new GuiThreadInfo { Size = Marshal.SizeOf<GuiThreadInfo>() };
return GetGUIThreadInfo(GetWindowThreadProcessId(checkbox, out _), ref info) &&
info.Caret == checkbox && info.CaretBounds.Left > 0 &&
info.CaretBounds.Right > info.CaretBounds.Left &&
info.CaretBounds.Bottom - info.CaretBounds.Top < info.CaretBounds.Right - info.CaretBounds.Left;
}
}
"@
function Wait-Until($Block) {
for ($i=0; $i -lt 120; $i++) {
$value = & $Block
if ($value) { return $value }
Start-Sleep -Milliseconds 100
}
throw "Timeout: $Block"
}
function Find-Window($Name, $ProcessId, [int]$ControlId=0, [IntPtr]$Except=[IntPtr]::Zero) {
$wnd = [IntPtr]::Zero
while (($wnd = [SearchArrowTest]::FindWindowEx(0, $wnd, $Name, [System.Management.Automation.Language.NullString]::Value)) -ne [IntPtr]::Zero) {
[uint32]$ownerProcess = 0
[void][SearchArrowTest]::GetWindowThreadProcessId($wnd, [ref]$ownerProcess)
if ($ownerProcess -eq $ProcessId -and $wnd -ne $Except -and [SearchArrowTest]::IsWindowVisible($wnd) -and
(!$ControlId -or [SearchArrowTest]::GetDlgItem($wnd,$ControlId) -ne [IntPtr]::Zero)) { return $wnd }
}
}
function Select-Row($Panel, $Name) {
$row = [SearchArrowTest]::SendText($Panel, 0x1A2, -1, $Name)
if ($row.ToInt64() -lt 0) { throw "Missing row $Name" }
[void][SearchArrowTest]::Send($Panel, 0x186, $row, 0)
}
function Post($Window, $Message, $W, $L = 0) {
if (![SearchArrowTest]::PostMessage($Window, $Message, $W, $L)) { throw "Post failed" }
}
function Assert-FarCheckbox($Dialog, [int]$Id, $NextControl) {
$checkbox=[SearchArrowTest]::GetDlgItem($Dialog,$Id)
Post $Dialog 0x28 $checkbox 1
[void](Wait-Until { [SearchArrowTest]::HasCheckboxCaret($checkbox) })
$checked=[SearchArrowTest]::Send($checkbox,0xF0,0,0)
[void][SearchArrowTest]::Send($checkbox,0xF5,0,0)
if ([SearchArrowTest]::Send($checkbox,0xF0,0,0) -eq $checked) { throw 'The Far checkbox did not toggle.' }
[void][SearchArrowTest]::Send($checkbox,0xF5,0,0)
Post $Dialog 0x28 $NextControl 1
[void](Wait-Until {
[SearchArrowTest]::FocusedControl($Dialog) -eq $NextControl -and
![SearchArrowTest]::HasCheckboxCaret($checkbox)
})
}
function Read-Bounds($Window) {
$bounds=[SearchArrowTest+Rect]::new()
if (![SearchArrowTest]::GetWindowRect($Window,[ref]$bounds)) { throw 'Missing window bounds' }
$bounds
}
function Assert-CompactNotice($Dialog, [int]$DefaultButton) {
$text=[SearchArrowTest]::GetDlgItem($Dialog,100)
if (([SearchArrowTest]::GetWindowStyle($text,-16).ToInt64() -band 0x00200000) -ne 0) {
throw 'A short Files notice has a vertical scrollbar.'
}
$lines=[SearchArrowTest]::Send($text,0xBA,0,0).ToInt32()
$body=Read-Bounds $text
$button=[SearchArrowTest]::GetDlgItem($Dialog,$DefaultButton)
$footer=Read-Bounds $button
$lineHeight=($body.Bottom-$body.Top)/$lines
$frame=Read-Bounds $Dialog
if (($body.Top-$frame.Top) -gt 2*$lineHeight -or
($frame.Bottom-$footer.Bottom) -gt $lineHeight) {
throw 'A short Files notice has excess space between its content and frame.'
}
if (($footer.Top-$body.Bottom) -gt $lineHeight -or $footer.Top -le $body.Bottom) {
throw 'A short Files notice has incorrect spacing between the text and the buttons.'
}
if ([SearchArrowTest]::FocusedControl($Dialog) -ne $button) {
throw 'A Files notice has the wrong starting focus.'
}
}
function Assert-FarFieldHeight($Dialog, [int[]]$Ids) {
$heights=@(foreach ($id in $Ids) {
$bounds=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,$id))
$bounds.Bottom-$bounds.Top
})
if (@($heights | Select-Object -Unique).Count -ne 1) {
throw "Far field heights differ: $heights."
}
}
function Assert-FarDropDown($Popup, $Anchor, [int]$ArrowWidth=0) {
$bounds=Read-Bounds $Popup
$anchorBounds=Read-Bounds $Anchor
if ($bounds.Left -ne $anchorBounds.Left -or $bounds.Top -ne $anchorBounds.Bottom -or
$bounds.Right -ne $anchorBounds.Right-$ArrowWidth) {
throw 'The Far list is not aligned with the field edges.'
}
$bitmap=[Drawing.Bitmap]::new($bounds.Right-$bounds.Left,$bounds.Bottom-$bounds.Top)
try {
$graphics=[Drawing.Graphics]::FromImage($bitmap)
try {
$dc=$graphics.GetHdc()
try {
if (![SearchArrowTest]::PrintWindow($Popup,$dc,0)) { throw 'Could not render Far list.' }
} finally { $graphics.ReleaseHdc($dc) }
} finally { $graphics.Dispose() }
$background=$bitmap.GetPixel(2,2)
if ($background.G -lt 85 -or $background.B -lt 85 -or $background.R -ge $background.G) {
throw "The Far list background is not teal: $background."
}
$frame=$false
$selection=$false
for ($y=3; $y -lt $bitmap.Height-3; $y++) {
$pixel=$bitmap.GetPixel([int]($bitmap.Width/3),$y)
if ($y -lt 16 -and $pixel.R -gt 220 -and $pixel.G -gt 220 -and $pixel.B -gt 220) { $frame=$true }
if ($pixel.R -lt 16 -and $pixel.G -lt 16 -and $pixel.B -lt 16) { $selection=$true }
}
if (!$frame -or !$selection) { throw 'The Far list is missing its light frame or black selected row.' }
} finally { $bitmap.Dispose() }
}
function Resize-Window($Window, $Bounds, [int]$Width, [switch]$Clamped) {
if (![SearchArrowTest]::SetWindowPos($Window,0,$Bounds.Left,$Bounds.Top,$Width,
($Bounds.Bottom-$Bounds.Top),0x14)) { throw 'Could not resize the owner window.' }
if (!$Clamped) {
[void](Wait-Until {
$current=Read-Bounds $Window
$current.Right-$current.Left -eq $Width
})
}
}
function Assert-MovesWithOwner($Owner, $Dialog) {
$ownerBefore=Read-Bounds $Owner
$dialogBefore=Read-Bounds $Dialog
$dx=17
$dy=23
if (![SearchArrowTest]::SetWindowPos($Owner,0,($ownerBefore.Left+$dx),($ownerBefore.Top+$dy),0,0,0x15)) {
throw 'Could not move the dialog owner.'
}
try {
for ($i=0; $i -lt 20; $i++) {
$ownerAfter=Read-Bounds $Owner
$dialogAfter=Read-Bounds $Dialog
$ownerDeltaX=$ownerAfter.Left-$ownerBefore.Left
$ownerDeltaY=$ownerAfter.Top-$ownerBefore.Top
$dialogDeltaX=$dialogAfter.Left-$dialogBefore.Left
$dialogDeltaY=$dialogAfter.Top-$dialogBefore.Top
if ($ownerDeltaX -eq $dx -and $ownerDeltaY -eq $dy -and
$dialogDeltaX -eq $dx -and $dialogDeltaY -eq $dy) { return }
Start-Sleep -Milliseconds 100
}
throw "Expected movement ($dx, $dy). Owner moved ($ownerDeltaX, $ownerDeltaY), dialog moved ($dialogDeltaX, $dialogDeltaY)."
} finally {
[void][SearchArrowTest]::SetWindowPos($Owner,0,$ownerBefore.Left,$ownerBefore.Top,0,0,0x15)
}
}
$scratch=Join-Path ([IO.Path]::GetDirectoryName((Resolve-Path -LiteralPath $Executable).Path)) ('search-arrow-test-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $scratch | Out-Null
$fixture = Join-Path $scratch ('files-editor-ui-' + [Guid]::NewGuid().ToString('N') + '.cs')
[IO.File]::WriteAllText($fixture, "public class Test {`r`n}`r`n")
$copy=Join-Path $scratch 'KSwitcher.Files.search-arrow-test.exe'
Copy-Item -LiteralPath $Executable -Destination $copy
$process = Start-Process -FilePath $copy -PassThru -WindowStyle Hidden
try {
$previousDpi=[SearchArrowTest]::SetThreadDpiAwarenessContext([IntPtr](-4))
$main = Wait-Until { Find-Window 'KSwitcher.Files' $process.Id }
Post $main 0x111 132
$commandMenu=Wait-Until {
$list=[SearchArrowTest]::GetDlgItem($main,121)
if ([SearchArrowTest]::IsWindowVisible($list)) { $list }
}
$count=[SearchArrowTest]::Send($commandMenu,0x18B,0,0).ToInt32()
$rows=@(for ($i=0; $i -lt $count; $i++) {
$label=[Text.StringBuilder]::new(128)
[void][SearchArrowTest]::GetText($commandMenu,0x189,$i,$label)
$label.ToString()
})
if (($rows -join '|') -cne "Refresh`tF2|Folder shortcuts||View`tF3|Edit`tF4|View externally`tAlt+F3|Edit externally`tAlt+F4") {
throw 'Commands did not group local actions or showed a LAN-only section.'
}
Post $commandMenu 0x100 40
Post $commandMenu 0x100 40
[void](Wait-Until { [SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -eq 3 })
Post $commandMenu 0x100 38
[void](Wait-Until { [SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -eq 1 })
$rowHeight=[SearchArrowTest]::Send($commandMenu,0x1A1,0,0).ToInt32()
$separatorPoint=((2*$rowHeight+1) -shl 16) -bor 4
[void][SearchArrowTest]::Send($commandMenu,0x201,1,$separatorPoint)
[void][SearchArrowTest]::Send($commandMenu,0x202,0,$separatorPoint)
if ([SearchArrowTest]::Send($commandMenu,0x188,0,0).ToInt32() -ne 1) {
throw 'A click selected the Commands separator.'
}
Post $commandMenu 0x100 27
[void](Wait-Until { ![SearchArrowTest]::IsWindowVisible($commandMenu) })
Write-Output 'Verified Commands grouping, arrow navigation, and inert separators.'
$panel = Wait-Until { $handle = [SearchArrowTest]::GetDlgItem($main,102); if ($handle -ne [IntPtr]::Zero) { $handle } }
$pathControl = [SearchArrowTest]::GetDlgItem($main,142)
$pathText = [Text.StringBuilder]::new(1024)
[void][SearchArrowTest]::GetText($pathControl,0xD,$pathText.Capacity,$pathText)
$path=$pathText.ToString()
if ([IO.Path]::GetPathRoot($path) -ne [IO.Path]::GetPathRoot($scratch)) {
throw 'Select a local left-panel folder on the test executable drive before running this interactive test.'
}
foreach ($component in $path.Substring(3).Split('\',[System.StringSplitOptions]::RemoveEmptyEntries)) {
Select-Row $panel '..'
[void][SearchArrowTest]::Send($panel,0x100,13,0)
}
foreach ($component in $scratch.Substring(3).Split('\')) {
Select-Row $panel "[$component]"
[void][SearchArrowTest]::Send($panel,0x100,13,0)
}
$newFolder='F7 тестова папка'
Post $panel 0x100 0x76
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
Assert-FarFieldHeight $makeDialog @(361,363,365,367)
$nameBounds=Read-Bounds $nameField
$arrowBounds=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,363))
if ($nameBounds.Right -ne $arrowBounds.Left) { throw 'F7 history arrow does not touch the field.' }
$okButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,1))
$cancelButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($makeDialog,2))
$okWidth=$okButton.Right-$okButton.Left
$gap=$cancelButton.Left-$okButton.Right
if ($gap -lt 0 -or $gap -gt $okWidth/7 -or $cancelButton.Right-$cancelButton.Left -le $okWidth) {
throw 'Make folder buttons are not grouped at their caption widths.'
}
[void][SearchArrowTest]::SendText($nameField,0xC,0,$newFolder)
Post $makeDialog 0x111 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
if (!(Test-Path -LiteralPath (Join-Path $scratch $newFolder) -PathType Container)) {
throw 'F7 did not create the folder in the active panel.'
}
$newRow=[SearchArrowTest]::SendText($panel,0x1A2,-1,"[$newFolder]")
if ($newRow.ToInt64() -lt 0 -or [SearchArrowTest]::Send($panel,0x188,0,0) -ne $newRow) {
throw 'F7 did not select the new folder.'
}
Post $main 0x111 115
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
[void][SearchArrowTest]::SendText($nameField,0xC,0,$newFolder)
Post $makeDialog 0x111 1
$folderError=Wait-Until { Find-Window '#32770' $process.Id 100 }
Assert-CompactNotice $folderError 1
Post $folderError 0x111 1
[void](Wait-Until {
![SearchArrowTest]::IsWindow($folderError) -and
[SearchArrowTest]::FocusedControl($makeDialog) -eq $nameField
})
[void][SearchArrowTest]::SendText($nameField,0xC,0,'Canceled folder')
Post $nameField 0x100 27
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
if (Test-Path -LiteralPath (Join-Path $scratch 'Canceled folder')) { throw 'Canceled F7 created a folder.' }
Write-Output 'Verified F7 folder creation, selection, duplicate error, and cancellation.'
Post $panel 0x100 0x76
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
if (![SearchArrowTest]::IsWindowEnabled([SearchArrowTest]::GetDlgItem($makeDialog,363))) {
throw 'F7 did not persist the folder name in history.'
}
Post $makeDialog 0x111 363
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
Assert-FarDropDown $popup $nameField
Post $popup 0x100 13
[void](Wait-Until { ![SearchArrowTest]::IsWindow($popup) })
$chosen=[Text.StringBuilder]::new(256)
[void][SearchArrowTest]::GetText($nameField,0xD,$chosen.Capacity,$chosen)
if ($chosen.ToString() -ne $newFolder) { throw 'The history list did not fill in the selected name.' }
Post $makeDialog 0x111 363
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
Post $popup 0x100 27
[void](Wait-Until {
![SearchArrowTest]::IsWindow($popup) -and [SearchArrowTest]::FocusedControl($makeDialog) -eq $nameField
})
$targetField=[SearchArrowTest]::GetDlgItem($makeDialog,367)
if ([SearchArrowTest]::IsWindowEnabled($targetField)) { throw 'Target is enabled with link type none.' }
$multiple=[SearchArrowTest]::GetDlgItem($makeDialog,368)
[void][SearchArrowTest]::Send($multiple,0xF1,1,0)
[void][SearchArrowTest]::SendText($nameField,0xC,0,'Alpha;"Beta, Gamma", Delta')
Post $makeDialog 0x111 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
foreach ($name in 'Alpha','Beta, Gamma','Delta') {
if (!(Test-Path -LiteralPath (Join-Path $scratch $name) -PathType Container)) {
throw "F7 did not create '$name' in the batch."
}
}
[IO.File]::WriteAllText((Join-Path $scratch 'Alpha\link-target.txt'),'F7 link target')
foreach ($kind in 1,2) {
Post $panel 0x100 0x76
$makeDialog=Wait-Until { Find-Window '#32770' $process.Id 361 }
$nameField=[SearchArrowTest]::GetDlgItem($makeDialog,361)
$selector=[SearchArrowTest]::GetDlgItem($makeDialog,365)
$targetField=[SearchArrowTest]::GetDlgItem($makeDialog,367)
Post $selector 0x100 0x73
$popup=Wait-Until { Find-Window 'KSwitcher.Files.DropDown' $process.Id }
Assert-FarDropDown $popup $selector ($arrowBounds.Right-$arrowBounds.Left)
for ($row=0; $row -lt $kind; $row++) { Post $popup 0x100 0x28 }
Post $popup 0x100 13
[void](Wait-Until { ![SearchArrowTest]::IsWindow($popup) })
[void](Wait-Until { [SearchArrowTest]::IsWindowEnabled($targetField) })
$linkName="Folder link $kind"
[void][SearchArrowTest]::SendText($nameField,0xC,0,$linkName)
[void][SearchArrowTest]::SendText($targetField,0xC,0,'Alpha')
Post $makeDialog 0x111 1
$linkPath=Join-Path $scratch $linkName
$outcome=Wait-Until {
if (![SearchArrowTest]::IsWindow($makeDialog)) { 'created' }
elseif (Find-Window '#32770' $process.Id 100) { 'error' }
}
if ($outcome -eq 'error' -and $kind -eq 2) {
# The test host can deny symlink creation by policy. Confirm the same result with Windows.
$probe=Join-Path $scratch 'Symbolic link privilege probe'
try {
[void][IO.Directory]::CreateSymbolicLink($probe,(Join-Path $scratch 'Alpha'))
throw 'Windows can create symbolic links, but F7 reported an error.'
} catch {
if ($_.Exception.InnerException -isnot [IO.IOException] -and
$_.Exception.InnerException -isnot [UnauthorizedAccessException]) { throw }
}
$alert=Find-Window '#32770' $process.Id 100
Post $alert 0x111 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($alert) })
Post $makeDialog 0x111 2
[void](Wait-Until { ![SearchArrowTest]::IsWindow($makeDialog) })
Write-Output 'Windows denied directory symbolic link creation and F7 reported the error.'
continue
}
if ($outcome -ne 'created') { throw "F7 failed to create link type $kind." }
$link=Get-Item -LiteralPath $linkPath
$expected=if ($kind -eq 1) { 'Junction' } else { 'SymbolicLink' }
if ($link.LinkType -ne $expected -or
!(Test-Path -LiteralPath (Join-Path $linkPath 'link-target.txt') -PathType Leaf)) {
throw "F7 did not create a $expected pointing to Alpha."
}
}
Write-Output 'Verified F7 history, quoted batch names, and directory link handling.'
Select-Row $panel ([IO.Path]::GetFileName($fixture))
Post $panel 0x100 0x74
$copyDialog=Wait-Until {
$dialog=Find-Window '#32770' $process.Id
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,211) -ne [IntPtr]::Zero) { $dialog }
}
if (![SearchArrowTest]::IsWindowEnabled($main)) { throw 'Copy disabled the Files frame.' }
Assert-FarFieldHeight $copyDialog @(211,213)
Assert-MovesWithOwner $main $copyDialog
$mainBounds=Read-Bounds $main
$copyBounds=Read-Bounds $copyDialog
$copyPreferred=$copyBounds.Right-$copyBounds.Left
try {
Resize-Window $main $mainBounds ([Math]::Max(540,[int]($copyPreferred*0.65))) -Clamped
[void](Wait-Until {
$current=Read-Bounds $copyDialog
$current.Right-$current.Left -lt $copyPreferred
})
} finally {
Resize-Window $main $mainBounds ($mainBounds.Right-$mainBounds.Left)
}
[void](Wait-Until {
$current=Read-Bounds $copyDialog
$current.Right-$current.Left -eq $copyPreferred
})
$destination=[SearchArrowTest]::GetDlgItem($copyDialog,211)
Assert-FarCheckbox $copyDialog 214 $destination
if ([SearchArrowTest]::GetDlgItem($copyDialog,328) -ne [IntPtr]::Zero) { throw 'Copy has an inline error row.' }
$compact=Read-Bounds $copyDialog
[void][SearchArrowTest]::SendText($destination,0xC,0,'')
Post $copyDialog 0x111 1
$alert=Wait-Until { Find-Window '#32770' $process.Id 100 }
$alertBounds=Read-Bounds $alert
$alertText=Read-Bounds ([SearchArrowTest]::GetDlgItem($alert,100))
$alertButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($alert,1))
if ($alertText.Bottom-$alertText.Top -gt 2*($alertButton.Bottom-$alertButton.Top) -or
$alertButton.Top-$alertText.Bottom -gt $alertButton.Bottom-$alertButton.Top) {
throw 'The short error alert has excess space around the message.'
}
$bitmap=[Drawing.Bitmap]::new($alertBounds.Right-$alertBounds.Left,$alertBounds.Bottom-$alertBounds.Top)
try {
$graphics=[Drawing.Graphics]::FromImage($bitmap)
try {
$dc=$graphics.GetHdc()
try {
if (![SearchArrowTest]::PrintWindow($alert,$dc,0)) { throw 'Could not render the path alert.' }
} finally { $graphics.ReleaseHdc($dc) }
} finally { $graphics.Dispose() }
$background=$bitmap.GetPixel(4,4)
if ($background.R -lt 100 -or $background.G -gt 20 -or $background.B -gt 20) {
throw "Path alert did not use the red warning surface. Got $background."
}
} finally { $bitmap.Dispose() }
Post $alert 0x111 1
[void](Wait-Until {
![SearchArrowTest]::IsWindow($alert) -and [SearchArrowTest]::FocusedControl($copyDialog) -eq $destination
})
$current=Read-Bounds $copyDialog
if ($current.Bottom-$current.Top -ne $compact.Bottom-$compact.Top) { throw 'The error resized Copy vertically.' }
Write-Output 'Verified the red path alert and focus restoration to Copy.'
Post $panel 0x201 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($copyDialog) })
function Open-Search {
Post $editor 0x111 3
Wait-Until {
$dialog=Find-Window '#32770' $process.Id
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,108) -ne [IntPtr]::Zero) { $dialog }
}
}
function Assert-Arrow($Dialog, [bool]$Enabled) {
$arrow=[SearchArrowTest]::GetDlgItem($Dialog,108)
$arrowBounds=[SearchArrowTest+Rect]::new()
$dialogBounds=[SearchArrowTest+Rect]::new()
if (![SearchArrowTest]::GetWindowRect($arrow,[ref]$arrowBounds) -or
![SearchArrowTest]::GetWindowRect($Dialog,[ref]$dialogBounds)) { throw 'Missing Search bounds' }
if ([SearchArrowTest]::IsWindowEnabled($arrow) -ne $Enabled) { throw 'Unexpected history button state' }
$bitmap=[Drawing.Bitmap]::new($dialogBounds.Right-$dialogBounds.Left,$dialogBounds.Bottom-$dialogBounds.Top)
try {
$graphics=[Drawing.Graphics]::FromImage($bitmap)
try {
$dc=$graphics.GetHdc()
try {
if (![SearchArrowTest]::PrintWindow($Dialog,$dc,0)) { throw 'Could not render the Search dialog' }
} finally { $graphics.ReleaseHdc($dc) }
} finally { $graphics.Dispose() }
$x=$arrowBounds.Left-$dialogBounds.Left
$y=$arrowBounds.Top-$dialogBounds.Top
$width=$arrowBounds.Right-$arrowBounds.Left
$height=$arrowBounds.Bottom-$arrowBounds.Top
$field=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,100))
$inset=$field.Left-$dialogBounds.Left
foreach ($dividerId in 104,105) {
$divider=Read-Bounds ([SearchArrowTest]::GetDlgItem($Dialog,$dividerId))
$line=$divider.Top-$dialogBounds.Top
for ($column=$inset; $column -lt $bitmap.Width-$inset; $column++) {
$pixel=$bitmap.GetPixel($column,$line)
if ($pixel.R -ne 0 -or $pixel.G -ne 0 -or $pixel.B -ne 0) {
throw "Search divider $dividerId is broken at pixel $column."
}
}
}
$background=$bitmap.GetPixel($x+2,$y+2)
if ([Math]::Abs($background.R-$background.G) -gt 1 -or
[Math]::Abs($background.G-$background.B) -gt 1 -or $background.R -lt 175) {
throw "Expected a light gray arrow slot, got $background."
}
$ink=@(for ($row=1; $row -lt $height-1; $row++) {
for ($column=1; $column -lt $width-1; $column++) {
$pixel=$bitmap.GetPixel($x+$column,$y+$row)
if ($background.R-[Math]::Max($pixel.R,[Math]::Max($pixel.G,$pixel.B)) -ge 100) { $row; break }
}
})
if ($ink.Count -lt [Math]::Max(5,$height/3)) { throw 'The arrow is missing or has insufficient contrast.' }
if ([Math]::Abs($ink[0]+$ink[-1]-($height-1)) -gt 2) {
throw "The arrow is vertically off center. Ink spans rows $($ink[0])–$($ink[-1]) in a $height-pixel slot."
}
} finally { $bitmap.Dispose() }
}
Post $panel 0x100 0x73
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
$firstControl=[SearchArrowTest]::FindWindowEx($editor,0,'Scintilla',[System.Management.Automation.Language.NullString]::Value)
if ($firstControl -eq [IntPtr]::Zero -or
[SearchArrowTest]::Send($firstControl,2482,32,0).ToInt64() -eq 0xFFFFFF) {
throw 'The editor is visible with the default white Scintilla background.'
}
$originalFocus=Wait-Until {
$focus=[SearchArrowTest]::FocusedControl($editor)
if ($focus -ne [IntPtr]::Zero) { $focus }
}
if (![SearchArrowTest]::IsWindowEnabled($main) -or [SearchArrowTest]::GetWindow($editor,4) -ne [IntPtr]::Zero -or
([SearchArrowTest]::GetWindowStyle($editor,-20).ToInt64() -band 0x40000) -eq 0) {
throw 'The editor is not an independent taskbar window.'
}
Post $panel 0x100 0x73
$existing=Wait-Until { Find-Window '#32770' $process.Id 100 }
$notice=[Text.StringBuilder]::new(256)
[void][SearchArrowTest]::GetText([SearchArrowTest]::GetDlgItem($existing,100),0xD,$notice.Capacity,$notice)
if (!$notice.ToString().Contains('already open')) { throw 'Reopening did not offer to switch to the editor.' }
Assert-CompactNotice $existing 7
Post $existing 0x111 6
[void](Wait-Until { ![SearchArrowTest]::IsWindow($existing) })
if (Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor) { throw 'Duplicate editor opened.' }
$otherFile=Join-Path $scratch 'other-editor.cs'
[IO.File]::WriteAllText($otherFile,'class Other { }')
[void](Wait-Until {
[SearchArrowTest]::SendText($panel,0x1A2,-1,'other-editor.cs').ToInt64() -ge 0
})
Select-Row $panel 'other-editor.cs'
Post $panel 0x100 0x73
$otherEditor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id 0 $editor }
if ([SearchArrowTest]::GetWindow($otherEditor,4) -ne [IntPtr]::Zero) { throw 'The second editor is owned.' }
$editorIdentity=[SearchArrowTest]::TaskbarIdentity($editor)
$otherIdentity=[SearchArrowTest]::TaskbarIdentity($otherEditor)
if (!$editorIdentity -or !$otherIdentity -or $editorIdentity -eq $otherIdentity) {
throw 'Editor windows do not have distinct Windows taskbar identities.'
}
Post $otherEditor 0x10 0
[void](Wait-Until { ![SearchArrowTest]::IsWindow($otherEditor) })
Select-Row $panel ([IO.Path]::GetFileName($fixture))
Post $editor 7 0
[void](Wait-Until { [SearchArrowTest]::FocusedControl($editor) -eq $originalFocus })
Write-Output 'Verified independent editor taskbar styling and the already-open prompt.'
foreach ($action in 'Escape','outside click','owner close') {
$dialog=Open-Search
$field=[SearchArrowTest]::GetDlgItem($dialog,100)
[void](Wait-Until { [SearchArrowTest]::FocusedControl($dialog) -eq $field })
if (![SearchArrowTest]::IsWindowEnabled($editor)) { throw 'Search disabled the editor frame.' }
switch ($action) {
'Escape' {
Post $main 0x111 115
$managerPrompt=Wait-Until { Find-Window '#32770' $process.Id 361 }
Post $managerPrompt 0x111 2
[void](Wait-Until { ![SearchArrowTest]::IsWindow($managerPrompt) })
if (![SearchArrowTest]::IsWindow($dialog)) { throw 'The manager dismissed the editor Search dialog.' }
Assert-MovesWithOwner $editor $dialog
$ownerBounds=Read-Bounds $editor
$dialogBounds=Read-Bounds $dialog
$originalWidth=$ownerBounds.Right-$ownerBounds.Left
$preferred=$dialogBounds.Right-$dialogBounds.Left
try {
Resize-Window $editor $ownerBounds ([int]($preferred*0.92))
$reduced=Wait-Until {
$current=Read-Bounds $dialog
$width=$current.Right-$current.Left
if ($width -lt $preferred) { $width }
}
Assert-Arrow $dialog $false
Resize-Window $editor $ownerBounds ([int]($preferred*0.30)) -Clamped
$minimum=Wait-Until {
$current=Read-Bounds $dialog
if ($current.Right-$current.Left -lt $reduced) { $current }
}
$floor=$minimum.Right-$minimum.Left
Resize-Window $editor $ownerBounds ([int]($preferred*0.25)) -Clamped
$stillMinimum=Read-Bounds $dialog
$narrowOwner=Read-Bounds $editor
if ($floor -ne ($stillMinimum.Right-$stillMinimum.Left) -or
$stillMinimum.Left -lt $narrowOwner.Left -or $stillMinimum.Right -gt $narrowOwner.Right) {
throw 'The editor shrank below the minimum width needed to contain Search.'
}
$previous=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,103))
$next=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,1))
if ($previous.Bottom -gt $next.Top) { throw 'The narrow Search actions are not on separate rows.' }
Assert-Arrow $dialog $false
} finally {
Resize-Window $editor $ownerBounds $originalWidth
}
[void](Wait-Until {
$restored=Read-Bounds $dialog
$restored.Right-$restored.Left -eq $preferred
})
Post $field 0x100 27
}
'outside click' { Post $editor 0x201 1 }
'owner close' { Post $editor 0x10 0 }
}
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
if (![SearchArrowTest]::IsWindow($editor)) { throw "$action closed the editor." }
[void](Wait-Until { [SearchArrowTest]::FocusedControl($editor) -eq $originalFocus })
}
Write-Output 'Verified Search focus and dismissal by Escape, outside click, and editor close.'
Write-Output 'Verified that Search narrows to its own minimum and regains its width as the editor grows.'
$dialog=Open-Search
Assert-FarCheckbox $dialog 101 ([SearchArrowTest]::GetDlgItem($dialog,100))
$previousButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,103))
$nextButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,1))
if ($nextButton.Left-$previousButton.Right -ne $gap) { throw 'Search and F7 use different button gaps.' }
Assert-Arrow $dialog $false
[void][SearchArrowTest]::SendText([SearchArrowTest]::GetDlgItem($dialog,100),0xC,0,'public')
Post $dialog 0x111 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
$dialog=Open-Search
Assert-Arrow $dialog $true
Write-Output 'Verified continuous Search dividers and history arrow contrast and centering.'
Post ([SearchArrowTest]::GetDlgItem($dialog,100)) 0x100 27
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
Post $editor 0x10 0
[void](Wait-Until { ![SearchArrowTest]::IsWindow($editor) })
Post $panel 0x100 0x73
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
$editControl=Wait-Until {
$control=[SearchArrowTest]::FindWindowEx($editor,0,'Scintilla',[System.Management.Automation.Language.NullString]::Value)
if ($control -ne [IntPtr]::Zero) { $control }
}
$initialText=[IO.File]::ReadAllText($fixture)
Post $editControl 0x102 88
[void](Wait-Until { [SearchArrowTest]::Send($editControl,2159,0,0) -ne [IntPtr]::Zero })
function Open-SavePrompt {
Post $editor 0x10 0
Wait-Until {
$dialog=Find-Window '#32770' $process.Id
if ($dialog -and [SearchArrowTest]::GetDlgItem($dialog,7) -ne [IntPtr]::Zero) { $dialog }
}
}
$dialog=Open-SavePrompt
$saveButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,6))
$discardButton=Read-Bounds ([SearchArrowTest]::GetDlgItem($dialog,7))
if ($discardButton.Left-$saveButton.Right -ne $gap) { throw 'Save and F7 use different button gaps.' }
Post $editor 0x201 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
if (![SearchArrowTest]::IsWindow($editor) -or
[SearchArrowTest]::Send($editControl,2159,0,0) -eq [IntPtr]::Zero) {
throw 'Dismissing the save prompt did not preserve the edited document.'
}
$dialog=Open-SavePrompt
Post $dialog 0x111 7
[void](Wait-Until { ![SearchArrowTest]::IsWindow($editor) })
if ([IO.File]::ReadAllText($fixture) -ne $initialText) { throw 'Discard wrote to the file.' }
Write-Output 'Verified that dismissing the save prompt preserves edits, and Discard leaves the file untouched.'
Post $panel 0x100 0x72
$editor=Wait-Until { Find-Window 'KSwitcher.Files.Text' $process.Id }
$dialog=Open-Search
Assert-Arrow $dialog $true
Assert-MovesWithOwner $editor $dialog
Post $editor 0x201 1
[void](Wait-Until { ![SearchArrowTest]::IsWindow($dialog) })
if (![SearchArrowTest]::IsWindow($editor)) { throw 'Outside click closed the viewer.' }
Write-Output 'Verified outside-click cancellation in Copy and Viewer Search.'
Write-Output 'Verified that Copy and Search move and resize with their owners.'
Post $main 0x10 0
[void](Wait-Until { ![SearchArrowTest]::IsWindow($main) })
if (![SearchArrowTest]::IsWindow($editor)) { throw 'Closing the manager also closed the viewer.' }
Post $editor 0x10 0
if (!$process.WaitForExit(5000)) { throw 'Files did not exit after its last window closed.' }
}
finally {
if (!$process.HasExited) {
Stop-Process -Id $process.Id
[void]$process.WaitForExit(5000)
}
if ($previousDpi) { [void][SearchArrowTest]::SetThreadDpiAwarenessContext($previousDpi) }
$resolved=(Resolve-Path -LiteralPath $scratch).Path
$root=(Resolve-Path -LiteralPath (Split-Path -Parent $scratch)).Path
if ([IO.Path]::GetDirectoryName($resolved) -eq $root -and (Split-Path -Leaf $resolved) -like 'search-arrow-test-*') {
Remove-Item -LiteralPath $resolved -Recurse -Force
}
}
+174
View File
@@ -0,0 +1,174 @@
param([string]$Executable = "$PSScriptRoot/../build/files-dev/KSwitcher.Files.exe", [switch]$GeometryOnly)
$ErrorActionPreference = 'Stop'
if (!$GeometryOnly) {
Add-Type -AssemblyName System.Drawing
Add-Type -ReferencedAssemblies @([Drawing.Bitmap].Assembly.Location, [Drawing.Color].Assembly.Location,
[Drawing.Graphics].Assembly.Location, [Drawing.Point].Assembly.Location, "System.Collections", "System.Runtime", "System.Threading.Thread", "System.Runtime.InteropServices", "System.Diagnostics.TraceSource", "System.Private.Windows.GdiPlus", "System.Private.Windows.Core") -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
public static class TerminalFrames
{
[StructLayout(LayoutKind.Sequential)]
public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr window, out Rect rect);
[DllImport("user32.dll")]
private static extern void keybd_event(byte key, byte scan, uint flags, UIntPtr extra);
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
private static extern void mouse_event(uint flags, uint x, uint y, uint data, UIntPtr extra);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string kind, string title);
[DllImport("user32.dll", CharSet=CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr window, int message, IntPtr length, System.Text.StringBuilder text);
public static void ClickOutput(IntPtr window)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
SetCursorPos((bounds.Left + bounds.Right) / 2, (bounds.Top + bounds.Bottom) / 2);
mouse_event(2, 0, 0, 0, UIntPtr.Zero);
mouse_event(4, 0, 0, 0, UIntPtr.Zero);
}
public static string ReadCommand(IntPtr window)
{
var input = FindWindowEx(window, IntPtr.Zero, "Edit", null);
var text = new System.Text.StringBuilder(256);
SendMessage(input, 13, new IntPtr(text.Capacity), text);
return text.ToString();
}
public static int[] Capture(IntPtr window, byte direction, int repeats)
{
Rect bounds;
if (!GetWindowRect(window, out bounds)) throw new InvalidOperationException("Missing test window.");
var positions = new List<int>();
var clock = Stopwatch.StartNew();
using (var bitmap = new Bitmap(150, 320))
using (var graphics = Graphics.FromImage(bitmap))
{
var trigger = new Thread(() => {
Thread.Sleep(100);
keybd_event(17, 0, 0, UIntPtr.Zero);
for (int i = 0; i < repeats; i++)
{
keybd_event(direction, 0, 1, UIntPtr.Zero);
if (i + 1 < repeats) Thread.Sleep(30);
}
keybd_event(direction, 0, 3, UIntPtr.Zero);
keybd_event(17, 0, 2, UIntPtr.Zero);
});
trigger.Start();
while (clock.ElapsedMilliseconds < 650)
{
graphics.CopyFromScreen(bounds.Left + 4, bounds.Bottom - 340, 0, 0, bitmap.Size);
int marker = -1;
for (int y = 0; y < bitmap.Height && marker < 0; y++)
for (int x = 0; x < bitmap.Width; x++)
{
var color = bitmap.GetPixel(x, y);
if (color.R > 140 && color.B > 140 && color.R - color.G > 35 && color.B - color.G > 35) { marker = y; break; }
}
positions.Add(marker);
Thread.Sleep(4);
}
trigger.Join();
}
return positions.ToArray();
}
}
'@
}
function Wait-ForTrace([string]$Pattern, [int]$After = 0) {
for ($attempt = 0; $attempt -lt 100; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace -ErrorAction SilentlyContinue)
$match = $lines | Select-Object -Skip $After | Where-Object { $_ -match $Pattern } | Select-Object -First 1
if ($match) { return $match }
Start-Sleep -Milliseconds 100
}
throw "Missing terminal event: $Pattern"
}
$process = Start-Process -FilePath (Resolve-Path $Executable) -ArgumentList '--terminal-trace' -PassThru
$trace = Join-Path ([IO.Path]::GetTempPath()) "KSwitcher.Files.terminal-trace-$($process.Id).log"
try {
$keyboard = New-Object -ComObject WScript.Shell
for ($attempt = 0; $attempt -lt 50 -and !$keyboard.AppActivate($process.Id); $attempt++) {
Start-Sleep -Milliseconds 100
}
if (!$keyboard.AppActivate($process.Id)) { throw 'Could not focus the test window.' }
Wait-ForTrace ' layout ' | Out-Null
for ($attempt = 0; $attempt -lt 20; $attempt++) {
$lines = @(Get-Content -LiteralPath $trace)
if ($lines -match ' layout \d+ \d+ \d+ 1 1$') { break }
if ($keyboard.AppActivate($process.Id)) { $keyboard.SendKeys('^o') }
Start-Sleep -Milliseconds 250
}
Wait-ForTrace ' web-anchor ' | Out-Null
if (!$GeometryOnly) {
[TerminalFrames]::ClickOutput($process.MainWindowHandle)
Start-Sleep -Milliseconds 200
$keyboard.SendKeys('focus-check')
Start-Sleep -Milliseconds 200
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'focus-check') {
throw 'Typing after clicking terminal output did not reach the command line.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
$keyboard.SendKeys('alpha beta gamma^{BACKSPACE}^{BACKSPACE}')
Start-Sleep -Milliseconds 150
if ([TerminalFrames]::ReadCommand($process.MainWindowHandle) -ne 'alpha ') {
throw 'Ctrl+Backspace did not delete one word per press.'
}
$keyboard.SendKeys('^a{BACKSPACE}')
Write-Output 'Typing after clicking terminal output reached the command line.'
}
$keyboard.SendKeys('[Console]::WriteLine{(}{[}char{]}27 {+} "{[}95mMMMMMMMM" {+} {[}char{]}27 {+} "{[}0m"{)}{ENTER}')
Start-Sleep -Milliseconds 500
$keyboard.SendKeys('^o')
for ($row = 0; $row -lt 5; $row++) { $keyboard.SendKeys('^{UP}') }
Start-Sleep -Milliseconds 500
foreach ($scenario in @(@{Key='UP'; Count=1}, @{Key='DOWN'; Count=1}, @{Key='UP'; Count=10})) {
$direction = $scenario.Key
$repeats = $scenario.Count
$after = @(Get-Content -LiteralPath $trace).Count
$event = if ($direction -eq 'UP') { 'grow' } else { 'shrink' }
$frames = $null
if ($GeometryOnly) {
$keyboard.SendKeys("^{$direction $repeats}")
} else {
$key = if ($direction -eq 'UP') { 38 } else { 40 }
$frames = [TerminalFrames]::Capture($process.MainWindowHandle, $key, $repeats)
}
Wait-ForTrace " key-$event " $after | Out-Null
Wait-ForTrace ' layout ' $after | Out-Null
Start-Sleep -Milliseconds 250
$events = @(Get-Content -LiteralPath $trace | Select-Object -Skip $after)
if (@($events -match " key-$event ").Count -lt $repeats) {
throw "Did not deliver all $repeats Ctrl+$direction events."
}
if ($events -match ' web-after-fit ') { throw "Terminal grid resized after Ctrl+$direction." }
if (!$GeometryOnly) {
$baseline = $frames[0]
if ($baseline -lt 0 -or $frames[-1] -lt 0) {
throw "Terminal marker was not visible at rest for Ctrl+$direction."
}
$shifted = @($frames | Where-Object { $_ -lt 0 -or [Math]::Abs($_ - $baseline) -gt 2 })
if ($shifted.Count) {
throw "Ctrl+$direction shifted or hid the marker in $($shifted.Count) of $($frames.Count) frames (rest Y=$baseline, sampled Y=$($frames | Sort-Object -Unique))."
}
}
Write-Output "Ctrl+$direction x$repeats kept the terminal grid stable."
}
} finally {
if (!$process.HasExited) { $null = $process.CloseMainWindow() }
}
+162
View File
@@ -0,0 +1,162 @@
#include "files_text_document.h"
#include <objbase.h>
#include <winioctl.h>
#include <fstream>
#include <iostream>
#include <stdexcept>
namespace fs = std::filesystem;
using ks::files::TextDocument;
namespace
{
void check(bool condition, const char *message)
{
if (!condition) throw std::runtime_error(message);
}
struct Fixture
{
fs::path directory;
Fixture()
{
GUID id{};
wchar_t name[40]{};
check(SUCCEEDED(CoCreateGuid(&id)) && StringFromGUID2(id, name, 40), "Create fixture ID");
directory = fs::temp_directory_path() / (std::wstring(L"ks-files-text-") + name);
fs::create_directory(directory);
}
~Fixture()
{
std::error_code error;
fs::remove_all(directory, error);
}
};
void writeFile(const fs::path &path, const std::string &bytes)
{
std::ofstream output(path, std::ios::binary);
output.write(bytes.data(), bytes.size());
check(static_cast<bool>(output), "Write fixture");
}
std::string readFile(const fs::path &path)
{
std::ifstream input(path, std::ios::binary);
check(static_cast<bool>(input), "Read fixture");
return {std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>()};
}
void editsAcrossPages(const fs::path &file)
{
const std::string first = std::string(TextDocument::pageBytes - 2, 'a') + "\r\n";
const std::string middle = std::string(TextDocument::pageBytes - 2, 'b') + "\r\n";
const std::string last = "last\r\n";
const std::string bom = "\xef\xbb\xbf";
writeFile(file, bom + first + middle + last);
TextDocument document(file);
check(document.pages() == 3, "Expected three pages");
check(document.readPage(1) == middle, "Read middle page");
document.stage(2, "last changed\r\n");
check(!document.modifiedWith(2, last), "Restoring the only modified page leaves no changes");
check(document.modifiedWith(0, first), "An original page must not hide changes to other pages");
document.stage(0, "first changed\n");
check(document.readPage(2) == "last changed\r\n", "Read staged page");
check(document.save(), "Replace and reopen");
check(!document.modified(), "Save clears staged page edits");
check(readFile(file) == bom + "first changed\n" + middle + "last changed\r\n",
"Save must preserve untouched bytes and the BOM");
document.stage(0, "saved again\n");
check(document.save(), "Save again through the same document");
}
void unicodeBoundaries(const fs::path &file)
{
const auto chunk = TextDocument::pageBytes;
const std::string emoji = "\xf0\x9f\x98\x80";
writeFile(file, std::string(chunk - 1, 'a') + emoji + "tail");
{
TextDocument document(file, false);
check(document.boundary(1) == chunk + 3, "UTF-8 split within a code point");
check(document.readPage(0).ends_with(emoji) && document.readPage(1) == "tail", "Read split UTF-8");
}
for (const bool little : {true, false})
{
const std::string bom = little ? "\xff\xfe" : "\xfe\xff";
const std::string letter(little ? "a\0" : "\0a", 2);
const std::string pair(little ? "\x3d\xd8\x00\xde" : "\xd8\x3d\xde\x00", 4);
std::string first;
for (size_t n = 0; n < chunk / 2 - 1; ++n) first += letter;
first += pair;
writeFile(file, bom + first + letter);
TextDocument document(file);
check(document.readPage(0).ends_with(emoji) && document.readPage(1) == "a", "Read split UTF-16");
document.stage(1, emoji);
check(document.save() && readFile(file) == bom + first + pair, "Preserve UTF-16 byte order");
}
}
void rejectExternalReplacement(const fs::path &file)
{
writeFile(file, "original");
TextDocument document(file);
document.stage(0, "editor changes");
auto later = file;
later += L".later";
auto original = file;
original += L".original";
writeFile(later, "external changes");
check(MoveFileExW(file.c_str(), original.c_str(), 0) && MoveFileExW(later.c_str(), file.c_str(), 0),
"Replace file externally");
bool rejected{};
try { document.save(); }
catch (const std::exception &) { rejected = true; }
check(rejected && document.modified() && readFile(file) == "external changes",
"Preserve external file and staged changes after conflict");
}
void sparseFile(const fs::path &file)
{
constexpr uint64_t offset = 5ULL << 30;
const auto handle = CreateFileW(file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
check(handle != INVALID_HANDLE_VALUE, "Create sparse fixture");
DWORD count{};
if (!DeviceIoControl(handle, FSCTL_SET_SPARSE, nullptr, 0, nullptr, 0, &count, nullptr))
{
CloseHandle(handle);
std::cout << "Sparse fixture skipped on this file system.\n";
return;
}
LARGE_INTEGER position{};
position.QuadPart = offset;
const bool written = SetFilePointerEx(handle, position, nullptr, FILE_BEGIN) &&
WriteFile(handle, "last", 4, &count, nullptr) && count == 4;
CloseHandle(handle);
check(written, "Write beyond the 32-bit range");
TextDocument document(file);
const auto index = document.pages() - 1;
check(document.size() == offset + 4 && document.boundary(index) == offset, "Use 64-bit offsets");
check(document.readPage(index) == "last", "Read past 4 GiB");
document.stage(index, "edited");
check(document.readPage(index) == "edited", "Stage an edit past 4 GiB");
}
}
int main()
{
try
{
const Fixture fixture;
editsAcrossPages(fixture.directory / L"pages.txt");
unicodeBoundaries(fixture.directory / L"unicode.txt");
rejectExternalReplacement(fixture.directory / L"external.txt");
sparseFile(fixture.directory / L"5gib.log");
std::cout << "Files text document regressions passed.\n";
}
catch (const std::exception &error)
{
std::cerr << error.what() << '\n';
return 1;
}
}
+4
View File
@@ -97,6 +97,10 @@ int main(int argc,char **argv)
LanService restored(root);auto state=request(restored,{{"action","status"}});
check(state["peers"].size()==1 && state["peers"][0].at("paired").get<bool>() && !state["peers"][0].at("online").get<bool>(),"saved pair remains visible offline");
check(state.dump().find(left.derived)==std::string::npos,"status never exposes stored pair keys");
const auto filesPeers=request(restored,{{"action","filesPeers"}});
check(filesPeers.at("peers").size()==1 && filesPeers.at("peers")[0].at("id")==b &&
filesPeers.at("peers")[0].at("address")=="",
"Files refresh returns the saved pair before discovery finds an address");
rejects([&]{request(restored,{{"action","input"},{"enabled",true},{"peer",b},{"edge",1}});},"input cannot be enabled without discovery");
rejects([&]{request(restored,{{"action","input"},{"enabled",false},{"edge",4}});},"invalid screen edge rejected");
auto position=request(restored,{{"action","input"},{"enabled",false},{"peer",b},{"edge",2}});
+333
View File
@@ -0,0 +1,333 @@
#include <winsock2.h>
#include <sddl.h>
#include "lan_files.h"
#include "files_lan_ipc.h"
#include <bit>
#include <atomic>
#include <fstream>
#include <iostream>
#include <thread>
using namespace ks;
using namespace ks::lan;
namespace fs=std::filesystem;
namespace
{
void require(bool condition,const char *message)
{
if(!condition) throw std::runtime_error(message);
}
void verifyFilesPeerImage()
{
const fs::path install = LR"(C:\Tools\KSwitcher)";
const auto transaction = install / L".kswitcher-update-0123456789abcdef0123456789abcdef";
constexpr auto files = L"KSwitcher.Files.exe";
require(isFilesPeerImage(install / files, install), "installed Files can request LAN peers");
require(isFilesPeerImage(transaction / L"backup" / files, install),
"Files open during an update can still request LAN peers");
require(!isFilesPeerImage(transaction / L"stage" / files, install), "staged Files image is rejected");
require(!isFilesPeerImage(transaction / L"backup" / L"KSwitcher.Config.exe", install),
"another executable in the backup is rejected");
require(!isFilesPeerImage(install / L".kswitcher-update-invalid" / L"backup" / files, install),
"malformed transaction directory is rejected");
require(!isFilesPeerImage(install.parent_path() / transaction.filename() / L"backup" / files, install),
"backup outside the active installation is rejected");
}
void save(const fs::path &path,const std::string &text)
{
std::ofstream file(path,std::ios::binary|std::ios::trunc);
file.write(text.data(),text.size());
require(bool(file),"write test fixture");
}
std::string load(const fs::path &path)
{
std::ifstream file(path,std::ios::binary);
return {std::istreambuf_iterator<char>(file),{}};
}
void verifyDirection(const FilesPeer *sourcePeer,const FilesPeer *targetPeer,
const std::string &id,const std::string &key,const fs::path &root)
{
auto connect=[&](const FilesPeer *peer)
{
return peer?std::make_unique<FilesConnection>(*peer,id,key):std::make_unique<FilesConnection>();
};
const auto sourcePath=root/L"source.bin";
const auto targetPath=root/L"target.bin";
std::string content(FileChunk*3+5,'a');
for(size_t i=0;i<content.size();++i) content[i]=static_cast<char>((i/257)%251);
save(sourcePath,content);
save(targetPath,"original");
auto source=connect(sourcePeer);
const auto info=source->call(fileRequest("stat",sourcePath));
checkFileReply(info);
const auto size=info.at("size").get<uint64_t>(),modified=info.at("modified").get<uint64_t>();
auto target=connect(targetPeer);
try
{
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
[](uint64_t done,uint64_t)
{
if(done==FileChunk) throw std::system_error(WSAECONNRESET,std::system_category());
return true;
});
require(false,"interruption expected");
}
catch(const std::system_error &e){require(e.code().value()==WSAECONNRESET,"unexpected transfer failure");}
target.reset();
source.reset();
require(load(targetPath)=="original","interruption overwrote target");
require(load(sourcePath)==content,"interruption changed source");
fs::path partial;
for(const auto &entry:fs::directory_iterator(root))
if(entry.path().extension()==L".partial") partial=entry.path();
require(!partial.empty() && fs::file_size(partial)==FileChunk,"confirmed chunk was lost");
source=connect(sourcePeer);
target=connect(targetPeer);
uint64_t offset=UINT64_MAX;
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
[&](uint64_t done,uint64_t){offset=std::min(offset,done);return true;});
require(offset==FileChunk,"resumed at the wrong offset");
require(load(targetPath)==content,"resumed file differs");
require(!fs::exists(partial),"committed partial remains");
require(load(sourcePath)==content,"copy removed source");
save(targetPath,"original");
try
{
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
[](uint64_t done,uint64_t){return done<FileChunk;});
require(false,"cancellation expected");
}
catch(const std::system_error &e){require(e.code().value()==ERROR_CANCELLED,"unexpected cancellation error");}
require(load(targetPath)=="original" && !fs::exists(partial),"cancel touched original or retained partial");
save(partial,std::string(FileChunk,'z'));
uint64_t minimum=UINT64_MAX;
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,false,
[&](uint64_t done,uint64_t){minimum=std::min(minimum,done);return true;});
require(minimum==0 && load(targetPath)==content,"corrupt partial was reused");
save(targetPath,"original");
streamFile(*source,*target,id,sourcePath,targetPath,size,modified,true,true,{});
require(load(targetPath)=="original"+content,"append did not preserve original prefix");
checkFileReply(source->call(fileRequest("remove",sourcePath)));
require(!fs::exists(sourcePath),"move source removal failed");
}
}
int main()
{
WSADATA network{};
if(WSAStartup(MAKEWORD(2,2),&network)) return 1;
const auto root=fs::temp_directory_path()/wide("kswitcher-lan-files-"+randomHex(16));
int result=0;
try
{
verifyFilesPeerImage();
fs::create_directory(root);
const auto client=randomHex(16),first=randomHex(16),second=randomHex(16),key=randomHex(32);
auto lookup=[&](const std::string &id){return id==client?key:std::string{};};
{
auto previous=std::make_unique<FilesListener>(first,lookup,0);
const auto port=previous->port();
require(port!=0,"old listener bound before restart");
FilesListener restarting(first,lookup,port);
require(restarting.port()==0,"new listener must not take over an occupied port");
previous.reset();
const auto deadline=GetTickCount64()+4000;
while(!restarting.port() && GetTickCount64()<deadline)
{
restarting.poll();
Sleep(20);
}
require(restarting.port()==port,"listener automatically recovers after its port becomes available");
std::jthread pollingRestarted([&](std::stop_token stop)
{
while(!stop.stop_requested()){restarting.poll();Sleep(5);}
});
FilesConnection recovered(FilesPeer{first,"127.0.0.1",L"Recovered",port},client,key);
checkFileReply(recovered.call({{"action","drives"}}));
}
std::atomic<bool> allowTerminal{};
std::atomic<unsigned> hibernateRequests{};
std::atomic<unsigned> updateRequests{};
FilesListener a(first,lookup,0,[&]{return allowTerminal.load();}),
b(second,lookup,0,{},[&]{++hibernateRequests;return ERROR_SUCCESS;},
[&]{++updateRequests;return ERROR_SUCCESS;});
require(a.port() && b.port() && a.port()!=b.port(),"test listeners unavailable");
std::jthread polling([&](std::stop_token stop)
{
while(!stop.stop_requested()){a.poll();b.poll();Sleep(5);}
});
const FilesPeer one{first,"127.0.0.1",L"One",a.port()},two{second,"127.0.0.1",L"Two",b.port()};
for(const auto &direction:{std::pair<const FilesPeer*,const FilesPeer*>{nullptr,&one},
{&one,nullptr},{&one,&two}})
verifyDirection(direction.first,direction.second,client,key,root);
try
{
FilesConnection unpaired(one,randomHex(16),key);
require(false,"unpaired client authenticated");
}
catch(const std::system_error &){}
FilesConnection host(one,client,key);
{
const Json hibernate={{"action","hibernate"}};
FilesConnection target(two,client,key);
FilesConnection local;
require(local.call(hibernate).at("error")==ERROR_ACCESS_DENIED,
"local file operations cannot dispatch the LAN power action");
require(host.call(hibernate).at("error")==ERROR_ACCESS_DENIED && hibernateRequests==0,
"hibernate requires a separately enabled handler on the target");
require(target.call({{"action","hibernate"},{"command","anything"}}).at("error")
==ERROR_INVALID_PARAMETER && hibernateRequests==0,"hibernate accepts no extra parameters");
require(target.call(hibernate).at("error")==ERROR_SUCCESS && hibernateRequests==1,
"hibernate dispatches only to the addressed paired PC");
const Json update={{"action","update"}};
require(host.call(update).at("error")==ERROR_ACCESS_DENIED && updateRequests==0,
"update requires a separate handler on the target");
require(target.call({{"action","update"},{"url","https://example.invalid"}}).at("error")
==ERROR_INVALID_PARAMETER && updateRequests==0,"update cannot supply an external package");
require(target.call(update).at("error")==ERROR_SUCCESS && updateRequests==1 && hibernateRequests==1,
"update dispatches to the addressed paired PC without invoking hibernate");
}
auto deniedCommand=fileRequest("terminalRun",root);
deniedCommand["command"]="Write-Output denied";
require(host.call(deniedCommand).at("error")==ERROR_ACCESS_DENIED,"remote command allowed without consent");
allowTerminal=true;
std::string console;
const auto exit=runRemoteCommand(host,root,
L"[Console]::Out.WriteLine((Get-Location).ProviderPath); "
L"[Console]::Error.WriteLine('Тест stderr'); Write-Error 'PowerShell error'; exit 7",
[&](std::string_view part){console+=part;});
require(exit==7 && console.find(utf8(root.native()))!=console.npos &&
console.find(utf8(L"Тест stderr"))!=console.npos,"remote command output or exit code differs");
require(console.find("PowerShell error")!=console.npos && console.find("CLIXML")==console.npos,
"remote PowerShell errors were not returned as text");
console.clear();
const auto directoryExit=runRemoteCommand(host,root,
L"Write-Progress -Activity 'Hidden progress probe' -Status 'Working'; dir",
[&](std::string_view part){console+=part;});
require(directoryExit==0 && console.find("CLIXML")==console.npos &&
console.find("Hidden progress probe")==console.npos,"remote directory command included progress metadata");
std::stop_source cancel;
try
{
runRemoteCommand(host,root,L"[Console]::Out.WriteLine('Ready'); Start-Sleep -Seconds 30",
[&](std::string_view){cancel.request_stop();},cancel.get_token());
require(false,"remote command cancellation did not stop execution");
}
catch(const std::system_error &error)
{
require(error.code().value()==ERROR_CANCELLED,"remote terminal returned the wrong cancellation error");
}
allowTerminal=false;
require(host.call(deniedCommand).at("error")==ERROR_ACCESS_DENIED,"remote command allowed after revocation");
std::cout<<"Verified remote PowerShell consent, working directory, streamed output, exit code and cancellation.\n";
const auto edited=root/L"edited.txt", serverFile=root/L"server.txt", snapshot=root/L"snapshot.txt";
save(serverFile,"original remote content");
const auto initial=host.call(fileRequest("stat",serverFile));
checkFileReply(initial);
const FileRevision base{initial.at("size"),initial.at("modified")};
FilesConnection local;
streamFile(host,local,one.id,serverFile,snapshot,base.size,base.modified,false,false,{});
require(load(snapshot)=="original remote content","remote editor snapshot is incorrect");
save(edited,"editor writeback");
const auto changed=local.call(fileRequest("stat",edited));
checkFileReply(changed);
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
require(load(serverFile)=="editor writeback","remote edit did not replace the original");
save(serverFile,"an external replacement of the remote file");
try
{
streamFile(local,host,"editor",edited,serverFile,changed.at("size"),changed.at("modified"),true,false,{},&base);
require(false,"stale editor upload succeeded");
}
catch(const std::system_error &error)
{
require(error.code().value()==ERROR_FILE_INVALID,"stale editor returned the wrong error");
}
require(load(serverFile)=="an external replacement of the remote file","stale upload overwrote external edits");
const auto drives=host.call({{"action","drives"}});
checkFileReply(drives);
require(drives.at("drives").size()==static_cast<size_t>(std::popcount(GetLogicalDrives())),"drive mask differs");
const auto denied=host.call(fileRequest("stat",root/L"NUL"));
require(denied.at("error")==ERROR_INVALID_NAME,"reserved device accepted");
// A deny ACE on file data applies to the resident's Windows token.
const auto protectedFile=root/L"protected.bin";
save(protectedFile,"restricted");
const auto protectedInfo=host.call(fileRequest("stat",protectedFile));
checkFileReply(protectedInfo);
PSECURITY_DESCRIPTOR descriptor{};
require(ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:(D;;0x1;;;WD)(A;;GA;;;WD)",SDDL_REVISION_1,&descriptor,nullptr)!=FALSE,
"create test DACL");
const auto applied=SetFileSecurityW(protectedFile.c_str(),DACL_SECURITY_INFORMATION,descriptor);
LocalFree(descriptor);
require(applied!=FALSE,"apply test DACL");
auto protectedRead=fileRequest("openRead",protectedFile);
protectedRead["size"]=protectedInfo.at("size");
protectedRead["modified"]=protectedInfo.at("modified");
require(host.call(protectedRead).at("error")==ERROR_ACCESS_DENIED,"Windows DACL did not deny remote read");
checkFileReply(host.call(fileRequest("remove",protectedFile)));
const auto tree=root/L"tree",file=tree/L"original.bin",copy=tree/L"copy.bin",renamed=tree/L"renamed.bin";
checkFileReply(host.call(fileRequest("mkdir",tree)));
save(file,"same host");
auto operation=fileRequest("copy",file);
operation["target"]=utf8(copy.native());
operation["replace"]=false;
operation["append"]=false;
save(file,std::string(FileChunk*2,'s'));
const auto largeInfo=host.call(fileRequest("stat",file));
operation["size"]=largeInfo.at("size");
operation["modified"]=largeInfo.at("modified");
{
FilesConnection interrupted(one,client,key);
try
{
interrupted.call(operation,[](uint64_t done,uint64_t)
{
if(done==FileChunk) throw std::system_error(WSAECONNRESET,std::system_category());
return true;
});
require(false,"same-host interruption expected");
}
catch(const std::system_error &e){require(e.code().value()==WSAECONNRESET,"same-host interruption error");}
}
require(!fs::exists(copy),"same-host interruption published a partial file");
uint64_t firstOffset=UINT64_MAX;
for(int i=0;i<100;++i)
{
const auto reply=host.call(operation,[&](uint64_t done,uint64_t)
{
firstOffset=std::min(firstOffset,done);
return true;
});
if(reply.at("error")==ERROR_SHARING_VIOLATION){Sleep(10);continue;}
checkFileReply(reply);
break;
}
require(firstOffset==FileChunk && load(copy)==load(file),"same-host copy did not resume");
operation=fileRequest("move",copy);
operation["target"]=utf8(renamed.native());
operation["replace"]=false;
checkFileReply(host.call(operation));
require(!fs::exists(copy) && load(renamed)==load(file),"same-host rename failed");
checkFileReply(host.call(fileRequest("remove",renamed)));
checkFileReply(host.call(fileRequest("remove",file)));
checkFileReply(host.call(fileRequest("rmdir",tree)));
require(!fs::exists(tree),"same-host directory remains");
}
catch(const std::exception &e){std::cerr<<e.what()<<'\n';result=1;}
std::error_code ignored;
fs::remove_all(root,ignored);
WSACleanup();
if(!result) std::cout<<"LAN file transfer regressions passed.\n";
return result;
}
+15
View File
@@ -4,6 +4,21 @@
using namespace ks;
int main(){try{
Settings base;base.autoCorrect=false;base.toggle=L"Ctrl+Alt+F11";
if(decodeSettings(encodeSettings(base),{}).filesHotkey)
throw std::runtime_error("missing Files shortcut did not survive round-trip");
auto cleared=base;
cleared.filesHotkey=L"";
const auto clearedRoundTrip=decodeSettings(encodeSettings(cleared),{});
if(!clearedRoundTrip.filesHotkey || !clearedRoundTrip.filesHotkey->empty())
throw std::runtime_error("explicitly cleared Files shortcut was lost");
auto duplicate=base;
duplicate.filesHotkey=base.toggle;
bool duplicateRejected=false;
try {validateSettings(duplicate);} catch(const std::exception &e)
{
duplicateRejected=std::string(e.what())=="Keyboard shortcuts must be unique";
}
if(!duplicateRejected) throw std::runtime_error("duplicate Files shortcut accepted");
auto draft=base,current=base;draft.toggle=L"Ctrl+Alt+F12";current.autoCorrect=true;
auto merged=decodeSettings(mergeConfigurationSettings(encodeSettings(base),encodeSettings(draft),encodeSettings(current)),{});
if(merged.toggle!=draft.toggle||!merged.autoCorrect)throw std::runtime_error("independent changes lost");
+32
View File
@@ -7,6 +7,38 @@ function Reject([scriptblock]$Action,[string]$Name) { $rejected=$false; try { &
$root=Join-Path ([IO.Path]::GetTempPath()) ('KSwitcher-update-tests-'+[guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($root) | Out-Null
try {
$companionRoot=Join-Path $root 'companion-install'
$otherRoot=Join-Path $root 'other-install'
[IO.Directory]::CreateDirectory($companionRoot) | Out-Null
[IO.Directory]::CreateDirectory($otherRoot) | Out-Null
$companions=@()
$Journal=Join-Path $root 'update.log'
try {
foreach ($fixture in @(
@{Root=$companionRoot; Name='KSwitcher.Files.exe'},
@{Root=$companionRoot; Name='KSwitcher.Screenshot.exe'},
@{Root=$companionRoot; Name='KSwitcher.Config.exe'},
@{Root=$otherRoot; Name='KSwitcher.Files.exe'})) {
$path=Join-Path $fixture.Root $fixture.Name
Copy-Item -LiteralPath (Join-Path ([Environment]::SystemDirectory) 'ping.exe') -Destination $path
$companions+=Start-Process -FilePath $path -ArgumentList '-t','127.0.0.1' -WindowStyle Hidden -PassThru
}
$oldFiles=Join-Path $companionRoot 'KSwitcher.Files.exe'
$backup=Join-Path $companionRoot '.kswitcher-update-0123456789abcdef0123456789abcdef/backup'
[IO.Directory]::CreateDirectory($backup) | Out-Null
Move-Item -LiteralPath $oldFiles -Destination $backup
Stop-UpdateCompanions $companionRoot
Assert (@($companions[0..2] | Where-Object HasExited).Count -eq 3) 'unattended update closes all companions in its installation'
Assert (!$companions[3].HasExited) 'unattended update leaves a companion from another installation running'
Assert (@(Get-Content -LiteralPath $Journal | Where-Object { $_ -match 'LAN update: Closing KSwitcher\.' }).Count -eq 3) 'unattended update journals every companion closure'
Assert (@(Get-Content -LiteralPath $Journal | Where-Object { $_ -match 'LAN update: Closed KSwitcher\.' }).Count -eq 3) 'unattended update confirms each companion exited'
} finally {
foreach ($companion in $companions) {
if (!$companion.HasExited) { $companion.Kill(); $companion.WaitForExit() }
$companion.Dispose()
}
$Journal=$null
}
$binaryInfo = [Diagnostics.FileVersionInfo]::GetVersionInfo((Resolve-Path -LiteralPath $Executable).Path)
$binaryVersion = Get-StableVersion $binaryInfo.FileVersion
Assert ($null -ne $binaryVersion) 'executable has a stable version'
+28
View File
@@ -0,0 +1,28 @@
param([Parameter(Mandatory)][string]$Destination)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot
$deps = New-Item -ItemType Directory -Force (Join-Path $root 'build/terminal-deps')
$version = '1.0.4314.35'
if (!(Test-Path "$deps/sdk/build/native/include/WebView2.h")) {
Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/microsoft.web.webview2/$version/microsoft.web.webview2.$version.nupkg" -OutFile "$deps/sdk.zip"
Expand-Archive "$deps/sdk.zip" "$deps/sdk" -Force
}
$assets = New-Item -ItemType Directory -Force (Join-Path $Destination 'terminal')
foreach ($package in @(
@{ Name='xterm'; Spec='@xterm/[email protected]'; Archive='xterm-xterm-6.0.0.tgz'; Files=@{'lib/xterm.js'='xterm.js'; 'css/xterm.css'='xterm.css'; 'LICENSE'='xterm-LICENSE.txt'} },
@{ Name='fit'; Spec='@xterm/[email protected]'; Archive='xterm-addon-fit-0.11.0.tgz'; Files=@{'lib/addon-fit.js'='addon-fit.js'; 'LICENSE'='addon-fit-LICENSE.txt'} }
)) {
if (!(Test-Path "$deps/$($package.Archive)")) {
& npm.cmd pack $package.Spec --pack-destination $deps.FullName --silent | Out-Host
if ($LASTEXITCODE) { throw "Could not download $($package.Spec)" }
}
$unpacked = New-Item -ItemType Directory -Force "$deps/$($package.Name)"
& tar -xf "$deps/$($package.Archive)" -C $unpacked.FullName
if ($LASTEXITCODE) { throw "Could not unpack $($package.Spec)" }
foreach ($file in $package.Files.Keys) {
Copy-Item "$unpacked/package/$file" "$assets/$($package.Files[$file])" -Force
}
}
Copy-Item "$root/resources/terminal/*" $assets -Force
Copy-Item "$deps/sdk/build/native/x64/WebView2Loader.dll" $Destination -Force
"$deps/sdk/build/native/include"