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:
2026-04-05 00:59:45 +09:00
parent 0929778ca7
commit 0336904258
115 changed files with 30749 additions and 1 deletions

View File

@@ -0,0 +1,277 @@
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L20-4: Unix 파일 권한 계산기 핸들러. "perm" 프리픽스로 사용합니다.
///
/// 예: perm → 주요 권한 목록
/// perm 755 → rwxr-xr-x 상세 설명
/// perm 644 → rw-r--r-- 상세 설명
/// perm rwxr-xr-x → 기호 → 숫자 변환
/// perm rw-r--r-- → 기호 → 숫자 변환
/// perm +x 644 → 실행 비트 추가
/// perm -x 755 → 실행 비트 제거
/// perm +w 444 → 쓰기 비트 추가
/// perm umask 022 → umask 적용 결과
/// perm common → 자주 쓰는 권한 목록
/// Enter → 값 복사.
/// </summary>
public class PermHandler : IActionHandler
{
public string? Prefix => "perm";
public PluginMetadata Metadata => new(
"Perm",
"Unix 파일 권한 계산기 — chmod 숫자↔기호·umask·권한 설명",
"1.0",
"AX");
private record CommonPerm(string Octal, string Symbol, string Description, string UseCase);
private static readonly CommonPerm[] Common =
[
new("777", "rwxrwxrwx", "모든 사용자 완전 권한", "임시 스크립트 (보안 주의)"),
new("755", "rwxr-xr-x", "소유자 완전·그룹/기타 읽기+실행", "실행 파일, 디렉토리"),
new("750", "rwxr-x---", "소유자 완전·그룹 읽기+실행·기타 없음", "그룹 공유 스크립트"),
new("700", "rwx------", "소유자만 완전 권한", "개인 스크립트"),
new("644", "rw-r--r--", "소유자 읽기+쓰기·그룹/기타 읽기만", "일반 파일"),
new("640", "rw-r-----", "소유자 읽기+쓰기·그룹 읽기·기타 없음", "설정 파일"),
new("600", "rw-------", "소유자만 읽기+쓰기", "개인 설정·SSH 키"),
new("444", "r--r--r--", "모든 사용자 읽기만", "공유 읽기 전용"),
new("400", "r--------", "소유자만 읽기", "SSL 인증서·개인 키"),
new("666", "rw-rw-rw-", "모든 사용자 읽기+쓰기 (실행 없음)", "임시 파일"),
new("664", "rw-rw-r--", "소유자+그룹 읽기+쓰기·기타 읽기", "그룹 협업 파일"),
new("660", "rw-rw----", "소유자+그룹 읽기+쓰기·기타 없음", "그룹 협업 파일"),
new("775", "rwxrwxr-x", "소유자+그룹 완전·기타 읽기+실행", "그룹 협업 디렉토리"),
new("770", "rwxrwx---", "소유자+그룹 완전·기타 없음", "그룹 전용 디렉토리"),
];
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("Unix 파일 권한 계산기",
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022 / perm common",
null, null, Symbol: "\uE8A5"));
foreach (var p in Common.Take(6))
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
$"{p.Description} ({p.UseCase})",
null, ("copy", p.Octal), Symbol: "\uE8A5"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var sub = parts[0].ToLowerInvariant();
// common
if (sub is "common" or "list" or "목록")
{
items.Add(new LauncherItem("자주 쓰는 파일 권한 목록", "", null, null, Symbol: "\uE8A5"));
foreach (var p in Common)
items.Add(new LauncherItem($"{p.Octal} {p.Symbol}",
$"{p.Description} · {p.UseCase}",
null, ("copy", p.Octal), Symbol: "\uE8A5"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// umask
if (sub == "umask" && parts.Length >= 2)
{
if (TryParseOctal(parts[1], out var umaskVal))
items.AddRange(BuildUmask(umaskVal));
else
items.Add(ErrorItem("umask 값은 3자리 8진수입니다 (예: 022)"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// +x / -x / +w / -w / +r / -r (비트 수정)
if (sub.Length == 2 && sub[0] is '+' or '-' && sub[1] is 'r' or 'w' or 'x')
{
if (parts.Length >= 2 && TryParseOctal(parts[1], out var baseVal))
items.AddRange(BuildBitModify(sub[0] == '+', sub[1], baseVal));
else
items.Add(ErrorItem($"예: perm {sub} 644"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 기호 표기 (rwxr-xr-x)
if (sub.Length == 9 && sub.All(c => c is 'r' or 'w' or 'x' or '-'))
{
items.AddRange(BuildFromSymbol(sub));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
// 8진수 표기 (755, 644...)
if (TryParseOctal(sub, out var octal))
{
items.AddRange(BuildFromOctal(octal));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
items.Add(new LauncherItem($"인식할 수 없는 입력: '{sub}'",
"perm 755 / perm rwxr-xr-x / perm +x 644 / perm umask 022",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(items);
}
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
{
if (item.Data is ("copy", string text))
{
try
{
System.Windows.Application.Current.Dispatcher.Invoke(
() => Clipboard.SetText(text));
NotificationService.Notify("Perm", "클립보드에 복사했습니다.");
}
catch { }
}
return Task.CompletedTask;
}
// ── 빌더 ────────────────────────────────────────────────────────────────
private static IEnumerable<LauncherItem> BuildFromOctal(int octal)
{
var sym = OctalToSymbol(octal);
var desc = DescribeOctal(octal);
var preset = Common.FirstOrDefault(p => p.Octal == octal.ToString("D3"));
yield return new LauncherItem($"{octal:D3} → {sym}",
preset?.Description ?? desc,
null, ("copy", octal.ToString("D3")), Symbol: "\uE8A5");
yield return CopyItem("8진수", octal.ToString("D3"));
yield return CopyItem("기호 표기", sym);
yield return CopyItem("chmod 명령", $"chmod {octal:D3} <파일>");
var parts = OctalToParts(octal);
yield return new LauncherItem("소유자 (Owner)",
$"{OctetToSymbol(parts[0])} ({BitsDesc(parts[0])})",
null, null, Symbol: "\uE8A5");
yield return new LauncherItem("그룹 (Group)",
$"{OctetToSymbol(parts[1])} ({BitsDesc(parts[1])})",
null, null, Symbol: "\uE8A5");
yield return new LauncherItem("기타 (Others)",
$"{OctetToSymbol(parts[2])} ({BitsDesc(parts[2])})",
null, null, Symbol: "\uE8A5");
if (preset != null)
yield return new LauncherItem("용도", preset.UseCase, null, null, Symbol: "\uE8A5");
// 관련 권한 제안
yield return new LauncherItem("── 관련 권한 ──", "", null, null, Symbol: "\uE8A5");
var related = Common.Where(p => Math.Abs(int.Parse(p.Octal) - octal) <= 11)
.Take(4).ToList();
foreach (var r in related)
yield return new LauncherItem($"{r.Octal} {r.Symbol}", r.Description,
null, ("copy", r.Octal), Symbol: "\uE8A5");
}
private static IEnumerable<LauncherItem> BuildFromSymbol(string sym)
{
var octal = SymbolToOctal(sym);
return BuildFromOctal(octal);
}
private static IEnumerable<LauncherItem> BuildUmask(int umask)
{
var fileDefault = 0666;
var dirDefault = 0777;
var fileResult = fileDefault & ~umask;
var dirResult = dirDefault & ~umask;
yield return new LauncherItem($"umask {umask:D3} → 파일: {fileResult:D3} 디렉토리: {dirResult:D3}",
"umask 적용 결과", null, null, Symbol: "\uE8A5");
yield return CopyItem("umask 값", umask.ToString("D3"));
yield return CopyItem("기본 파일 권한", fileResult.ToString("D3"));
yield return CopyItem("기본 디렉토리 권한", dirResult.ToString("D3"));
yield return CopyItem("파일 기호", OctalToSymbol(fileResult));
yield return CopyItem("디렉토리 기호", OctalToSymbol(dirResult));
yield return new LauncherItem("설명",
$"umask {umask:D3}: 파일={OctalToSymbol(fileResult)}, 디렉토리={OctalToSymbol(dirResult)}",
null, null, Symbol: "\uE8A5");
}
private static IEnumerable<LauncherItem> BuildBitModify(bool add, char bit, int baseOctal)
{
var mask = bit switch
{
'r' => 0444,
'w' => 0222,
'x' => 0111,
_ => 0
};
var result = add ? (baseOctal | mask) : (baseOctal & ~mask);
result &= 0777;
var label = add ? $"+{bit}" : $"-{bit}";
yield return new LauncherItem($"{baseOctal:D3} {label} → {result:D3} ({OctalToSymbol(result)})",
"Enter 복사", null, ("copy", result.ToString("D3")), Symbol: "\uE8A5");
yield return CopyItem("변경 전", baseOctal.ToString("D3"));
yield return CopyItem("변경 후", result.ToString("D3"));
yield return CopyItem("기호", OctalToSymbol(result));
yield return CopyItem("chmod", $"chmod {label} <파일> (또는 chmod {result:D3} <파일>)");
}
// ── 변환 헬퍼 ────────────────────────────────────────────────────────────
private static bool TryParseOctal(string s, out int val)
{
val = 0;
s = s.TrimStart('0');
if (string.IsNullOrEmpty(s)) { val = 0; return true; }
if (s.Length > 4) return false;
try { val = Convert.ToInt32(s, 8); return val <= 0777; }
catch { return false; }
}
private static int[] OctalToParts(int octal) =>
[(octal >> 6) & 7, (octal >> 3) & 7, octal & 7];
private static string OctetToSymbol(int v) =>
$"{((v & 4) != 0 ? 'r' : '-')}{((v & 2) != 0 ? 'w' : '-')}{((v & 1) != 0 ? 'x' : '-')}";
private static string OctalToSymbol(int octal)
{
var p = OctalToParts(octal);
return OctetToSymbol(p[0]) + OctetToSymbol(p[1]) + OctetToSymbol(p[2]);
}
private static int SymbolToOctal(string sym)
{
int TriBit(int off) =>
((sym[off] == 'r' ? 4 : 0) |
(sym[off + 1] == 'w' ? 2 : 0) |
(sym[off + 2] == 'x' ? 1 : 0));
return (TriBit(0) << 6) | (TriBit(3) << 3) | TriBit(6);
}
private static string BitsDesc(int v)
{
var parts = new List<string>();
if ((v & 4) != 0) parts.Add("읽기");
if ((v & 2) != 0) parts.Add("쓰기");
if ((v & 1) != 0) parts.Add("실행");
return parts.Count == 0 ? "없음" : string.Join("·", parts);
}
private static string DescribeOctal(int octal)
{
var p = OctalToParts(octal);
return $"소유자:{BitsDesc(p[0])} 그룹:{BitsDesc(p[1])} 기타:{BitsDesc(p[2])}";
}
private static LauncherItem CopyItem(string label, string value) =>
new(label, value, null, ("copy", value), Symbol: "\uE8A5");
private static LauncherItem ErrorItem(string msg) =>
new(msg, "올바른 입력 형식을 확인하세요", null, null, Symbol: "\uE783");
}