[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:
2026-04-04 12:54:24 +09:00
parent 375ea0566d
commit 2d3e5f6a72
7 changed files with 995 additions and 1 deletions

View 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);
}