런처 워크스페이스 복원 매칭과 ~restore 명령을 보강
변경 목적: - 런처 ~ 예약어의 창 복원 품질을 점검하고, 같은 exe의 첫 창만 반복 재사용되어 브라우저/다중 창 배치가 꼬이던 문제를 줄입니다. - 도움말에만 있던 ~restore, ~list 명령을 실제 핸들러 동작과 맞춥니다. 핵심 수정사항: - ContextManager가 열린 창 후보를 exe + 제목 유사도 기준으로 매칭하고, 이미 다른 스냅샷에 배정된 핸들은 재사용하지 않도록 변경했습니다. - WorkspaceHandler에 ~restore <이름>, ~list 지원과 최근 저장 순 프로필 목록 복원 액션 정리를 추가했습니다. - WorkspaceHandlerTests, ContextManagerTests를 추가해 명령 파싱과 창 매칭 우선순위를 회귀 검증합니다. - README.md, docs/DEVELOPMENT.md에 검토 결과와 검증 이력을 기록했습니다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter WorkspaceHandlerTests^|ContextManagerTests -p:OutputPath=bin\\verify_workspace_restore_review_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review_tests\\ - 경고 0 / 오류 0, 테스트 6건 통과
This commit is contained in:
@@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저
|
|||||||
개발 참고: Claw Code 동등성 작업 추적 문서
|
개발 참고: Claw Code 동등성 작업 추적 문서
|
||||||
`docs/claw-code-parity-plan.md`
|
`docs/claw-code-parity-plan.md`
|
||||||
|
|
||||||
|
- 업데이트: 2026-04-15 16:49 (KST)
|
||||||
|
- 런처 `~` 워크스페이스 복원 경로를 검토하고, 여러 브라우저/앱 창이 있을 때 같은 `exe`의 첫 창 하나만 반복 재사용하던 매칭 문제를 수정했습니다. 기존 [ContextManager.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/ContextManager.cs)는 저장된 스냅샷마다 `exe`만 보고 첫 HWND를 잡아 여러 Chrome/Edge 창 배치가 쉽게 꼬일 수 있었습니다.
|
||||||
|
- 이제 [ContextManager.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/ContextManager.cs)는 열린 창 후보를 수집한 뒤 `exe + 제목 유사도 + 이미 배정된 창 제외` 기준으로 복원 대상을 고릅니다. 이미 다른 스냅샷에 배정된 핸들은 다시 쓰지 않아, 같은 프로세스의 다중 창이 한 창으로 덮여 이동되던 문제를 줄였습니다.
|
||||||
|
- [WorkspaceHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/WorkspaceHandler.cs)는 도움말과 실제 동작이 어긋나던 `~restore <이름>`, `~list`를 실제로 지원하도록 정리했고, 프로필 목록도 최근 저장 순으로 복원 액션을 직접 반환하도록 맞췄습니다.
|
||||||
|
- 테스트: [WorkspaceHandlerTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs), [ContextManagerTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Core/ContextManagerTests.cs) 추가
|
||||||
|
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\` 경고 0 / 오류 0
|
||||||
|
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests" -p:OutputPath=bin\\verify_workspace_restore_review_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review_tests\\` 통과 6
|
||||||
|
|
||||||
- 업데이트: 2026-04-15 16:12 (KST)
|
- 업데이트: 2026-04-15 16:12 (KST)
|
||||||
- Code/Cowork 권한 팝업과 승인 재사용이 상대 경로에서 잘못 동작하던 문제를 수정했습니다. 상대 경로 `index.html` 같은 대상이 워크스페이스가 아닌 프로세스 현재 폴더(`dist`) 기준으로 해석되면서, 권한 팝업 미리보기와 사내 모드 외부 경로 판정이 잘못되고 `이번 실행 동안 허용`도 재사용되지 않던 상태였습니다.
|
- Code/Cowork 권한 팝업과 승인 재사용이 상대 경로에서 잘못 동작하던 문제를 수정했습니다. 상대 경로 `index.html` 같은 대상이 워크스페이스가 아닌 프로세스 현재 폴더(`dist`) 기준으로 해석되면서, 권한 팝업 미리보기와 사내 모드 외부 경로 판정이 잘못되고 `이번 실행 동안 허용`도 재사용되지 않던 상태였습니다.
|
||||||
- [IAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/IAgentTool.cs)는 새 workspace-aware 경로 해석을 사용해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 항상 현재 워크스페이스 기준으로 판정하도록 바꿨습니다. 그 결과 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 접근으로 오판하지 않습니다.
|
- [IAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/IAgentTool.cs)는 새 workspace-aware 경로 해석을 사용해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 항상 현재 워크스페이스 기준으로 판정하도록 바꿨습니다. 그 결과 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 접근으로 오판하지 않습니다.
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
업데이트: 2026-04-14 19:50 (KST)
|
업데이트: 2026-04-14 19:50 (KST)
|
||||||
업데이트: 2026-04-15 12:51 (KST)
|
업데이트: 2026-04-15 12:51 (KST)
|
||||||
|
- 업데이트: 2026-04-15 16:49 (KST)
|
||||||
|
- 런처 `~` 워크스페이스 복원 경로를 재검토하고, 같은 `exe`의 첫 번째 창 하나를 모든 스냅샷에 재사용하던 복원 매칭 결함을 수정했습니다. 기존 `src/AxCopilot/Core/ContextManager.cs`는 각 스냅샷마다 `exe`만 비교해 HWND를 찾았기 때문에, Chrome/Edge처럼 여러 창이 떠 있을 때 한 창만 반복 이동되며 배치가 무너지기 쉬웠습니다.
|
||||||
|
- `src/AxCopilot/Core/ContextManager.cs`는 열린 창 후보를 수집한 뒤 `exe + 제목 매칭 점수 + 이미 사용한 핸들 제외` 기준으로 창을 배정하도록 바꿨습니다. exact title을 최우선으로 두고, 브라우저 suffix 제거/토큰 비교를 통해 비슷한 제목도 보조적으로 매칭합니다. 이미 다른 스냅샷에 배정된 핸들은 다시 쓰지 않아 multi-window 복원 품질을 높였습니다.
|
||||||
|
- `src/AxCopilot/Handlers/WorkspaceHandler.cs`는 문서에만 있었던 `~restore <이름>`과 `~list`를 실제 핸들러에서도 지원하도록 보완했습니다. 프로필 목록은 최근 생성 순으로 정렬하고 각 항목이 곧바로 restore action을 가지도록 정리했습니다.
|
||||||
|
- 테스트: `src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs`, `src/AxCopilot.Tests/Core/ContextManagerTests.cs`
|
||||||
|
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\` 경고 0 / 오류 0
|
||||||
|
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests" -p:OutputPath=bin\\verify_workspace_restore_review_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review_tests\\` 통과 6
|
||||||
- 업데이트: 2026-04-15 16:12 (KST)
|
- 업데이트: 2026-04-15 16:12 (KST)
|
||||||
- 권한 경로 해석과 세션 승인 재사용을 workspace-aware 기준으로 정리했습니다. 상대 경로 `index.html` 같은 대상이 권한 팝업/사내 모드 외부 경로 판정에서 프로세스 현재 폴더(`dist`) 기준으로 잘못 절대경로화되면서, 팝업 표시가 틀어지고 `이번 실행 동안 허용`도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했습니다.
|
- 권한 경로 해석과 세션 승인 재사용을 workspace-aware 기준으로 정리했습니다. 상대 경로 `index.html` 같은 대상이 권한 팝업/사내 모드 외부 경로 판정에서 프로세스 현재 폴더(`dist`) 기준으로 잘못 절대경로화되면서, 팝업 표시가 틀어지고 `이번 실행 동안 허용`도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했습니다.
|
||||||
- `src/AxCopilot/Services/Agent/IAgentTool.cs`는 `ResolvePathForWorkspaceCheck(...)`를 추가해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 현재 `WorkFolder` 기준으로 절대경로화한 뒤 판정하도록 변경했습니다. 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 경로로 오판하지 않습니다.
|
- `src/AxCopilot/Services/Agent/IAgentTool.cs`는 `ResolvePathForWorkspaceCheck(...)`를 추가해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 현재 `WorkFolder` 기준으로 절대경로화한 뒤 판정하도록 변경했습니다. 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 경로로 오판하지 않습니다.
|
||||||
|
|||||||
65
src/AxCopilot.Tests/Core/ContextManagerTests.cs
Normal file
65
src/AxCopilot.Tests/Core/ContextManagerTests.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using AxCopilot.Core;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxCopilot.Tests.Core;
|
||||||
|
|
||||||
|
public class ContextManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeWindowTitle_StripsKnownBrowserSuffix()
|
||||||
|
{
|
||||||
|
var normalized = ContextManager.NormalizeWindowTitle("Inbox - Google Chrome");
|
||||||
|
|
||||||
|
normalized.Should().Be("inbox");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CalculateTitleMatchScore_ExactTitle_BeatsPartialTitle()
|
||||||
|
{
|
||||||
|
var exact = ContextManager.CalculateTitleMatchScore("Inbox - Google Chrome", "Inbox - Google Chrome");
|
||||||
|
var partial = ContextManager.CalculateTitleMatchScore("Inbox - Google Chrome", "Inbox - Chrome");
|
||||||
|
|
||||||
|
exact.Should().BeGreaterThan(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectBestMatchingWindow_PrefersExactTitleAmongSameExe()
|
||||||
|
{
|
||||||
|
var snapshot = new WindowSnapshot
|
||||||
|
{
|
||||||
|
Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
Title = "Inbox - Google Chrome"
|
||||||
|
};
|
||||||
|
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
new ContextManager.WindowCandidate(new IntPtr(1), snapshot.Exe, "Docs - Google Chrome"),
|
||||||
|
new ContextManager.WindowCandidate(new IntPtr(2), snapshot.Exe, "Inbox - Google Chrome")
|
||||||
|
};
|
||||||
|
|
||||||
|
var selected = ContextManager.SelectBestMatchingWindow(snapshot, candidates);
|
||||||
|
|
||||||
|
selected.Should().Be(new IntPtr(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectBestMatchingWindow_SkipsAlreadyUsedHandle()
|
||||||
|
{
|
||||||
|
var snapshot = new WindowSnapshot
|
||||||
|
{
|
||||||
|
Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
Title = "Inbox - Google Chrome"
|
||||||
|
};
|
||||||
|
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
new ContextManager.WindowCandidate(new IntPtr(1), snapshot.Exe, "Inbox - Google Chrome")
|
||||||
|
};
|
||||||
|
|
||||||
|
var selected = ContextManager.SelectBestMatchingWindow(snapshot, candidates, new HashSet<IntPtr> { new(1) });
|
||||||
|
|
||||||
|
selected.Should().Be(IntPtr.Zero);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs
Normal file
64
src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using AxCopilot.Core;
|
||||||
|
using AxCopilot.Handlers;
|
||||||
|
using AxCopilot.Models;
|
||||||
|
using AxCopilot.Services;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AxCopilot.Tests.Handlers;
|
||||||
|
|
||||||
|
public class WorkspaceHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetItemsAsync_RestoreSubcommand_ReturnsRestoreAction()
|
||||||
|
{
|
||||||
|
var settings = new SettingsService();
|
||||||
|
settings.Settings.Profiles.Add(new WorkspaceProfile
|
||||||
|
{
|
||||||
|
Name = "업무",
|
||||||
|
Windows = new List<WindowSnapshot> { new() { Exe = "chrome.exe", Title = "메일" } },
|
||||||
|
CreatedAt = new DateTime(2026, 4, 15, 9, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
var handler = new WorkspaceHandler(new ContextManager(settings), settings);
|
||||||
|
|
||||||
|
var items = (await handler.GetItemsAsync("restore 업무", CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
items.Should().ContainSingle();
|
||||||
|
items[0].Title.Should().Contain("업무");
|
||||||
|
items[0].Data.Should().BeOfType<WorkspaceAction>();
|
||||||
|
|
||||||
|
var action = (WorkspaceAction)items[0].Data!;
|
||||||
|
action.Type.Should().Be(WorkspaceActionType.Restore);
|
||||||
|
action.Name.Should().Be("업무");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetItemsAsync_ListSubcommand_ReturnsSavedProfiles()
|
||||||
|
{
|
||||||
|
var settings = new SettingsService();
|
||||||
|
settings.Settings.Profiles.Add(new WorkspaceProfile
|
||||||
|
{
|
||||||
|
Name = "업무",
|
||||||
|
Windows = new List<WindowSnapshot> { new() { Exe = "chrome.exe", Title = "메일" } },
|
||||||
|
CreatedAt = new DateTime(2026, 4, 15, 9, 0, 0)
|
||||||
|
});
|
||||||
|
settings.Settings.Profiles.Add(new WorkspaceProfile
|
||||||
|
{
|
||||||
|
Name = "개발",
|
||||||
|
Windows = new List<WindowSnapshot> { new() { Exe = "code.exe", Title = "AX Copilot" } },
|
||||||
|
CreatedAt = new DateTime(2026, 4, 15, 10, 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
var handler = new WorkspaceHandler(new ContextManager(settings), settings);
|
||||||
|
|
||||||
|
var items = (await handler.GetItemsAsync("list", CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
items.Should().HaveCount(2);
|
||||||
|
items[0].Title.Should().Be("~개발");
|
||||||
|
items[1].Title.Should().Be("~업무");
|
||||||
|
items.All(item => item.Data is WorkspaceAction action && action.Type == WorkspaceActionType.Restore)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,13 +110,14 @@ public class ContextManager
|
|||||||
|
|
||||||
var results = new List<string>();
|
var results = new List<string>();
|
||||||
var monitorCount = GetMonitorCount();
|
var monitorCount = GetMonitorCount();
|
||||||
|
var usedHandles = new HashSet<IntPtr>();
|
||||||
|
|
||||||
foreach (var snapshot in profile.Windows)
|
foreach (var snapshot in profile.Windows)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
// 1. 실행 중인 창 찾기
|
// 1. 실행 중인 창 찾기
|
||||||
var hWnd = FindMatchingWindow(snapshot);
|
var hWnd = FindMatchingWindow(snapshot, usedHandles);
|
||||||
|
|
||||||
// 2. 창이 없으면 EXE 실행 후 대기
|
// 2. 창이 없으면 EXE 실행 후 대기
|
||||||
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe))
|
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe))
|
||||||
@@ -124,7 +125,7 @@ public class ContextManager
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true });
|
||||||
hWnd = await WaitForWindowAsync(snapshot.Exe, TimeSpan.FromSeconds(3), ct);
|
hWnd = await WaitForWindowAsync(snapshot, usedHandles, TimeSpan.FromSeconds(3), ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -140,6 +141,8 @@ public class ContextManager
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usedHandles.Add(hWnd);
|
||||||
|
|
||||||
// 3. 모니터 불일치 처리
|
// 3. 모니터 불일치 처리
|
||||||
if (snapshot.Monitor >= monitorCount)
|
if (snapshot.Monitor >= monitorCount)
|
||||||
{
|
{
|
||||||
@@ -207,35 +210,164 @@ public class ContextManager
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot)
|
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet<IntPtr> usedHandles)
|
||||||
{
|
{
|
||||||
IntPtr found = IntPtr.Zero;
|
var candidates = GetOpenWindowCandidates();
|
||||||
EnumWindows((hWnd, _) =>
|
return SelectBestMatchingWindow(snapshot, candidates, usedHandles);
|
||||||
{
|
|
||||||
var path = GetProcessPath(hWnd);
|
|
||||||
if (string.Equals(path, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
found = hWnd;
|
|
||||||
return false; // 첫 번째 매칭 창에서 중단
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, IntPtr.Zero);
|
|
||||||
return found;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IntPtr> WaitForWindowAsync(string exePath, TimeSpan timeout, CancellationToken ct)
|
private static async Task<IntPtr> WaitForWindowAsync(
|
||||||
|
WindowSnapshot snapshot,
|
||||||
|
ISet<IntPtr> usedHandles,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var deadline = DateTime.UtcNow + timeout;
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
while (DateTime.UtcNow < deadline)
|
while (DateTime.UtcNow < deadline)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
var hWnd = FindMatchingWindow(new WindowSnapshot { Exe = exePath });
|
var hWnd = FindMatchingWindow(snapshot, usedHandles);
|
||||||
if (hWnd != IntPtr.Zero) return hWnd;
|
if (hWnd != IntPtr.Zero) return hWnd;
|
||||||
await Task.Delay(200, ct);
|
await Task.Delay(200, ct);
|
||||||
}
|
}
|
||||||
return IntPtr.Zero;
|
return IntPtr.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<WindowCandidate> GetOpenWindowCandidates()
|
||||||
|
{
|
||||||
|
var candidates = new List<WindowCandidate>();
|
||||||
|
|
||||||
|
EnumWindows((hWnd, _) =>
|
||||||
|
{
|
||||||
|
if (!IsWindowVisible(hWnd)) return true;
|
||||||
|
if (IsSystemWindow(hWnd)) return true;
|
||||||
|
|
||||||
|
var title = GetWindowTitle(hWnd);
|
||||||
|
if (string.IsNullOrWhiteSpace(title)) return true;
|
||||||
|
|
||||||
|
var exePath = GetProcessPath(hWnd);
|
||||||
|
if (string.IsNullOrWhiteSpace(exePath)) return true;
|
||||||
|
|
||||||
|
candidates.Add(new WindowCandidate(hWnd, exePath, title));
|
||||||
|
return true;
|
||||||
|
}, IntPtr.Zero);
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static IntPtr SelectBestMatchingWindow(
|
||||||
|
WindowSnapshot snapshot,
|
||||||
|
IReadOnlyCollection<WindowCandidate> candidates,
|
||||||
|
ISet<IntPtr>? usedHandles = null)
|
||||||
|
{
|
||||||
|
var matches = candidates
|
||||||
|
.Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(candidate => usedHandles == null || !usedHandles.Contains(candidate.Handle))
|
||||||
|
.Select(candidate => new
|
||||||
|
{
|
||||||
|
candidate.Handle,
|
||||||
|
Score = CalculateWindowMatchScore(snapshot, candidate)
|
||||||
|
})
|
||||||
|
.OrderByDescending(candidate => candidate.Score)
|
||||||
|
.ThenBy(candidate => candidate.Handle)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (matches.Count == 0)
|
||||||
|
return IntPtr.Zero;
|
||||||
|
|
||||||
|
return matches[0].Handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static int CalculateWindowMatchScore(WindowSnapshot snapshot, WindowCandidate candidate)
|
||||||
|
{
|
||||||
|
var score = 0;
|
||||||
|
score += CalculateTitleMatchScore(snapshot.Title, candidate.Title);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(snapshot.Title))
|
||||||
|
score += 100;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static int CalculateTitleMatchScore(string expectedTitle, string actualTitle)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(expectedTitle) || string.IsNullOrWhiteSpace(actualTitle))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (string.Equals(expectedTitle, actualTitle, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return 2200;
|
||||||
|
|
||||||
|
var normalizedExpected = NormalizeWindowTitle(expectedTitle);
|
||||||
|
var normalizedActual = NormalizeWindowTitle(actualTitle);
|
||||||
|
|
||||||
|
if (normalizedExpected.Length == 0 || normalizedActual.Length == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
|
||||||
|
if (string.Equals(normalizedExpected, normalizedActual, StringComparison.OrdinalIgnoreCase))
|
||||||
|
score += 900;
|
||||||
|
|
||||||
|
if (normalizedActual.Contains(normalizedExpected, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalizedExpected.Contains(normalizedActual, StringComparison.OrdinalIgnoreCase))
|
||||||
|
score += 400;
|
||||||
|
|
||||||
|
var expectedTokens = TokenizeWindowTitle(normalizedExpected);
|
||||||
|
var actualTokens = TokenizeWindowTitle(normalizedActual);
|
||||||
|
var overlap = expectedTokens.Intersect(actualTokens, StringComparer.OrdinalIgnoreCase).Count();
|
||||||
|
|
||||||
|
if (overlap > 0)
|
||||||
|
score += overlap * 120;
|
||||||
|
|
||||||
|
if (expectedTokens.Count > 0 && overlap == expectedTokens.Count)
|
||||||
|
score += 180;
|
||||||
|
|
||||||
|
score += Math.Min(GetSharedPrefixLength(normalizedExpected, normalizedActual), 60);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string NormalizeWindowTitle(string title)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var normalized = title.Trim()
|
||||||
|
.Replace('—', '-')
|
||||||
|
.Replace('–', '-');
|
||||||
|
|
||||||
|
foreach (var suffix in KnownWindowTitleSuffixes)
|
||||||
|
{
|
||||||
|
if (normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
normalized = normalized[..^suffix.Length].Trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join(' ', normalized
|
||||||
|
.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static List<string> TokenizeWindowTitle(string title)
|
||||||
|
{
|
||||||
|
return title
|
||||||
|
.Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(token => token.Length > 1)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetSharedPrefixLength(string left, string right)
|
||||||
|
{
|
||||||
|
var max = Math.Min(left.Length, right.Length);
|
||||||
|
var length = 0;
|
||||||
|
while (length < max && left[length] == right[length])
|
||||||
|
length++;
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetWindowTitle(IntPtr hWnd)
|
private static string GetWindowTitle(IntPtr hWnd)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder(256);
|
var sb = new StringBuilder(256);
|
||||||
@@ -296,6 +428,20 @@ public class ContextManager
|
|||||||
private const uint SWP_NOZORDER = 0x0004;
|
private const uint SWP_NOZORDER = 0x0004;
|
||||||
private const uint SWP_NOACTIVATE = 0x0010;
|
private const uint SWP_NOACTIVATE = 0x0010;
|
||||||
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
||||||
|
private static readonly char[] TokenSeparators = [' ', '-', '|', ':', '·', '_', '/', '\\', '.', ',', '(', ')', '[', ']'];
|
||||||
|
private static readonly string[] KnownWindowTitleSuffixes =
|
||||||
|
[
|
||||||
|
"- google chrome",
|
||||||
|
"- chrome",
|
||||||
|
"- microsoft edge",
|
||||||
|
"- edge",
|
||||||
|
"- mozilla firefox",
|
||||||
|
"- firefox",
|
||||||
|
"- whale",
|
||||||
|
"- microsoft visual studio",
|
||||||
|
"- visual studio code",
|
||||||
|
"- vscode"
|
||||||
|
];
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||||
@@ -327,6 +473,8 @@ public class ContextManager
|
|||||||
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||||
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
[DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||||
[DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
[DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||||
|
|
||||||
|
internal readonly record struct WindowCandidate(IntPtr Handle, string Exe, string Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RestoreResult(bool Success, string Message);
|
public record RestoreResult(bool Success, string Message);
|
||||||
|
|||||||
@@ -30,22 +30,14 @@ public class WorkspaceHandler : IActionHandler
|
|||||||
// 서브 커맨드 분기
|
// 서브 커맨드 분기
|
||||||
if (parts.Length == 0 || string.IsNullOrEmpty(query))
|
if (parts.Length == 0 || string.IsNullOrEmpty(query))
|
||||||
{
|
{
|
||||||
// 프로필 목록 표시
|
return Task.FromResult<IEnumerable<LauncherItem>>(BuildProfileListItems());
|
||||||
var items = _settings.Settings.Profiles
|
|
||||||
.Select(p => new LauncherItem(
|
|
||||||
$"~{p.Name}",
|
|
||||||
$"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}",
|
|
||||||
null, p, Symbol: Symbols.Workspace))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (items.Count == 0)
|
|
||||||
items.Add(new LauncherItem("저장된 프로필 없음", "~save <이름> 으로 현재 배치를 저장하세요", null, null, Symbol: Symbols.Info));
|
|
||||||
|
|
||||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd = parts[0].ToLowerInvariant();
|
var cmd = parts[0].ToLowerInvariant();
|
||||||
|
|
||||||
|
if (cmd == "list")
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(BuildProfileListItems());
|
||||||
|
|
||||||
if (cmd == "save")
|
if (cmd == "save")
|
||||||
{
|
{
|
||||||
var name = parts.Length > 1 ? parts[1] : "default";
|
var name = parts.Length > 1 ? parts[1] : "default";
|
||||||
@@ -56,6 +48,15 @@ public class WorkspaceHandler : IActionHandler
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cmd == "restore" && parts.Length > 1)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||||
|
{
|
||||||
|
new LauncherItem($"프로필 '{parts[1]}' 복원", "Enter로 확인", null,
|
||||||
|
new WorkspaceAction(WorkspaceActionType.Restore, parts[1]), Symbol: Symbols.Restore)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd == "delete" && parts.Length > 1)
|
if (cmd == "delete" && parts.Length > 1)
|
||||||
{
|
{
|
||||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||||
@@ -87,6 +88,31 @@ public class WorkspaceHandler : IActionHandler
|
|||||||
return Task.FromResult<IEnumerable<LauncherItem>>(matched);
|
return Task.FromResult<IEnumerable<LauncherItem>>(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<LauncherItem> BuildProfileListItems()
|
||||||
|
{
|
||||||
|
var items = _settings.Settings.Profiles
|
||||||
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
|
.Select(p => new LauncherItem(
|
||||||
|
$"~{p.Name}",
|
||||||
|
$"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}",
|
||||||
|
null,
|
||||||
|
new WorkspaceAction(WorkspaceActionType.Restore, p.Name),
|
||||||
|
Symbol: Symbols.Workspace))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
items.Add(new LauncherItem(
|
||||||
|
"저장된 프로필 없음",
|
||||||
|
"~save <이름> 으로 현재 배치를 저장하세요",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
Symbol: Symbols.Info));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (item.Data is not WorkspaceAction action) return;
|
if (item.Data is not WorkspaceAction action) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user