런처 워크스페이스 복원 매칭과 ~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 동등성 작업 추적 문서
|
||||
`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)
|
||||
- Code/Cowork 권한 팝업과 승인 재사용이 상대 경로에서 잘못 동작하던 문제를 수정했습니다. 상대 경로 `index.html` 같은 대상이 워크스페이스가 아닌 프로세스 현재 폴더(`dist`) 기준으로 해석되면서, 권한 팝업 미리보기와 사내 모드 외부 경로 판정이 잘못되고 `이번 실행 동안 허용`도 재사용되지 않던 상태였습니다.
|
||||
- [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-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)
|
||||
- 권한 경로 해석과 세션 승인 재사용을 workspace-aware 기준으로 정리했습니다. 상대 경로 `index.html` 같은 대상이 권한 팝업/사내 모드 외부 경로 판정에서 프로세스 현재 폴더(`dist`) 기준으로 잘못 절대경로화되면서, 팝업 표시가 틀어지고 `이번 실행 동안 허용`도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했습니다.
|
||||
- `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 monitorCount = GetMonitorCount();
|
||||
var usedHandles = new HashSet<IntPtr>();
|
||||
|
||||
foreach (var snapshot in profile.Windows)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// 1. 실행 중인 창 찾기
|
||||
var hWnd = FindMatchingWindow(snapshot);
|
||||
var hWnd = FindMatchingWindow(snapshot, usedHandles);
|
||||
|
||||
// 2. 창이 없으면 EXE 실행 후 대기
|
||||
if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe))
|
||||
@@ -124,7 +125,7 @@ public class ContextManager
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -140,6 +141,8 @@ public class ContextManager
|
||||
continue;
|
||||
}
|
||||
|
||||
usedHandles.Add(hWnd);
|
||||
|
||||
// 3. 모니터 불일치 처리
|
||||
if (snapshot.Monitor >= monitorCount)
|
||||
{
|
||||
@@ -207,35 +210,164 @@ public class ContextManager
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot)
|
||||
private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet<IntPtr> usedHandles)
|
||||
{
|
||||
IntPtr found = IntPtr.Zero;
|
||||
EnumWindows((hWnd, _) =>
|
||||
{
|
||||
var path = GetProcessPath(hWnd);
|
||||
if (string.Equals(path, snapshot.Exe, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
found = hWnd;
|
||||
return false; // 첫 번째 매칭 창에서 중단
|
||||
}
|
||||
return true;
|
||||
}, IntPtr.Zero);
|
||||
return found;
|
||||
var candidates = GetOpenWindowCandidates();
|
||||
return SelectBestMatchingWindow(snapshot, candidates, usedHandles);
|
||||
}
|
||||
|
||||
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;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var hWnd = FindMatchingWindow(new WindowSnapshot { Exe = exePath });
|
||||
var hWnd = FindMatchingWindow(snapshot, usedHandles);
|
||||
if (hWnd != IntPtr.Zero) return hWnd;
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
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)
|
||||
{
|
||||
var sb = new StringBuilder(256);
|
||||
@@ -296,6 +428,20 @@ public class ContextManager
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
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)]
|
||||
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 IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||
[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);
|
||||
|
||||
@@ -30,22 +30,14 @@ public class WorkspaceHandler : IActionHandler
|
||||
// 서브 커맨드 분기
|
||||
if (parts.Length == 0 || string.IsNullOrEmpty(query))
|
||||
{
|
||||
// 프로필 목록 표시
|
||||
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);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(BuildProfileListItems());
|
||||
}
|
||||
|
||||
var cmd = parts[0].ToLowerInvariant();
|
||||
|
||||
if (cmd == "list")
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(BuildProfileListItems());
|
||||
|
||||
if (cmd == "save")
|
||||
{
|
||||
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)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
@@ -87,6 +88,31 @@ public class WorkspaceHandler : IActionHandler
|
||||
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)
|
||||
{
|
||||
if (item.Data is not WorkspaceAction action) return;
|
||||
|
||||
Reference in New Issue
Block a user