AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다. 핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다. 핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다. 문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다. 검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
This commit is contained in:
270
src/AxCopilot/Handlers/TimerHandler.cs
Normal file
270
src/AxCopilot/Handlers/TimerHandler.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L19-2: 타이머·알람 핸들러. "timer" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: timer → 사용법 + 실행 중인 타이머 목록
|
||||
/// timer 30 → 30초 타이머 (Enter로 시작)
|
||||
/// timer 5m → 5분 타이머
|
||||
/// timer 1h30m → 1시간 30분 타이머
|
||||
/// timer 2h → 2시간 타이머
|
||||
/// timer 10m30s → 10분 30초 타이머
|
||||
/// timer stop → 모든 타이머 취소
|
||||
/// timer stop <id> → 특정 타이머 취소
|
||||
/// Enter → 타이머 시작 (또는 중단).
|
||||
/// </summary>
|
||||
public class TimerHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "timer";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Timer",
|
||||
"타이머·알람 — 초/분/시간 단위 백그라운드 타이머",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
// 타이머 레코드
|
||||
private record TimerEntry(int Id, string Label, TimeSpan Duration, DateTime StartAt, CancellationTokenSource Cts);
|
||||
|
||||
// 정적 타이머 레지스트리
|
||||
private static readonly List<TimerEntry> _timers = [];
|
||||
private static readonly object _lock = new();
|
||||
private static int _nextId = 1;
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
items.Add(new LauncherItem("타이머·알람",
|
||||
"timer 30 / timer 5m / timer 1h30m / timer stop",
|
||||
null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem("사용법", "timer <시간> · 예: 30(초) / 5m / 1h30m / 2h", null, null, Symbol: "\uE916"));
|
||||
AddRunningTimers(items);
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
// stop 명령
|
||||
if (sub is "stop" or "cancel" or "취소")
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_timers.Count == 0)
|
||||
{
|
||||
items.Add(new LauncherItem("실행 중인 타이머 없음", "취소할 타이머가 없습니다",
|
||||
null, null, Symbol: "\uE916"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 특정 ID 취소
|
||||
if (parts.Length >= 2 && int.TryParse(parts[1], out var stopId))
|
||||
{
|
||||
var t = _timers.FirstOrDefault(x => x.Id == stopId);
|
||||
if (t != null)
|
||||
items.Add(new LauncherItem($"타이머 #{t.Id} '{t.Label}' 취소",
|
||||
$"Enter로 취소합니다", null, ("stop", stopId.ToString()), Symbol: "\uE916"));
|
||||
else
|
||||
items.Add(new LauncherItem($"타이머 #{stopId}를 찾을 수 없습니다",
|
||||
"timer 명령으로 목록을 확인하세요", null, null, Symbol: "\uE783"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 전체 취소
|
||||
items.Add(new LauncherItem($"모든 타이머 취소 ({_timers.Count}개)",
|
||||
"Enter로 모두 취소합니다", null, ("stop_all", ""), Symbol: "\uE916"));
|
||||
foreach (var t in _timers)
|
||||
items.Add(new LauncherItem($" #{t.Id} {t.Label}",
|
||||
$"시작: {t.StartAt:HH:mm:ss} 남은: {Remaining(t)}",
|
||||
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
|
||||
}
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
// 시간 파싱 시도
|
||||
if (TryParseTime(sub, out var duration) && duration > TimeSpan.Zero)
|
||||
{
|
||||
var label = FormatDuration(duration);
|
||||
var endTime = DateTime.Now.Add(duration);
|
||||
items.Add(new LauncherItem($"⏱ {label} 타이머 시작",
|
||||
$"완료: {endTime:HH:mm:ss} · Enter로 시작",
|
||||
null, ("start", DurationToSeconds(duration).ToString()), Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem($"완료 예정", $"{endTime:HH:mm:ss}", null, null, Symbol: "\uE916"));
|
||||
items.Add(new LauncherItem($"경과 시간", label, null, null, Symbol: "\uE916"));
|
||||
|
||||
// 실행 중 타이머 표시
|
||||
AddRunningTimers(items);
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"형식 오류: '{q}'",
|
||||
"예: timer 30 / timer 5m / timer 1h30m / timer stop",
|
||||
null, null, Symbol: "\uE783"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("start", string secStr) when long.TryParse(secStr, out var sec):
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(sec);
|
||||
var label = FormatDuration(duration);
|
||||
var cts = new CancellationTokenSource();
|
||||
int id;
|
||||
lock (_lock)
|
||||
{
|
||||
id = _nextId++;
|
||||
_timers.Add(new TimerEntry(id, label, duration, DateTime.Now, cts));
|
||||
}
|
||||
NotificationService.Notify("Timer", $"⏱ #{id} {label} 타이머 시작됩니다.");
|
||||
_ = RunTimerAsync(id, label, duration, cts.Token);
|
||||
break;
|
||||
}
|
||||
|
||||
case ("stop", string idStr) when int.TryParse(idStr, out var stopId):
|
||||
{
|
||||
TimerEntry? entry = null;
|
||||
lock (_lock)
|
||||
{
|
||||
entry = _timers.FirstOrDefault(t => t.Id == stopId);
|
||||
if (entry != null) _timers.Remove(entry);
|
||||
}
|
||||
if (entry != null)
|
||||
{
|
||||
entry.Cts.Cancel();
|
||||
NotificationService.Notify("Timer", $"⏹ #{entry.Id} '{entry.Label}' 타이머가 취소되었습니다.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ("stop_all", _):
|
||||
{
|
||||
List<TimerEntry> all;
|
||||
lock (_lock)
|
||||
{
|
||||
all = [.._timers];
|
||||
_timers.Clear();
|
||||
}
|
||||
foreach (var t in all) t.Cts.Cancel();
|
||||
NotificationService.Notify("Timer", $"⏹ 타이머 {all.Count}개 모두 취소됐습니다.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── 타이머 실행 ──────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task RunTimerAsync(int id, string label, TimeSpan duration, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(duration, token);
|
||||
// 완료 — 레지스트리에서 제거
|
||||
lock (_lock) { _timers.RemoveAll(t => t.Id == id); }
|
||||
NotificationService.Notify("⏰ 타이머 완료", $"#{id} {label} 타이머가 종료되었습니다!");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 취소됨 — 이미 처리됨
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
private static bool TryParseTime(string s, out TimeSpan ts)
|
||||
{
|
||||
ts = TimeSpan.Zero;
|
||||
s = s.ToLowerInvariant().Trim();
|
||||
|
||||
// 숫자만 → 초
|
||||
if (long.TryParse(s, out var sec))
|
||||
{
|
||||
ts = TimeSpan.FromSeconds(sec);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 복합 형식: 1h30m20s
|
||||
long hours = 0, minutes = 0, seconds = 0;
|
||||
var rem = s;
|
||||
bool found = false;
|
||||
|
||||
if (TryExtractUnit(ref rem, 'h', out var h)) { hours = h; found = true; }
|
||||
if (TryExtractUnit(ref rem, 'm', out var m)) { minutes = m; found = true; }
|
||||
if (TryExtractUnit(ref rem, 's', out var sv)){ seconds = sv; found = true; }
|
||||
|
||||
if (found && rem.Length == 0)
|
||||
{
|
||||
ts = TimeSpan.FromSeconds(hours * 3600 + minutes * 60 + seconds);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryExtractUnit(ref string s, char unit, out long value)
|
||||
{
|
||||
value = 0;
|
||||
var idx = s.IndexOf(unit);
|
||||
if (idx <= 0) return false;
|
||||
var numStr = s[..idx];
|
||||
if (!long.TryParse(numStr, out value)) return false;
|
||||
s = s[(idx + 1)..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalSeconds < 60) return $"{(long)ts.TotalSeconds}초";
|
||||
if (ts.TotalMinutes < 60)
|
||||
{
|
||||
var mins = (long)ts.TotalMinutes;
|
||||
var secs = (long)(ts.TotalSeconds - mins * 60);
|
||||
return secs > 0 ? $"{mins}분 {secs}초" : $"{mins}분";
|
||||
}
|
||||
var h = (long)ts.TotalHours;
|
||||
var m = (long)(ts.TotalMinutes - h * 60);
|
||||
var s = (long)(ts.TotalSeconds - h * 3600 - m * 60);
|
||||
var result = $"{h}시간";
|
||||
if (m > 0) result += $" {m}분";
|
||||
if (s > 0) result += $" {s}초";
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long DurationToSeconds(TimeSpan ts) => (long)ts.TotalSeconds;
|
||||
|
||||
private static string Remaining(TimerEntry t)
|
||||
{
|
||||
var elapsed = DateTime.Now - t.StartAt;
|
||||
var left = t.Duration - elapsed;
|
||||
if (left <= TimeSpan.Zero) return "완료";
|
||||
return FormatDuration(left);
|
||||
}
|
||||
|
||||
private static void AddRunningTimers(List<LauncherItem> items)
|
||||
{
|
||||
List<TimerEntry> running;
|
||||
lock (_lock) { running = [.._timers]; }
|
||||
if (running.Count == 0) return;
|
||||
|
||||
items.Add(new LauncherItem($"── 실행 중인 타이머 {running.Count}개 ──", "", null, null, Symbol: "\uE916"));
|
||||
foreach (var t in running)
|
||||
{
|
||||
var left = Remaining(t);
|
||||
items.Add(new LauncherItem($"#{t.Id} {t.Label}", $"남은 시간: {left} · 시작: {t.StartAt:HH:mm:ss}",
|
||||
null, ("stop", t.Id.ToString()), Symbol: "\uE916"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user