Compare commits

...
13 changed files with 465 additions and 44 deletions

No files matched your search

+10 -1
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24)
project(KSwitcher VERSION 0.6.81 LANGUAGES CXX RC)
project(KSwitcher VERSION 0.6.82 LANGUAGES CXX RC)
if(NOT WIN32)
message(FATAL_ERROR "KSwitcher uses native Windows APIs")
endif()
@@ -117,6 +117,15 @@ 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)
+10
View File
@@ -4,6 +4,12 @@ Far Manager є зразком для оформлення файлового м
## Скорочення
- Enter і подвійний клік відкривають папку в поточній панелі, а локальний
документ — у програмі, призначеній для його типу у Windows. EXE запускається
окремим процесом. BAT і CMD виконуються в поточній сесії вбудованого
термінала, а їхній вивід з’являється під панелями. Щоб відкрити файл із
панелі LAN, спочатку скопіюй його до локальної папки.
- У командному рядку Ctrl+V і Shift+Insert вставляють текст із буфера обміну
в позицію курсора або замінюють виділення. Якщо фокус на файловій панелі,
ці клавіші переводять його до командного рядка й вставляють текст там.
@@ -66,6 +72,10 @@ Far Manager є зразком для оформлення файлового м
## Панелі
- Обидві панелі автоматично підхоплюють створення, зміну й видалення файлів
у відкритих локальних папках. Фокус залишається там, де був, а курсор
лишається на поточному елементі, якщо той ще існує.
- Ctrl+8 відкриває список усіх ярликів папок. Кожен рядок показує групу
й назву або шлях. Enter відкриває папку в активній панелі, F4 редагує
ярлик, а Delete прибирає його зі списку.
Binary file not shown.
+6
View File
@@ -1,5 +1,11 @@
# Latest Windows release
## 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.
+1 -1
View File
@@ -1 +1 @@
d471919e04961b9fb323ef83e8b5e6ed41ce4667e881b5133574d6f4915c53e7 KSwitcher-win-x64.zip
84ba82bdf179419c6cc7a612b12c6adb962a47c55aacee311861e9843be1e683 KSwitcher-win-x64.zip
+10 -10
View File
@@ -1,23 +1,25 @@
{
"schema": 1,
"version": "0.6.82",
"files": [
{
"size": 2341376,
"sha256": "ac5858551e4c487fe75954ea0e01d12ca021ada8e785b901f425dea9c5dedea1",
"sha256": "8017fa3d840d4e89871b926ceea6c53d99238ba8145a8158930098c3fd7b8485",
"path": "KSwitcher.exe"
},
{
"size": 2341888,
"sha256": "bb20f7dc5feb3e5657e94f18088aac26d818cb8e35f4e1248f67dedabf7139dd",
"sha256": "cbb6624c0382d3a9bd1aa65542f9c2475a171cd11d55372b1c3a3c21fe65d86c",
"path": "KSwitcher.Config.exe"
},
{
"size": 3319808,
"sha256": "3b71f51aee26e0a146678b7278a81f6a9dd827f9b8e650c33ede997eb813b97f",
"sha256": "bc87038c9b090cea36475d3921e6d8a7567d407a447903291f45301fd0e4394d",
"path": "KSwitcher.Screenshot.exe"
},
{
"size": 1827328,
"sha256": "f4c5d3359b601b753906d6c4ac3b1fef66ae95f341a30bb66ba5229cc3ea5645",
"size": 1829888,
"sha256": "487e51da766bab3c1e2fbcb88d374ea3a03cda6a5736f1972b861cfff68af82f",
"path": "KSwitcher.Files.exe"
},
{
@@ -41,8 +43,8 @@
"path": "terminal/index.html"
},
{
"size": 3481,
"sha256": "f677e61d7d90d3023839ee131ddabed7a5c3510b18d47cc9a3c19353913ca5cc",
"size": 3978,
"sha256": "cc6c9cbb3684dbcda1ff587492b5a2047dd62d79b16a037bbc1d9b261afa3f78",
"path": "terminal/terminal.js"
},
{
@@ -125,7 +127,5 @@
"sha256": "ba214e2aca7405e698b3ab21ceb1f41ce088edd996b3e30e4a9d58ff496f9413",
"path": "tools/StayHere-README.md"
}
],
"schema": 1,
"version": "0.6.81"
]
}
+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,81,0
PRODUCTVERSION 0,6,81,0
FILEVERSION 0,6,82,0
PRODUCTVERSION 0,6,82,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.81"
VALUE "FileVersion", "0.6.82"
END
END
BLOCK "VarFileInfo"
+14 -2
View File
@@ -24,6 +24,8 @@ 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;
@@ -64,8 +66,9 @@ terminal.onScroll(() => {
anchorOutput();
});
bridge.addEventListener('message', ({ data }) => {
if (data === 'trace:on') {
if (data === 'trace:on' || data === 'trace:output') {
traceEnabled = true;
traceOutput = data === 'trace:output';
trace(2);
return;
}
@@ -83,7 +86,16 @@ bridge.addEventListener('message', ({ data }) => {
}
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);
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;
+133 -25
View File
@@ -245,6 +245,23 @@ struct Panel
HWND path{}, list{}, heading{};
fs::path directory;
std::optional<FilesPeer> peer;
struct DirectoryWatch
{
HANDLE handle=INVALID_HANDLE_VALUE;
~DirectoryWatch() { reset(); }
void reset()
{
if(handle!=INVALID_HANDLE_VALUE) FindCloseChangeNotification(handle);
handle=INVALID_HANDLE_VALUE;
}
void start(const fs::path &path)
{
reset();
handle=FindFirstChangeNotificationW(path.c_str(),FALSE,
FILE_NOTIFY_CHANGE_FILE_NAME|FILE_NOTIFY_CHANGE_DIR_NAME|
FILE_NOTIFY_CHANGE_ATTRIBUTES|FILE_NOTIFY_CHANGE_SIZE|FILE_NOTIFY_CHANGE_LAST_WRITE);
}
} watch;
std::map<fs::path,uint64_t> timestamps;
std::vector<fs::path> rows;
std::vector<std::wstring> sizes;
@@ -258,6 +275,7 @@ Panel panels[2];
FilesTerminal terminal;
std::ofstream terminalTrace;
unsigned terminalTraceLines{};
bool traceTerminalOutput{};
void traceTerminal(const char *event,int a=0,int b=0,int c=0,int d=0,int e=0)
{
if(!terminalTrace.is_open() || terminalTraceLines>=4096) return;
@@ -269,6 +287,7 @@ COORD terminalSize{80,25};
bool terminalViewRequested{};
HWND terminalInput{},terminalOutput{},terminalPrompt{};
constexpr UINT_PTR terminalTimer=2;
constexpr UINT_PTR directoryWatchTimer=4;
bool terminalVisible{};
std::deque<int> terminalCommandPanels;
std::wstring terminalDirectory;
@@ -814,8 +833,10 @@ bool startTerminal(HWND owner)
if(message==L"ready")
{
terminalView.setFont(displayFont,fontPoints);
if(terminalTrace.is_open()) terminalView.enableTrace();
if(terminalTrace.is_open()) terminalView.enableTrace(traceTerminalOutput);
}
else if(traceTerminalOutput && terminalTrace.is_open() && message.starts_with(L"output:"))
terminalTrace<<"output "<<ks::utf8(message.substr(7))<<std::endl;
else if(terminalTrace.is_open() && message.starts_with(L"trace:"))
{
int phase{},height{},rows{},viewport{},base{},offset{};
@@ -892,6 +913,19 @@ void updateTerminalOutput()
const auto bytes=terminal.takeOutput();
if(!bytes.empty()) terminalView.send(bytes);
}
bool submitTerminalCommand(const Panel &panel,const std::wstring &value)
{
if(!startTerminal(GetParent(panel.list))) return false;
if(!terminal.write(value+L"\r"))
{
setStatus(L"Could not send PowerShell input.");
return false;
}
terminalCommandPanels.push_back(static_cast<int>(&panel-panels));
return true;
}
LRESULT CALLBACK terminalInputKeys(HWND input,UINT message,WPARAM wp,LPARAM lp,UINT_PTR,DWORD_PTR)
{
if(message==WM_CHAR && wp==VK_ESCAPE)
@@ -928,12 +962,9 @@ LRESULT CALLBACK terminalInputKeys(HWND input,UINT message,WPARAM wp,LPARAM lp,U
SendMessageW(panels[active].list,WM_KEYDOWN,VK_RETURN,lp);
return 0;
}
const auto owner=GetParent(input);
if(!startTerminal(owner)) return 0;
std::wstring value(GetWindowTextLengthW(input)+1,L'\0');
value.resize(GetWindowTextW(input,value.data(),static_cast<int>(value.size())));
if(!terminal.write(value+L"\r")) { setStatus(L"Could not send PowerShell input."); return 0; }
terminalCommandPanels.push_back(active);
if(!submitTerminalCommand(panels[active],value)) return 0;
if(value.find_first_not_of(L" \t\r\n")!=std::wstring::npos) rememberCommand(value);
terminalHistoryIndex=terminalHistory.size();
SetWindowTextW(input,L"");
@@ -1013,6 +1044,11 @@ void openOn(Panel &panel,const fs::path &directory,bool offerElevation,std::opti
{
const auto previousDirectory=(panel.peer?panel.peer->id:std::string{})==
(peer?peer->id:std::string{})?panel.directory:fs::path{};
const bool sameDirectory=previousDirectory==directory;
// Start watching before enumeration so changes during a scan trigger another refresh.
Panel::DirectoryWatch nextWatch;
if(!peer && (!sameDirectory || panel.watch.handle==INVALID_HANDLE_VALUE))
nextWatch.start(directory);
WIN32_FIND_DATAW data{};
const auto search=peer?INVALID_HANDLE_VALUE:FindFirstFileW((directory/L"*").c_str(),&data);
auto error=search==INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS;
@@ -1096,11 +1132,24 @@ void openOn(Panel &panel,const fs::path &directory,bool offerElevation,std::opti
std::sort(folders.begin(), folders.end());
std::sort(files.begin(), files.end());
std::set<fs::path> markedPaths;
if(previousDirectory==directory)
fs::path previousSelection;
LRESULT previousRow=0, previousTop=0;
if(sameDirectory)
{
previousRow=SendMessageW(panel.list,LB_GETCURSEL,0,0);
previousTop=SendMessageW(panel.list,LB_GETTOPINDEX,0,0);
if(previousRow>=0 && static_cast<size_t>(previousRow)<panel.rows.size())
previousSelection=panel.rows[previousRow];
for(size_t i=0;i<panel.rows.size();++i)
if(panel.marked[i]) markedPaths.insert(panel.rows[i]);
}
panel.directory = directory;
panel.peer=std::move(peer);
if(!sameDirectory || nextWatch.handle!=INVALID_HANDLE_VALUE)
{
panel.watch.reset();
std::swap(panel.watch.handle,nextWatch.handle);
}
panel.timestamps=std::move(timestamps);
panel.rows.clear();
panel.sizes.clear();
@@ -1131,18 +1180,25 @@ void openOn(Panel &panel,const fs::path &directory,bool offerElevation,std::opti
for (const auto &file : files) add(file, file.filename().wstring(), false);
const auto label=(panel.peer?panel.peer->name+L" · ":std::wstring{})+directory.native();
SetWindowTextW(panel.path,label.c_str());
if(&panel==&panels[active])
if(&panel==&panels[active] && !sameDirectory)
layout(GetParent(panel.list));
auto selected=panel.rows.begin();
auto selected=std::find(panel.rows.begin(),panel.rows.end(),previousSelection);
if(selected==panel.rows.end() && !panel.rows.empty())
selected=panel.rows.begin()+std::clamp<LRESULT>(previousRow,0,panel.rows.size()-1);
if(!previousDirectory.empty() && previousDirectory.parent_path()==directory)
{
const auto child=std::find(panel.rows.begin(),panel.rows.end(),previousDirectory);
if(child!=panel.rows.end()) selected=child;
}
SendMessageW(panel.list,LB_SETCURSEL,selected-panel.rows.begin(),0);
if(selected!=panel.rows.end())
{
if(sameDirectory) SendMessageW(panel.list,LB_SETTOPINDEX,previousTop,0);
SendMessageW(panel.list,LB_SETCURSEL,selected-panel.rows.begin(),0);
}
InvalidateRect(panel.list,nullptr,FALSE);
if(error!=ERROR_SUCCESS && error!=ERROR_NO_MORE_FILES && error!=ERROR_FILE_NOT_FOUND)
setStatus(L"Some entries could not be read.");
else showFileDetails(panel);
else if(&panel==&panels[active]) showFileDetails(panel);
}
void open(Panel &panel,const fs::path &directory,bool offerElevation=false)
@@ -1150,6 +1206,19 @@ void open(Panel &panel,const fs::path &directory,bool offerElevation=false)
openOn(panel,directory,offerElevation,panel.peer);
}
void refreshChangedPanels()
{
for(auto &panel:panels)
{
const auto watch=panel.watch.handle;
if(watch==INVALID_HANDLE_VALUE || WaitForSingleObject(watch,0)!=WAIT_OBJECT_0) continue;
if(!FindNextChangeNotification(watch)) panel.watch.reset();
// Pass a copy because opening the folder rebuilds the panel.
const auto directory=panel.directory;
open(panel,directory);
}
}
void selectDrive()
{
auto row=SendMessageW(picker,LB_GETCURSEL,0,0);
@@ -1497,6 +1566,47 @@ void chooseDrive(int index,bool fromMenu)
RedrawWindow(owner,nullptr,nullptr,RDW_INVALIDATE|RDW_ALLCHILDREN|RDW_UPDATENOW);
}
void openPanelFile(const Panel &panel, const fs::path &file, const wchar_t *verb, const wchar_t *failureMessage)
{
if(panel.peer)
{
setStatus(L"Copy the remote file to a local panel before opening it.");
return;
}
std::error_code error;
if (!fs::is_regular_file(file, error))
{
if(!verb) setStatus(failureMessage);
return;
}
const auto extension=file.extension().wstring();
const auto isExtension=[&](const wchar_t *expected)
{
return CompareStringOrdinal(extension.c_str(),-1,expected,-1,TRUE)==CSTR_EQUAL;
};
if(!verb && (isExtension(L".bat") || isExtension(L".cmd")))
{
std::wstring command=L"& '";
for(const auto character:file.native())
{
// PowerShell recognizes typographic apostrophes as quote delimiters too.
if(character==L'\'' || character==L'\u2018' || character==L'\u2019') command+=character;
command+=character;
}
command+=L"'";
if(submitTerminalCommand(panel,command) && terminalRows<5)
{
terminalRows=5;
layout(GetParent(panel.list));
}
return;
}
if(!verb && isExtension(L".exe")) verb=L"open";
if (reinterpret_cast<INT_PTR>(ShellExecuteW(GetParent(panel.list), verb, file.c_str(), nullptr,
panel.directory.c_str(), SW_SHOWNORMAL)) <= 32)
setStatus(failureMessage);
}
void enter(Panel &panel)
{
auto row = SendMessageW(panel.list, LB_GETCURSEL, 0, 0);
@@ -1504,6 +1614,7 @@ void enter(Panel &panel)
{
auto target = panel.rows[row];
if(panel.sizes[row]==L"Folder" || panel.sizes[row]==L"Up") open(panel,target,true);
else openPanelFile(panel, target, nullptr, L"Cannot open the selected file.");
}
}
@@ -3172,20 +3283,11 @@ LRESULT CALLBACK panelKeys(HWND window, UINT message, WPARAM wp, LPARAM lp, UINT
if (wp == VK_F3 || wp == VK_F4)
{
const auto &panel = panels[index];
if(panel.peer)
{
setStatus(L"Copy the remote file to a local panel before opening it.");
return 0;
}
auto row = SendMessageW(window, LB_GETCURSEL, 0, 0);
if (row < 0 || static_cast<size_t>(row) >= panel.rows.size()) return 0;
const auto &file = panel.rows[row];
std::error_code error;
if (!fs::is_regular_file(file, error)) return 0;
auto verb = wp == VK_F4 ? L"edit" : L"open";
if (reinterpret_cast<INT_PTR>(ShellExecuteW(GetParent(window), verb, file.c_str(), nullptr,
panel.directory.c_str(), SW_SHOWNORMAL)) <= 32)
setStatus(wp == VK_F4 ? L"No editor is registered for this file." : L"Cannot open the selected file.");
const bool edit = wp == VK_F4;
openPanelFile(panel, panel.rows[row], edit ? L"edit" : L"open",
edit ? L"No editor is registered for this file." : L"Cannot open the selected file.");
return 0;
}
if (wp == VK_F5 || wp == VK_F6)
@@ -3403,6 +3505,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
case WM_TIMER:
if(wp==driveQueryTimer) {pollDriveQueries(window);return 0;}
if(wp==terminalTimer) { updateTerminalOutput(); return 0; }
if(wp==directoryWatchTimer) { refreshChangedPanels(); return 0; }
if(wp!=transferHighlightTimer) break;
++transferHighlightPhase;
for(auto &panel:panels)
@@ -3434,13 +3537,13 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
SetWindowSubclass(picker,pickerKeys,1,0);
SendMessageW(picker,LB_SETITEMHEIGHT,0,textHeight);
status = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE | SS_PATHELLIPSIS,
0, 0, 0, 0, window, nullptr, instance, nullptr);
0, 0, 0, 0, window, reinterpret_cast<HMENU>(144), instance, nullptr);
terminalInput=CreateWindowW(L"EDIT",L"",WS_CHILD|WS_VISIBLE|ES_AUTOHSCROLL,
0,0,0,0,window,nullptr,instance,nullptr);
terminalPrompt=CreateWindowW(L"STATIC",L"",WS_CHILD|WS_VISIBLE|SS_PATHELLIPSIS,
0,0,0,0,window,nullptr,instance,nullptr);
terminalOutput=CreateWindowW(L"STATIC",L"",WS_CHILD|WS_CLIPCHILDREN,
0,0,0,0,window,nullptr,instance,nullptr);
0,0,0,0,window,reinterpret_cast<HMENU>(145),instance,nullptr);
terminalBackground=CreateSolidBrush(RGB(24,24,24));
if(!terminalInput || !terminalOutput || !terminalPrompt || !terminalBackground) return -1;
for(const auto control:{terminalInput,terminalOutput,terminalPrompt})
@@ -3449,6 +3552,7 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
SendMessageW(terminalInput,EM_SETMARGINS,EC_LEFTMARGIN|EC_RIGHTMARGIN,0);
SetWindowSubclass(terminalInput,terminalInputKeys,1,0);
SetTimer(window,terminalTimer,50,nullptr);
SetTimer(window,directoryWatchTimer,200,nullptr);
if (!mono || !blue || !teal || !menuBar || !picker || !pickerFrame || !status) return -1;
SendMessageW(status, WM_SETFONT, reinterpret_cast<WPARAM>(mono), FALSE);
const wchar_t *labels[]{L"F2 Refresh", L"F3 View", L"F4 Edit", L"F5 Copy", L"F6 Move", L"F10 Exit"};
@@ -3943,6 +4047,8 @@ LRESULT CALLBACK filesWindow(HWND window, UINT message, WPARAM wp, LPARAM lp)
return 0;
case WM_DESTROY:
KillTimer(window,terminalTimer);
KillTimer(window,directoryWatchTimer);
for(auto &panel:panels) panel.watch.reset();
terminalView.close();
terminal.close();
PostQuitMessage(0);
@@ -3974,8 +4080,10 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR, int show)
return static_cast<int>(result);
}
for(int i=1;i<count;++i)
if(std::wstring_view(args[i])==L"--terminal-trace")
if(std::wstring_view(args[i])==L"--terminal-trace" ||
std::wstring_view(args[i])==L"--terminal-output-trace")
{
traceTerminalOutput=std::wstring_view(args[i])==L"--terminal-output-trace";
wchar_t temp[MAX_PATH]{};
const auto length=GetTempPathW(MAX_PATH,temp);
if(length && length<MAX_PATH)
+4 -1
View File
@@ -56,7 +56,10 @@ class FilesTerminalView
int viewportHeight{};
public:
bool initialized() const { return ready; }
void enableTrace() { if(ready) web->PostWebMessageAsString(L"trace:on"); }
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;
+1 -1
View File
@@ -2,7 +2,7 @@
#include "platform.h"
namespace ks
{
inline constexpr wchar_t AppVersion[] = L"0.6.81";
inline constexpr wchar_t AppVersion[] = L"0.6.82";
inline constexpr UINT UpdateMessage = WM_APP + 7;
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
inline constexpr UINT UpdateNotificationRetryMs = 5 * 60 * 1000;
+254
View File
@@ -0,0 +1,254 @@
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 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 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 $List -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 $List }
}
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)
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 $left -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 $panel.List) { 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 $right) { throw 'Deletion moved focus.' }
Write-Output 'Both panels refreshed external creation, modification and deletion while preserving focus.'
} 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;
}