[Phase L5-4] 앱 세션 스냅 (session) 구현
AppSettings.Models.cs (수정):
- AppSession 클래스 신규: Name/Description/Apps/CreatedAt
- SessionApp 클래스 신규: Path/Arguments/Label/SnapPosition/DelayMs
- 스냅 위치 14종: full/left/right/tl~br/center/third-l/c/r/two3-l/r/none
AppSettings.cs (수정):
- [JsonPropertyName("appSessions")] AppSessions 리스트 추가
SessionHandler.cs (신규, ~160줄):
- prefix="session", GetItemsAsync: 목록+필터, new/edit/del 서브커맨드
- ExecuteAsync: 편집기 열기 또는 LaunchSessionAsync 실행
- LaunchSessionAsync: Process.Start → 창 핸들 대기(최대 6초 폴링) → ApplySnapToWindow
- ApplySnapToWindow: P/Invoke(SetWindowPos, ShowWindow, MonitorFromWindow, GetMonitorInfo) + 14종 스냅 좌표 계산
SessionEditorWindow.xaml (신규, ~200줄):
- 타이틀바 + 세션명·설명 TextBox + 앱 목록 영역 + 하단 버튼 바
- SnapPickerPopup: PlacementTarget 코드에서 동적 지정, ContentControl에 StackPanel 14개 옵션
SessionEditorWindow.xaml.cs (신규, ~240줄):
- AppRowUi 내부 모델: Path/Label/SnapPosition/Args/DelayMs + SnapLabelRef 갱신
- BuildRowGrid(): 경로TextBox + 라벨TextBox + 스냅Border(팝업) + 삭제Border
- BtnAddApp: OpenFileDialog → 자동 라벨(파일명)
- BtnSave: 유효 행 필터 → AppSession 구성 → 기존 교체 또는 신규 추가 → Save()
App.xaml.cs (수정):
- Phase L5-4 섹션에 SessionHandler 등록
docs/LAUNCHER_ROADMAP.md (수정):
- L5-4 항목 ✅ 완료 표시 + 구현 상세 기록
빌드: 경고 0, 오류 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
301
src/AxCopilot/Handlers/SessionHandler.cs
Normal file
301
src/AxCopilot/Handlers/SessionHandler.cs
Normal file
@@ -0,0 +1,301 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: session → 저장된 세션 목록
|
||||
/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용)
|
||||
/// session new 이름 → 새 세션 편집기 열기
|
||||
/// session edit 이름 → 기존 세션 편집기 열기
|
||||
/// session del 이름 → 세션 삭제
|
||||
///
|
||||
/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다.
|
||||
/// </summary>
|
||||
public class SessionHandler : IActionHandler
|
||||
{
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
public SessionHandler(SettingsService settings) { _settings = settings; }
|
||||
|
||||
public string? Prefix => "session";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"AppSession",
|
||||
"앱 세션 스냅 — session",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// ─── 항목 목록 ──────────────────────────────────────────────────────────
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
|
||||
var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
|
||||
|
||||
// "new [이름]" — 새 세션 만들기
|
||||
if (cmd == "new")
|
||||
{
|
||||
var name = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "새 세션";
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{name}' 세션 만들기",
|
||||
"편집기에서 앱 목록과 스냅 레이아웃을 설정합니다",
|
||||
null, $"__new__{name}", Symbol: "\uE710")
|
||||
});
|
||||
}
|
||||
|
||||
// "edit 이름" — 기존 세션 편집
|
||||
if (cmd == "edit" && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 세션 편집",
|
||||
"편집기 열기",
|
||||
null, $"__edit__{parts[1]}", Symbol: "\uE70F")
|
||||
});
|
||||
}
|
||||
|
||||
// "del 이름" or "delete 이름" — 세션 삭제
|
||||
if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(new[]
|
||||
{
|
||||
new LauncherItem($"'{parts[1]}' 세션 삭제",
|
||||
"Enter로 삭제 확인 (되돌릴 수 없습니다)",
|
||||
null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
|
||||
});
|
||||
}
|
||||
|
||||
// 세션 목록 (필터 적용)
|
||||
var sessions = _settings.Settings.AppSessions;
|
||||
var filter = q.ToLowerInvariant();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
foreach (var s in sessions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(filter) &&
|
||||
!s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
|
||||
!s.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var appNames = string.Join(", ",
|
||||
s.Apps.Take(3).Select(a =>
|
||||
string.IsNullOrEmpty(a.Label)
|
||||
? Path.GetFileNameWithoutExtension(a.Path)
|
||||
: a.Label));
|
||||
|
||||
items.Add(new LauncherItem(
|
||||
s.Name,
|
||||
$"{s.Apps.Count}개 앱 · {appNames}",
|
||||
null, s,
|
||||
Symbol: "\uE8A1")); // 창 레이아웃 아이콘
|
||||
}
|
||||
|
||||
if (items.Count == 0 && string.IsNullOrEmpty(filter))
|
||||
{
|
||||
items.Add(new LauncherItem(
|
||||
"저장된 세션 없음",
|
||||
"'session new 이름'으로 앱 세션을 만드세요",
|
||||
null, null,
|
||||
Symbol: Symbols.Info));
|
||||
}
|
||||
|
||||
// 새 세션 만들기 (항상 표시)
|
||||
items.Add(new LauncherItem(
|
||||
"새 세션 만들기",
|
||||
"session new [이름] · 앱 목록 + 스냅 레이아웃 지정",
|
||||
null, "__new__새 세션",
|
||||
Symbol: "\uE710"));
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// ─── 실행 ─────────────────────────────────────────────────────────────
|
||||
public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
if (item.Data is string s)
|
||||
{
|
||||
if (s.StartsWith("__new__"))
|
||||
{
|
||||
var name = s["__new__".Length..];
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.SessionEditorWindow(null, _settings);
|
||||
win.InitialName = name;
|
||||
win.Show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__edit__"))
|
||||
{
|
||||
var name = s["__edit__".Length..];
|
||||
var session = _settings.Settings.AppSessions
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() =>
|
||||
{
|
||||
var win = new Views.SessionEditorWindow(session, _settings);
|
||||
win.Show();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (s.StartsWith("__del__"))
|
||||
{
|
||||
var name = s["__del__".Length..];
|
||||
var session = _settings.Settings.AppSessions
|
||||
.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
if (session != null)
|
||||
{
|
||||
_settings.Settings.AppSessions.Remove(session);
|
||||
_settings.Save();
|
||||
NotificationService.Notify("AX Copilot", $"세션 '{name}' 삭제됨");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.Data is Models.AppSession appSession)
|
||||
await LaunchSessionAsync(appSession, ct);
|
||||
}
|
||||
|
||||
// ─── 세션 실행 로직 ───────────────────────────────────────────────────
|
||||
private static async Task LaunchSessionAsync(Models.AppSession session, CancellationToken ct)
|
||||
{
|
||||
NotificationService.Notify("AX Copilot", $"'{session.Name}' 세션 시작...");
|
||||
LogService.Info($"세션 실행 시작: {session.Name} ({session.Apps.Count}개 앱)");
|
||||
|
||||
int launched = 0, failed = 0;
|
||||
|
||||
foreach (var app in session.Apps)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (app.DelayMs > 0)
|
||||
await Task.Delay(app.DelayMs, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(app.Path)) continue;
|
||||
|
||||
System.Diagnostics.Process? proc = null;
|
||||
try
|
||||
{
|
||||
proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = app.Path,
|
||||
Arguments = app.Arguments ?? "",
|
||||
UseShellExecute = true
|
||||
});
|
||||
launched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"세션 앱 실행 실패: {app.Path} — {ex.Message}");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 스냅 위치 없거나 none → 그냥 실행
|
||||
if (proc == null || string.IsNullOrEmpty(app.SnapPosition) || app.SnapPosition == "none")
|
||||
continue;
|
||||
|
||||
// 창이 나타날 때까지 대기 (최대 6초)
|
||||
var hWnd = IntPtr.Zero;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(6);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try { proc.Refresh(); } catch { break; }
|
||||
hWnd = proc.MainWindowHandle;
|
||||
if (hWnd != IntPtr.Zero) break;
|
||||
await Task.Delay(200, ct);
|
||||
}
|
||||
|
||||
if (hWnd == IntPtr.Zero)
|
||||
{
|
||||
LogService.Warn($"세션: 창 핸들 획득 실패 ({app.Path})");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 창이 완전히 렌더링될 시간 허용
|
||||
await Task.Delay(250, ct);
|
||||
ApplySnapToWindow(hWnd, app.SnapPosition);
|
||||
}
|
||||
|
||||
var msg = failed > 0
|
||||
? $"'{session.Name}' 실행 완료 ({launched}개 성공, {failed}개 실패)"
|
||||
: $"'{session.Name}' 실행 완료 ({launched}개 앱)";
|
||||
NotificationService.Notify("AX Copilot", msg);
|
||||
LogService.Info($"세션 실행 완료: {msg}");
|
||||
}
|
||||
|
||||
// ─── 스냅 적용 (SnapHandler와 동일한 좌표 계산) ──────────────────────
|
||||
private static void ApplySnapToWindow(IntPtr hwnd, string snapKey)
|
||||
{
|
||||
if (snapKey == "full")
|
||||
{
|
||||
ShowWindow(hwnd, SW_MAXIMIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
var mi = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
|
||||
if (!GetMonitorInfo(hMonitor, ref mi)) return;
|
||||
|
||||
var w = mi.rcWork;
|
||||
int mw = w.right - w.left;
|
||||
int mh = w.bottom - w.top;
|
||||
int mx = w.left;
|
||||
int my = w.top;
|
||||
|
||||
var (x, y, cw, ch) = snapKey switch
|
||||
{
|
||||
"left" => (mx, my, mw / 2, mh),
|
||||
"right" => (mx + mw / 2, my, mw / 2, mh),
|
||||
"top" => (mx, my, mw, mh / 2),
|
||||
"bottom" => (mx, my + mh / 2, mw, mh / 2),
|
||||
"tl" => (mx, my, mw / 2, mh / 2),
|
||||
"tr" => (mx + mw / 2, my, mw / 2, mh / 2),
|
||||
"bl" => (mx, my + mh / 2, mw / 2, mh / 2),
|
||||
"br" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2),
|
||||
"third-l" => (mx, my, mw / 3, mh),
|
||||
"third-c" => (mx + mw / 3, my, mw / 3, mh),
|
||||
"third-r" => (mx + mw * 2 / 3,my, mw / 3, mh),
|
||||
"two3-l" => (mx, my, mw * 2 / 3, mh),
|
||||
"two3-r" => (mx + mw / 3, my, mw * 2 / 3, mh),
|
||||
"center" => (mx + mw / 10, my + mh / 10, mw * 8 / 10, mh * 8 / 10),
|
||||
_ => (mx, my, mw, mh)
|
||||
};
|
||||
|
||||
ShowWindow(hwnd, SW_RESTORE);
|
||||
SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER);
|
||||
}
|
||||
|
||||
// ─── P/Invoke ─────────────────────────────────────────────────────────
|
||||
private const uint SWP_SHOWWINDOW = 0x0040;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
|
||||
private const int SW_RESTORE = 9;
|
||||
private const int SW_MAXIMIZE = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int left, top, right, bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MONITORINFO
|
||||
{
|
||||
public int cbSize;
|
||||
public RECT rcMonitor, rcWork;
|
||||
public uint dwFlags;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
|
||||
[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 GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
|
||||
}
|
||||
Reference in New Issue
Block a user