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:
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
375
src/AxCopilot/Handlers/DockerHandler.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using AxCopilot.SDK;
|
||||
using AxCopilot.Services;
|
||||
using AxCopilot.Themes;
|
||||
|
||||
namespace AxCopilot.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// L16-2: Docker 컨테이너·이미지 조회 핸들러. "docker" 프리픽스로 사용합니다.
|
||||
///
|
||||
/// 예: docker → 실행 중 컨테이너 목록
|
||||
/// docker all → 모든 컨테이너 (중지 포함)
|
||||
/// docker images → 로컬 이미지 목록
|
||||
/// docker ps → 컨테이너 목록 (docker ps 동일)
|
||||
/// docker stop <name> → 컨테이너 중지
|
||||
/// docker start <name> → 컨테이너 시작
|
||||
/// docker logs <name> → 컨테이너 로그 (터미널)
|
||||
/// docker shell <name> → 컨테이너 shell 접속
|
||||
/// Enter → 명령 실행 또는 컨테이너 ID 복사.
|
||||
/// </summary>
|
||||
public class DockerHandler : IActionHandler
|
||||
{
|
||||
public string? Prefix => "docker";
|
||||
|
||||
public PluginMetadata Metadata => new(
|
||||
"Docker",
|
||||
"Docker 컨테이너·이미지 조회 — 시작·중지·로그·쉘",
|
||||
"1.0",
|
||||
"AX");
|
||||
|
||||
public Task<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
||||
{
|
||||
var q = query.Trim();
|
||||
var items = new List<LauncherItem>();
|
||||
|
||||
if (!IsDockerAvailable())
|
||||
{
|
||||
items.Add(new LauncherItem("Docker를 찾을 수 없습니다",
|
||||
"Docker Desktop이 설치되어 있는지 확인하세요", null, null, Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("Docker Desktop 설치",
|
||||
"https://www.docker.com/products/docker-desktop",
|
||||
null, ("open_url", "https://www.docker.com/products/docker-desktop"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var containers = GetContainers(running: true);
|
||||
items.Add(new LauncherItem(
|
||||
$"실행 중 컨테이너 {containers.Count}개",
|
||||
"docker ps / docker all / docker images",
|
||||
null, null, Symbol: "\uE756"));
|
||||
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("실행 중인 컨테이너 없음", "docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
else
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
|
||||
items.Add(new LauncherItem("docker images", "로컬 이미지 목록", null, ("sub", "images"), Symbol: "\uE756"));
|
||||
items.Add(new LauncherItem("docker all", "모든 컨테이너 목록", null, ("sub", "all"), Symbol: "\uE756"));
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var sub = parts[0].ToLowerInvariant();
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "all":
|
||||
case "ps":
|
||||
{
|
||||
var all = sub == "all";
|
||||
var containers = GetContainers(running: !all);
|
||||
items.Add(new LauncherItem(
|
||||
$"{(all ? "전체" : "실행 중")} 컨테이너 {containers.Count}개",
|
||||
"", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in containers)
|
||||
items.Add(MakeContainerItem(c));
|
||||
if (containers.Count == 0)
|
||||
items.Add(new LauncherItem("컨테이너 없음", "", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "images":
|
||||
case "image":
|
||||
case "img":
|
||||
{
|
||||
var images = GetImages();
|
||||
items.Add(new LauncherItem($"로컬 이미지 {images.Count}개", "", null, null, Symbol: "\uE756"));
|
||||
foreach (var img in images)
|
||||
items.Add(MakeImageItem(img));
|
||||
if (images.Count == 0)
|
||||
items.Add(new LauncherItem("이미지 없음", "docker pull <이름> 으로 받기", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
|
||||
case "stop":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
// 실행 중 컨테이너 목록 표시 → 클릭 시 stop
|
||||
var running = GetContainers(running: true);
|
||||
items.Add(new LauncherItem("중지할 컨테이너 선택", "Enter → 중지", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"중지: {c.Name}", c.Image,
|
||||
null, ("stop", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 중지: {name}",
|
||||
$"docker stop {name} · Enter 실행",
|
||||
null, ("stop", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "start":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var stopped = GetContainers(running: false, stopped: true);
|
||||
items.Add(new LauncherItem("시작할 컨테이너 선택", "Enter → 시작", null, null, Symbol: "\uE756"));
|
||||
foreach (var c in stopped)
|
||||
items.Add(new LauncherItem($"시작: {c.Name}", c.Image,
|
||||
null, ("start", c.Id), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"컨테이너 시작: {name}",
|
||||
$"docker start {name} · Enter 실행",
|
||||
null, ("start", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "logs":
|
||||
case "log":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"로그: {c.Name}", "Enter → 터미널에서 로그 보기",
|
||||
null, ("logs", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"로그: {name}", $"docker logs -f {name}",
|
||||
null, ("logs", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "shell":
|
||||
case "exec":
|
||||
case "sh":
|
||||
{
|
||||
var name = parts.Length > 1 ? parts[1] : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
var running = GetContainers(running: true);
|
||||
foreach (var c in running)
|
||||
items.Add(new LauncherItem($"쉘: {c.Name}", "Enter → 컨테이너 shell 접속",
|
||||
null, ("shell", c.Name), Symbol: "\uE756"));
|
||||
}
|
||||
else
|
||||
{
|
||||
items.Add(new LauncherItem($"쉘 접속: {name}", $"docker exec -it {name} sh",
|
||||
null, ("shell", name), Symbol: "\uE756"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
// 컨테이너 이름 검색
|
||||
var all = GetContainers(running: false, stopped: true, all: true);
|
||||
var found = all.Where(c =>
|
||||
c.Name.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Image.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (found.Count > 0)
|
||||
foreach (var c in found)
|
||||
items.Add(MakeContainerItem(c));
|
||||
else
|
||||
items.Add(new LauncherItem($"'{q}' 컨테이너 없음",
|
||||
"docker all → 전체 목록", null, null, Symbol: "\uE946"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<LauncherItem>>(items);
|
||||
}
|
||||
|
||||
public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
|
||||
{
|
||||
switch (item.Data)
|
||||
{
|
||||
case ("stop", string id):
|
||||
RunDockerSilent($"stop {id}");
|
||||
NotificationService.Notify("Docker", $"중지: {id}");
|
||||
break;
|
||||
|
||||
case ("start", string id):
|
||||
RunDockerSilent($"start {id}");
|
||||
NotificationService.Notify("Docker", $"시작: {id}");
|
||||
break;
|
||||
|
||||
case ("logs", string name):
|
||||
RunInTerminal($"docker logs -f {name}");
|
||||
break;
|
||||
|
||||
case ("shell", string name):
|
||||
RunInTerminal($"docker exec -it {name} sh");
|
||||
break;
|
||||
|
||||
case ("copy", string text):
|
||||
try
|
||||
{
|
||||
System.Windows.Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
|
||||
NotificationService.Notify("Docker", "복사됨");
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
|
||||
case ("open_url", string url):
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{ FileName = url, UseShellExecute = true });
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
break;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Docker 조회 ──────────────────────────────────────────────────────────
|
||||
|
||||
private record DockerContainer(string Id, string Name, string Image, string Status, string Ports);
|
||||
private record DockerImage(string Repository, string Tag, string Id, string Size, string Created);
|
||||
|
||||
private static List<DockerContainer> GetContainers(bool running = true, bool stopped = false, bool all = false)
|
||||
{
|
||||
var result = new List<DockerContainer>();
|
||||
try
|
||||
{
|
||||
var filter = all || (!running && stopped) ? "-a" : (running ? "" : "--filter status=exited");
|
||||
var output = RunDockerOutput($"ps {filter} --format \"{{{{.ID}}}}\\t{{{{.Names}}}}\\t{{{{.Image}}}}\\t{{{{.Status}}}}\\t{{{{.Ports}}}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerContainer(
|
||||
Id: cols[0],
|
||||
Name: cols[1],
|
||||
Image: cols[2],
|
||||
Status: cols[3],
|
||||
Ports: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<DockerImage> GetImages()
|
||||
{
|
||||
var result = new List<DockerImage>();
|
||||
try
|
||||
{
|
||||
var output = RunDockerOutput("images --format \"{{.Repository}}\\t{{.Tag}}\\t{{.ID}}\\t{{.Size}}\\t{{.CreatedSince}}\"");
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) continue;
|
||||
var cols = trimmed.Split('\t');
|
||||
if (cols.Length < 4) continue;
|
||||
result.Add(new DockerImage(
|
||||
Repository: cols[0],
|
||||
Tag: cols.Length > 1 ? cols[1] : "latest",
|
||||
Id: cols.Length > 2 ? cols[2] : "",
|
||||
Size: cols.Length > 3 ? cols[3] : "",
|
||||
Created: cols.Length > 4 ? cols[4] : ""));
|
||||
}
|
||||
}
|
||||
catch { /* Docker 없음 */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string RunDockerOutput(string args)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
if (proc == null) return "";
|
||||
var output = proc.StandardOutput.ReadToEnd();
|
||||
proc.WaitForExit(5000);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void RunDockerSilent(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = args,
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(10000);
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
|
||||
private static bool IsDockerAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "docker", Arguments = "version --format json",
|
||||
UseShellExecute = false, CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
using var proc = System.Diagnostics.Process.Start(psi);
|
||||
proc?.WaitForExit(3000);
|
||||
return proc?.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
private static LauncherItem MakeContainerItem(DockerContainer c)
|
||||
{
|
||||
var isRunning = c.Status.StartsWith("Up", StringComparison.OrdinalIgnoreCase);
|
||||
var icon = isRunning ? "\uE768" : "\uE71A";
|
||||
var ports = string.IsNullOrWhiteSpace(c.Ports) ? "" : $" · {c.Ports}";
|
||||
return new LauncherItem(c.Name,
|
||||
$"{c.Status}{ports} · {c.Image}",
|
||||
null, ("copy", c.Id), Symbol: icon);
|
||||
}
|
||||
|
||||
private static LauncherItem MakeImageItem(DockerImage img)
|
||||
{
|
||||
var name = img.Tag == "<none>" ? img.Repository : $"{img.Repository}:{img.Tag}";
|
||||
return new LauncherItem(name, $"{img.Size} · {img.Created} · {img.Id[..Math.Min(12, img.Id.Length)]}",
|
||||
null, ("copy", name), Symbol: "\uE756");
|
||||
}
|
||||
|
||||
private static void RunInTerminal(string cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "cmd", Arguments = $"/K {cmd}", UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { /* 비핵심 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user