워크스페이스 복원에 탐색기·메모장 상태와 적응형 실행 간격을 추가한다
- 파일 탐색기 현재 폴더 경로와 메모장 열린 파일 경로를 워크스페이스 스냅샷에 저장하고 복원 경로에 연결 - 브라우저/앱 공용 프로세스 명령줄 파서를 추가하고 패키지형 메모장 실행 fallback을 보강 - 복원 중 새 창 실행 사이에 CPU·메모리 부하 기반 적응형 지연과 설정 UI를 추가 - README와 DEVELOPMENT 개발 이력을 2026-04-15 17:41 (KST) 기준으로 갱신 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_app_restore\\ -p:IntermediateOutputPath=obj\\verify_workspace_app_restore\\ 경고 0 오류 0, dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter WorkspaceHandlerTests|ContextManagerTests|SettingsServiceTests -p:OutputPath=bin\\verify_workspace_app_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_app_restore_tests\\ 54개 통과
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# AX Commander
|
||||
|
||||
- 업데이트: 2026-04-15 17:41 (KST)
|
||||
- `~` 워크스페이스 저장/복원에 파일 탐색기와 메모장 상태 복원을 추가했습니다. `src/AxCopilot/Core/AppWorkspaceStateHelper.cs`가 파일 탐색기 현재 폴더 경로와 메모장 열린 파일 경로를 best-effort로 수집하고, `src/AxCopilot/Core/ProcessCommandLineHelper.cs`가 프로세스 명령줄 파싱을 공용화합니다.
|
||||
- `src/AxCopilot/Core/ContextManager.cs`는 저장된 탐색기/메모장 상태가 현재 창 제목과 다를 때 새 창을 띄워 원래 경로와 파일을 다시 열고, 새 창 연속 실행 시에는 CPU·메모리 부하와 이미 실행한 창 수를 반영한 적응형 지연을 넣어 복원 속도를 자동 조절합니다.
|
||||
- `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`에 `복원 속도 자동 조절`, `복원 기본 간격`, `복원 최대 간격` 설정을 추가했습니다.
|
||||
- 테스트를 `src/AxCopilot.Tests/Core/ContextManagerTests.cs`, `src/AxCopilot.Tests/Services/SettingsServiceTests.cs`에 확장했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_app_restore\\ -p:IntermediateOutputPath=obj\\verify_workspace_app_restore\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests|SettingsServiceTests" -p:OutputPath=bin\\verify_workspace_app_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_app_restore_tests\\` 54개 통과를 확인했습니다.
|
||||
|
||||
- 업데이트: 2026-04-15 17:26 (KST)
|
||||
- `~` 워크스페이스 저장/복원에 브라우저 상태 복원을 추가했습니다. `src/AxCopilot/Core/BrowserWorkspaceStateHelper.cs`가 Edge/Chrome/Whale/Brave/Opera/Firefox 창에서 프로필 인자와 탭 URL, 활성 탭 인덱스를 best-effort로 수집하고, `src/AxCopilot/Core/ContextManager.cs`가 브라우저 상태가 저장된 경우 새 창을 띄워 동일한 탭 묶음을 다시 열어 배치합니다.
|
||||
- `src/AxCopilot/Models/AppSettings.cs`, `src/AxCopilot/ViewModels/SettingsViewModel.cs`, `src/AxCopilot/Views/SettingsWindow.xaml`에 `브라우저 상태 복원` 토글을 연결해 저장 시 탭 포커스 이동이 부담되는 환경에서는 기능을 끌 수 있게 했습니다.
|
||||
|
||||
2152
docs/DEVELOPMENT.md
2152
docs/DEVELOPMENT.md
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
using AxCopilot.Core;
|
||||
using AxCopilot.Models;
|
||||
using FluentAssertions;
|
||||
using System.IO;
|
||||
using Xunit;
|
||||
|
||||
namespace AxCopilot.Tests.Core;
|
||||
@@ -154,4 +155,196 @@ public class ContextManagerTests
|
||||
|
||||
BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, candidate).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppLaunchPlan_CreatesExplorerWindowWithCapturedFolder()
|
||||
{
|
||||
using var folder1 = new TempDirectory();
|
||||
using var folder2 = new TempDirectory();
|
||||
|
||||
var state = new AppWindowState
|
||||
{
|
||||
Kind = "explorer",
|
||||
PrimaryPath = folder1.Path,
|
||||
Paths = [folder1.Path, folder2.Path]
|
||||
};
|
||||
|
||||
var plan = AppWorkspaceStateHelper.CreateLaunchPlan(
|
||||
@"C:\Windows\explorer.exe",
|
||||
state);
|
||||
|
||||
plan.Should().NotBeNull();
|
||||
plan!.Arguments.Should().ContainSingle()
|
||||
.Which.Should().Be(folder1.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppLaunchPlan_CreatesNotepadWindowWithCapturedFiles()
|
||||
{
|
||||
using var file1 = new TempFile(".txt");
|
||||
using var file2 = new TempFile(".log");
|
||||
|
||||
var state = new AppWindowState
|
||||
{
|
||||
Kind = "notepad",
|
||||
PrimaryPath = file1.Path,
|
||||
Paths = [file1.Path, file2.Path]
|
||||
};
|
||||
|
||||
var plan = AppWorkspaceStateHelper.CreateLaunchPlan(
|
||||
@"C:\Windows\System32\notepad.exe",
|
||||
state);
|
||||
|
||||
plan.Should().NotBeNull();
|
||||
plan!.Arguments.Should().ContainInOrder(file1.Path, file2.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppLaunchPlan_UsesSystemExecutableFallbackForPackagedNotepad()
|
||||
{
|
||||
using var file1 = new TempFile(".txt");
|
||||
|
||||
var state = new AppWindowState
|
||||
{
|
||||
Kind = "notepad",
|
||||
PrimaryPath = file1.Path,
|
||||
Paths = [file1.Path]
|
||||
};
|
||||
|
||||
var plan = AppWorkspaceStateHelper.CreateLaunchPlan(
|
||||
@"C:\Program Files\WindowsApps\Microsoft.WindowsNotepad_11.2401.32.0_x64__8wekyb3d8bbwe\Notepad.exe",
|
||||
state);
|
||||
|
||||
plan.Should().NotBeNull();
|
||||
plan!.FileName.Should().Be("notepad.exe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppShouldLaunchNewWindow_WithCapturedStateAndDifferentTitle_ReturnsTrue()
|
||||
{
|
||||
using var folder = new TempDirectory();
|
||||
|
||||
var snapshot = new WindowSnapshot
|
||||
{
|
||||
Exe = @"C:\Windows\explorer.exe",
|
||||
Title = "문서 - 파일 탐색기",
|
||||
AppState = new AppWindowState
|
||||
{
|
||||
Kind = "explorer",
|
||||
PrimaryPath = folder.Path,
|
||||
Paths = [folder.Path]
|
||||
}
|
||||
};
|
||||
|
||||
var candidate = new ContextManager.WindowCandidate(
|
||||
new IntPtr(7),
|
||||
snapshot.Exe,
|
||||
"다운로드 - 파일 탐색기");
|
||||
|
||||
AppWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, candidate).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppShouldLaunchNewWindow_WithCapturedStateAndExactTitle_ReturnsFalse()
|
||||
{
|
||||
var snapshot = new WindowSnapshot
|
||||
{
|
||||
Exe = @"C:\Windows\System32\notepad.exe",
|
||||
Title = "todo.txt - 메모장",
|
||||
AppState = new AppWindowState
|
||||
{
|
||||
Kind = "notepad",
|
||||
PrimaryPath = @"C:\Temp\todo.txt",
|
||||
Paths = [@"C:\Temp\todo.txt"]
|
||||
}
|
||||
};
|
||||
|
||||
var candidate = new ContextManager.WindowCandidate(
|
||||
new IntPtr(8),
|
||||
snapshot.Exe,
|
||||
"todo.txt - 메모장");
|
||||
|
||||
AppWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, candidate).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRestoreLaunchDelayMs_Disabled_ReturnsZero()
|
||||
{
|
||||
var settings = new LauncherSettings
|
||||
{
|
||||
EnableAdaptiveWorkspaceRestoreThrottle = false,
|
||||
WorkspaceRestoreBaseDelayMs = 250,
|
||||
WorkspaceRestoreMaxDelayMs = 1200
|
||||
};
|
||||
|
||||
var delay = ContextManager.CalculateRestoreLaunchDelayMs(settings, 85, 92, 3);
|
||||
|
||||
delay.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateRestoreLaunchDelayMs_HighLoadIncreasesDelay()
|
||||
{
|
||||
var settings = new LauncherSettings
|
||||
{
|
||||
EnableAdaptiveWorkspaceRestoreThrottle = true,
|
||||
WorkspaceRestoreBaseDelayMs = 250,
|
||||
WorkspaceRestoreMaxDelayMs = 1200
|
||||
};
|
||||
|
||||
var lowLoadDelay = ContextManager.CalculateRestoreLaunchDelayMs(settings, 20, 45, 1);
|
||||
var highLoadDelay = ContextManager.CalculateRestoreLaunchDelayMs(settings, 88, 91, 4);
|
||||
|
||||
lowLoadDelay.Should().Be(250);
|
||||
highLoadDelay.Should().BeGreaterThan(lowLoadDelay);
|
||||
highLoadDelay.Should().BeLessOrEqualTo(1200);
|
||||
}
|
||||
|
||||
private sealed class TempFile : IDisposable
|
||||
{
|
||||
public TempFile(string extension)
|
||||
{
|
||||
Path = System.IO.Path.ChangeExtension(System.IO.Path.GetTempFileName(), extension);
|
||||
File.WriteAllText(Path, "temp");
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(Path))
|
||||
File.Delete(Path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 테스트 정리 실패는 무시합니다.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"axcopilot-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 테스트 정리 실패는 무시합니다.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,21 @@ public class SettingsServiceTests
|
||||
new LauncherSettings().EnableBrowserSessionRestore.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherSettings_DefaultAdaptiveWorkspaceRestoreThrottle_IsEnabled()
|
||||
{
|
||||
new LauncherSettings().EnableAdaptiveWorkspaceRestoreThrottle.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LauncherSettings_DefaultWorkspaceRestoreDelayRange_IsValid()
|
||||
{
|
||||
var settings = new LauncherSettings();
|
||||
|
||||
settings.WorkspaceRestoreBaseDelayMs.Should().Be(250);
|
||||
settings.WorkspaceRestoreMaxDelayMs.Should().BeGreaterOrEqualTo(settings.WorkspaceRestoreBaseDelayMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_DefaultMonitorMismatch_IsWarn()
|
||||
{
|
||||
@@ -244,6 +259,39 @@ public class SettingsServiceTests
|
||||
"https://portal.example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkspaceProfile_Serialization_PreservesAppState()
|
||||
{
|
||||
var profile = new WorkspaceProfile
|
||||
{
|
||||
Name = "앱 상태 프로필",
|
||||
Windows =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Exe = "explorer.exe",
|
||||
Title = "문서 - 파일 탐색기",
|
||||
AppState = new AppWindowState
|
||||
{
|
||||
Kind = "explorer",
|
||||
PrimaryPath = @"C:\Docs",
|
||||
ActivePathIndex = 0,
|
||||
Paths = [@"C:\Docs", @"C:\Docs\Archive"]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(profile, JsonOptions);
|
||||
var restored = JsonSerializer.Deserialize<WorkspaceProfile>(json, JsonOptions)!;
|
||||
|
||||
restored.Windows.Should().HaveCount(1);
|
||||
restored.Windows[0].AppState.Should().NotBeNull();
|
||||
restored.Windows[0].AppState!.Kind.Should().Be("explorer");
|
||||
restored.Windows[0].AppState!.PrimaryPath.Should().Be(@"C:\Docs");
|
||||
restored.Windows[0].AppState!.Paths.Should().ContainInOrder(@"C:\Docs", @"C:\Docs\Archive");
|
||||
}
|
||||
|
||||
// ─── ClipboardTransformer ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
291
src/AxCopilot/Core/AppWorkspaceStateHelper.cs
Normal file
291
src/AxCopilot/Core/AppWorkspaceStateHelper.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Web;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
internal static class AppWorkspaceStateHelper
|
||||
{
|
||||
public static AppWindowState? TryCapture(IntPtr hWnd, string exePath, string title)
|
||||
{
|
||||
var exeName = NormalizeExeName(Path.GetFileNameWithoutExtension(exePath) ?? string.Empty);
|
||||
return exeName switch
|
||||
{
|
||||
"explorer" => TryCaptureExplorer(hWnd, title),
|
||||
"notepad" => TryCaptureNotepad(hWnd),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool ShouldLaunchNewWindow(WindowSnapshot snapshot, ContextManager.WindowCandidate? candidate)
|
||||
{
|
||||
if (!HasRestorableState(snapshot.AppState))
|
||||
return false;
|
||||
|
||||
return candidate is null || !BrowserWorkspaceStateHelper.TitlesRepresentSameWindow(snapshot.Title, candidate.Value.Title);
|
||||
}
|
||||
|
||||
internal static AppLaunchPlan? CreateLaunchPlan(string exePath, AppWindowState? state)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(exePath) || state == null)
|
||||
return null;
|
||||
|
||||
return NormalizeKind(state.Kind) switch
|
||||
{
|
||||
"explorer" => CreateExplorerLaunchPlan(exePath, state),
|
||||
"notepad" => CreateNotepadLaunchPlan(exePath, state),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static AppWindowState? TryCaptureExplorer(IntPtr hWnd, string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = GetExplorerEntries(hWnd)
|
||||
.Where(entry => Directory.Exists(entry.Path))
|
||||
.GroupBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
if (entries.Count == 0)
|
||||
return null;
|
||||
|
||||
var activeIndex = entries.FindIndex(entry => TitleRepresentsExplorerEntry(title, entry));
|
||||
|
||||
if (activeIndex < 0)
|
||||
activeIndex = 0;
|
||||
|
||||
return new AppWindowState
|
||||
{
|
||||
Kind = "explorer",
|
||||
PrimaryPath = entries[activeIndex].Path,
|
||||
ActivePathIndex = activeIndex,
|
||||
Paths = entries.Select(entry => entry.Path).ToList()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"파일 탐색기 경로 수집 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AppWindowState? TryCaptureNotepad(IntPtr hWnd)
|
||||
{
|
||||
try
|
||||
{
|
||||
GetWindowThreadProcessId(hWnd, out var processId);
|
||||
if (processId == 0)
|
||||
return null;
|
||||
|
||||
var commandLine = ProcessCommandLineHelper.TryGetProcessCommandLine((int)processId);
|
||||
if (string.IsNullOrWhiteSpace(commandLine))
|
||||
return null;
|
||||
|
||||
var args = ProcessCommandLineHelper.SplitCommandLine(commandLine);
|
||||
if (args.Count <= 1)
|
||||
return null;
|
||||
|
||||
var paths = args
|
||||
.Skip(1)
|
||||
.Where(argument => !string.IsNullOrWhiteSpace(argument))
|
||||
.Where(argument => !argument.StartsWith("-", StringComparison.Ordinal))
|
||||
.Select(argument => argument.Trim().Trim('"'))
|
||||
.Where(File.Exists)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (paths.Count == 0)
|
||||
return null;
|
||||
|
||||
return new AppWindowState
|
||||
{
|
||||
Kind = "notepad",
|
||||
PrimaryPath = paths[0],
|
||||
ActivePathIndex = 0,
|
||||
Paths = paths
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"메모장 파일 경로 수집 실패: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<ExplorerEntry> GetExplorerEntries(IntPtr hWnd)
|
||||
{
|
||||
var shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||
if (shellType == null)
|
||||
yield break;
|
||||
|
||||
dynamic? shell = null;
|
||||
dynamic? windows = null;
|
||||
|
||||
try
|
||||
{
|
||||
shell = Activator.CreateInstance(shellType);
|
||||
windows = shell?.Windows();
|
||||
|
||||
if (windows == null)
|
||||
yield break;
|
||||
|
||||
foreach (var window in windows)
|
||||
{
|
||||
string fullName;
|
||||
string locationUrl;
|
||||
string locationName;
|
||||
int windowHwnd;
|
||||
|
||||
try
|
||||
{
|
||||
fullName = window.FullName as string ?? string.Empty;
|
||||
locationUrl = window.LocationURL as string ?? string.Empty;
|
||||
locationName = window.LocationName as string ?? string.Empty;
|
||||
windowHwnd = (int)window.HWND;
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (windowHwnd != hWnd.ToInt32())
|
||||
continue;
|
||||
|
||||
if (!Path.GetFileName(fullName).Equals("explorer.exe", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var path = TryConvertExplorerUrlToPath(locationUrl);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
continue;
|
||||
|
||||
yield return new ExplorerEntry(path, locationName);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(windows);
|
||||
ReleaseComObject(shell);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? TryConvertExplorerUrlToPath(string locationUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locationUrl))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(locationUrl, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
if (!uri.IsFile)
|
||||
return null;
|
||||
|
||||
var localPath = HttpUtility.UrlDecode(uri.LocalPath);
|
||||
return string.IsNullOrWhiteSpace(localPath) ? null : localPath;
|
||||
}
|
||||
|
||||
private static AppLaunchPlan? CreateExplorerLaunchPlan(string exePath, AppWindowState state)
|
||||
{
|
||||
var path = ResolvePrimaryPath(state, Directory.Exists);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
return new AppLaunchPlan(ResolveLaunchFileName(exePath, state.Kind), [path]);
|
||||
}
|
||||
|
||||
private static AppLaunchPlan? CreateNotepadLaunchPlan(string exePath, AppWindowState state)
|
||||
{
|
||||
var paths = state.Paths
|
||||
.Where(File.Exists)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (paths.Count == 0 && File.Exists(state.PrimaryPath))
|
||||
paths.Add(state.PrimaryPath!);
|
||||
|
||||
if (paths.Count == 0)
|
||||
return null;
|
||||
|
||||
return new AppLaunchPlan(ResolveLaunchFileName(exePath, state.Kind), paths);
|
||||
}
|
||||
|
||||
private static bool HasRestorableState(AppWindowState? state)
|
||||
{
|
||||
if (state == null)
|
||||
return false;
|
||||
|
||||
return NormalizeKind(state.Kind) switch
|
||||
{
|
||||
"explorer" => !string.IsNullOrWhiteSpace(ResolvePrimaryPath(state, Directory.Exists)),
|
||||
"notepad" => state.Paths.Any(File.Exists) || File.Exists(state.PrimaryPath),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolvePrimaryPath(AppWindowState state, Func<string, bool> exists)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(state.PrimaryPath) && exists(state.PrimaryPath))
|
||||
return state.PrimaryPath;
|
||||
|
||||
return state.Paths.FirstOrDefault(exists);
|
||||
}
|
||||
|
||||
private static string NormalizeExeName(string exeName) => exeName.Trim().ToLowerInvariant();
|
||||
|
||||
private static string NormalizeKind(string kind) => kind.Trim().ToLowerInvariant();
|
||||
|
||||
private static bool TitleRepresentsExplorerEntry(string title, ExplorerEntry entry)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.LocationName) &&
|
||||
title.Contains(entry.LocationName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (title.Contains(entry.Path, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
var folderName = Path.GetFileName(entry.Path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
return !string.IsNullOrWhiteSpace(folderName) &&
|
||||
title.Contains(folderName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveLaunchFileName(string exePath, string kind)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(exePath) && File.Exists(exePath))
|
||||
return exePath;
|
||||
|
||||
return NormalizeKind(kind) switch
|
||||
{
|
||||
"explorer" => "explorer.exe",
|
||||
"notepad" => "notepad.exe",
|
||||
_ => exePath
|
||||
};
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object? instance)
|
||||
{
|
||||
if (instance == null || !Marshal.IsComObject(instance))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.FinalReleaseComObject(instance);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// COM 해제 실패는 저장/복원을 중단하지 않습니다.
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||
|
||||
private sealed record ExplorerEntry(string Path, string LocationName);
|
||||
}
|
||||
|
||||
internal sealed record AppLaunchPlan(string FileName, IReadOnlyList<string> Arguments);
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO;
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Automation;
|
||||
using AxCopilot.Models;
|
||||
@@ -191,79 +190,23 @@ internal static class BrowserWorkspaceStateHelper
|
||||
if (string.IsNullOrWhiteSpace(commandLine))
|
||||
return;
|
||||
|
||||
var args = SplitCommandLine(commandLine);
|
||||
var args = ProcessCommandLineHelper.SplitCommandLine(commandLine);
|
||||
if (args.Count == 0)
|
||||
return;
|
||||
|
||||
if (state.Kind == "firefox")
|
||||
{
|
||||
state.UserDataDir = GetArgumentValue(args, "-profile");
|
||||
state.ProfileDirectory = GetArgumentValue(args, "-P");
|
||||
state.UserDataDir = ProcessCommandLineHelper.GetArgumentValue(args, "-profile");
|
||||
state.ProfileDirectory = ProcessCommandLineHelper.GetArgumentValue(args, "-P");
|
||||
return;
|
||||
}
|
||||
|
||||
state.UserDataDir = GetArgumentValue(args, "--user-data-dir");
|
||||
state.ProfileDirectory = GetArgumentValue(args, "--profile-directory");
|
||||
state.UserDataDir = ProcessCommandLineHelper.GetArgumentValue(args, "--user-data-dir");
|
||||
state.ProfileDirectory = ProcessCommandLineHelper.GetArgumentValue(args, "--profile-directory");
|
||||
}
|
||||
|
||||
private static string? TryGetProcessCommandLine(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {processId}");
|
||||
using var results = searcher.Get();
|
||||
|
||||
foreach (ManagementObject process in results)
|
||||
return process["CommandLine"]?.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"프로세스 명령줄 조회 실패: PID={processId} - {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> SplitCommandLine(string commandLine)
|
||||
{
|
||||
var argv = CommandLineToArgvW(commandLine, out var argc);
|
||||
if (argv == IntPtr.Zero || argc <= 0)
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var args = new List<string>(argc);
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
var value = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
args.Add(Marshal.PtrToStringUni(value) ?? string.Empty);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalFree(argv);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetArgumentValue(IReadOnlyList<string> args, string key)
|
||||
{
|
||||
for (var i = 1; i < args.Count; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (current.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase))
|
||||
return current[(key.Length + 1)..].Trim().Trim('"');
|
||||
|
||||
if (!current.Equals(key, StringComparison.OrdinalIgnoreCase) || i + 1 >= args.Count)
|
||||
continue;
|
||||
|
||||
return args[i + 1].Trim().Trim('"');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
=> ProcessCommandLineHelper.TryGetProcessCommandLine(processId);
|
||||
|
||||
private static void CaptureTabs(IntPtr hWnd, BrowserWindowState state)
|
||||
{
|
||||
@@ -616,13 +559,6 @@ internal static class BrowserWorkspaceStateHelper
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("shell32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CommandLineToArgvW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
|
||||
out int pNumArgs);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr hMem);
|
||||
}
|
||||
|
||||
internal sealed record BrowserLaunchPlan(string FileName, IReadOnlyList<string> Arguments);
|
||||
|
||||
@@ -12,8 +12,43 @@ namespace AxCopilot.Core;
|
||||
/// </summary>
|
||||
public class ContextManager
|
||||
{
|
||||
private static volatile PerformanceCounter? _workspaceRestoreCpuCounter;
|
||||
private static readonly object WorkspaceRestoreCpuLock = new();
|
||||
private static float _workspaceRestoreCpuCached = -1;
|
||||
private static DateTime _workspaceRestoreCpuUpdated = DateTime.MinValue;
|
||||
private readonly SettingsService _settings;
|
||||
|
||||
static ContextManager()
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_workspaceRestoreCpuCounter?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 종료 시 정리 실패는 무시합니다.
|
||||
}
|
||||
|
||||
_workspaceRestoreCpuCounter = null;
|
||||
};
|
||||
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var counter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
|
||||
counter.NextValue();
|
||||
_workspaceRestoreCpuCounter = counter;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 일부 환경에서는 PerformanceCounter를 지원하지 않을 수 있습니다.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ContextManager(SettingsService settings)
|
||||
{
|
||||
_settings = settings;
|
||||
@@ -59,10 +94,13 @@ public class ContextManager
|
||||
|
||||
int monitorIndex = GetMonitorIndex(hWnd, monitorMap);
|
||||
BrowserWindowState? browserState = null;
|
||||
AppWindowState? appState = null;
|
||||
|
||||
if (shouldCaptureBrowserState)
|
||||
browserState = BrowserWorkspaceStateHelper.TryCapture(hWnd, exePath);
|
||||
|
||||
appState = AppWorkspaceStateHelper.TryCapture(hWnd, exePath, title);
|
||||
|
||||
snapshots.Add(new WindowSnapshot
|
||||
{
|
||||
Exe = exePath,
|
||||
@@ -76,7 +114,8 @@ public class ContextManager
|
||||
},
|
||||
ShowCmd = showCmd,
|
||||
Monitor = monitorIndex,
|
||||
Browser = browserState
|
||||
Browser = browserState,
|
||||
AppState = appState
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -117,6 +156,7 @@ public class ContextManager
|
||||
var results = new List<string>();
|
||||
var monitorCount = GetMonitorCount();
|
||||
var usedHandles = new HashSet<IntPtr>();
|
||||
var launchedWindowCount = 0;
|
||||
|
||||
foreach (var snapshot in profile.Windows)
|
||||
{
|
||||
@@ -125,10 +165,11 @@ public class ContextManager
|
||||
// 1. 실행 중인 창 찾기
|
||||
var matchedCandidate = FindMatchingWindowCandidate(snapshot, usedHandles);
|
||||
var hWnd = matchedCandidate?.Handle ?? IntPtr.Zero;
|
||||
var launchedBrowserWindow = false;
|
||||
var launchedSpecialWindow = false;
|
||||
|
||||
// 2. 브라우저 상태가 있으면 새 창 복원을 우선 시도
|
||||
if (File.Exists(snapshot.Exe) && BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, matchedCandidate))
|
||||
// 2. 브라우저/탐색기/메모장 상태가 있으면 새 창 복원을 우선 시도
|
||||
if (BrowserWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, matchedCandidate) ||
|
||||
AppWorkspaceStateHelper.ShouldLaunchNewWindow(snapshot, matchedCandidate))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -136,9 +177,11 @@ public class ContextManager
|
||||
.Select(candidate => candidate.Handle)
|
||||
.ToHashSet();
|
||||
|
||||
launchedBrowserWindow = LaunchBrowserWindow(snapshot);
|
||||
if (launchedBrowserWindow)
|
||||
await ApplyRestoreLaunchThrottleAsync(launchedWindowCount, ct);
|
||||
launchedSpecialWindow = LaunchSpecialWindow(snapshot);
|
||||
if (launchedSpecialWindow)
|
||||
{
|
||||
launchedWindowCount++;
|
||||
hWnd = await WaitForLaunchedWindowAsync(
|
||||
snapshot,
|
||||
preLaunchHandles,
|
||||
@@ -159,11 +202,13 @@ public class ContextManager
|
||||
}
|
||||
|
||||
// 3. 창이 없으면 EXE 실행 후 대기
|
||||
if (hWnd == IntPtr.Zero && !launchedBrowserWindow && File.Exists(snapshot.Exe))
|
||||
if (hWnd == IntPtr.Zero && !launchedSpecialWindow && File.Exists(snapshot.Exe))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ApplyRestoreLaunchThrottleAsync(launchedWindowCount, ct);
|
||||
Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true });
|
||||
launchedWindowCount++;
|
||||
hWnd = await WaitForWindowAsync(snapshot, usedHandles, TimeSpan.FromSeconds(3), ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -212,7 +257,7 @@ public class ContextManager
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
if (launchedBrowserWindow && snapshot.Browser != null)
|
||||
if (launchedSpecialWindow && snapshot.Browser != null)
|
||||
BrowserWorkspaceStateHelper.TryRestoreActiveTab(hWnd, snapshot.Browser);
|
||||
|
||||
results.Add($"✓ {snapshot.Title}: 복원 완료");
|
||||
@@ -309,22 +354,129 @@ public class ContextManager
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private static bool LaunchBrowserWindow(WindowSnapshot snapshot)
|
||||
private async Task ApplyRestoreLaunchThrottleAsync(int launchedWindowCount, CancellationToken ct)
|
||||
{
|
||||
var plan = BrowserWorkspaceStateHelper.CreateLaunchPlan(snapshot.Exe, snapshot.Browser);
|
||||
if (plan == null)
|
||||
return false;
|
||||
var delayMs = GetRestoreLaunchDelayMs(_settings.Settings.Launcher, launchedWindowCount);
|
||||
if (delayMs <= 0)
|
||||
return;
|
||||
|
||||
var startInfo = new ProcessStartInfo(plan.FileName)
|
||||
await Task.Delay(delayMs, ct);
|
||||
}
|
||||
|
||||
private static int GetRestoreLaunchDelayMs(LauncherSettings settings, int launchedWindowCount)
|
||||
{
|
||||
if (launchedWindowCount <= 0 || !settings.EnableAdaptiveWorkspaceRestoreThrottle)
|
||||
return 0;
|
||||
|
||||
return CalculateRestoreLaunchDelayMs(
|
||||
settings,
|
||||
GetWorkspaceRestoreCpuUsagePercent(),
|
||||
GetWorkspaceRestoreMemoryLoadPercent(),
|
||||
launchedWindowCount);
|
||||
}
|
||||
|
||||
internal static int CalculateRestoreLaunchDelayMs(
|
||||
LauncherSettings settings,
|
||||
float cpuUsagePercent,
|
||||
uint memoryLoadPercent,
|
||||
int launchedWindowCount)
|
||||
{
|
||||
if (launchedWindowCount <= 0 || !settings.EnableAdaptiveWorkspaceRestoreThrottle)
|
||||
return 0;
|
||||
|
||||
var baseDelay = Math.Clamp(settings.WorkspaceRestoreBaseDelayMs, 0, 3000);
|
||||
var maxDelay = Math.Max(baseDelay, Math.Clamp(settings.WorkspaceRestoreMaxDelayMs, 0, 5000));
|
||||
var delay = baseDelay;
|
||||
|
||||
if (launchedWindowCount > 1)
|
||||
delay += Math.Min((launchedWindowCount - 1) * 120, 600);
|
||||
|
||||
if (cpuUsagePercent >= 80f)
|
||||
delay += 550;
|
||||
else if (cpuUsagePercent >= 65f)
|
||||
delay += 350;
|
||||
else if (cpuUsagePercent >= 50f)
|
||||
delay += 180;
|
||||
else if (cpuUsagePercent >= 35f)
|
||||
delay += 80;
|
||||
|
||||
if (memoryLoadPercent >= 90)
|
||||
delay += 500;
|
||||
else if (memoryLoadPercent >= 80)
|
||||
delay += 300;
|
||||
else if (memoryLoadPercent >= 70)
|
||||
delay += 150;
|
||||
|
||||
return Math.Clamp(delay, baseDelay, maxDelay);
|
||||
}
|
||||
|
||||
private static float GetWorkspaceRestoreCpuUsagePercent()
|
||||
{
|
||||
try
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(plan.FileName) ?? Environment.CurrentDirectory
|
||||
var counter = _workspaceRestoreCpuCounter;
|
||||
if (counter == null)
|
||||
return -1;
|
||||
|
||||
lock (WorkspaceRestoreCpuLock)
|
||||
{
|
||||
if ((DateTime.UtcNow - _workspaceRestoreCpuUpdated).TotalMilliseconds > 800)
|
||||
{
|
||||
_workspaceRestoreCpuCached = counter.NextValue();
|
||||
_workspaceRestoreCpuUpdated = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
return _workspaceRestoreCpuCached;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static uint GetWorkspaceRestoreMemoryLoadPercent()
|
||||
{
|
||||
var memory = new MEMORYSTATUSEX
|
||||
{
|
||||
dwLength = (uint)Marshal.SizeOf<MEMORYSTATUSEX>()
|
||||
};
|
||||
|
||||
foreach (var argument in plan.Arguments)
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
return GlobalMemoryStatusEx(ref memory) ? memory.dwMemoryLoad : 0;
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
private static bool LaunchSpecialWindow(WindowSnapshot snapshot)
|
||||
{
|
||||
var browserPlan = BrowserWorkspaceStateHelper.CreateLaunchPlan(snapshot.Exe, snapshot.Browser);
|
||||
if (browserPlan != null)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo(browserPlan.FileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(browserPlan.FileName) ?? Environment.CurrentDirectory
|
||||
};
|
||||
|
||||
foreach (var argument in browserPlan.Arguments)
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
var appPlan = AppWorkspaceStateHelper.CreateLaunchPlan(snapshot.Exe, snapshot.AppState);
|
||||
if (appPlan == null)
|
||||
return false;
|
||||
|
||||
var appStartInfo = new ProcessStartInfo(appPlan.FileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(appPlan.FileName) ?? Environment.CurrentDirectory
|
||||
};
|
||||
|
||||
foreach (var argument in appPlan.Arguments)
|
||||
appStartInfo.ArgumentList.Add(argument);
|
||||
|
||||
Process.Start(appStartInfo);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -561,6 +713,20 @@ public class ContextManager
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT { public int x, y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
||||
private struct MEMORYSTATUSEX
|
||||
{
|
||||
public uint dwLength;
|
||||
public uint dwMemoryLoad;
|
||||
public ulong ullTotalPhys;
|
||||
public ulong ullAvailPhys;
|
||||
public ulong ullTotalPageFile;
|
||||
public ulong ullAvailPageFile;
|
||||
public ulong ullTotalVirtual;
|
||||
public ulong ullAvailVirtual;
|
||||
public ulong ullAvailExtendedVirtual;
|
||||
}
|
||||
|
||||
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
||||
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref RECT lprcMonitor, IntPtr dwData);
|
||||
|
||||
@@ -576,6 +742,9 @@ public class ContextManager
|
||||
[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 EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
|
||||
|
||||
internal readonly record struct WindowCandidate(IntPtr Handle, string Exe, string Title);
|
||||
}
|
||||
|
||||
75
src/AxCopilot/Core/ProcessCommandLineHelper.cs
Normal file
75
src/AxCopilot/Core/ProcessCommandLineHelper.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Core;
|
||||
|
||||
internal static class ProcessCommandLineHelper
|
||||
{
|
||||
public static string? TryGetProcessCommandLine(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT CommandLine FROM Win32_Process WHERE ProcessId = {processId}");
|
||||
using var results = searcher.Get();
|
||||
|
||||
foreach (ManagementObject process in results)
|
||||
return process["CommandLine"]?.ToString();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"프로세스 명령줄 조회 실패: PID={processId} - {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<string> SplitCommandLine(string commandLine)
|
||||
{
|
||||
var argv = CommandLineToArgvW(commandLine, out var argc);
|
||||
if (argv == IntPtr.Zero || argc <= 0)
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var args = new List<string>(argc);
|
||||
for (var i = 0; i < argc; i++)
|
||||
{
|
||||
var value = Marshal.ReadIntPtr(argv, i * IntPtr.Size);
|
||||
args.Add(Marshal.PtrToStringUni(value) ?? string.Empty);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
finally
|
||||
{
|
||||
LocalFree(argv);
|
||||
}
|
||||
}
|
||||
|
||||
public static string? GetArgumentValue(IReadOnlyList<string> args, string key)
|
||||
{
|
||||
for (var i = 1; i < args.Count; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (current.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase))
|
||||
return current[(key.Length + 1)..].Trim().Trim('"');
|
||||
|
||||
if (!current.Equals(key, StringComparison.OrdinalIgnoreCase) || i + 1 >= args.Count)
|
||||
continue;
|
||||
|
||||
return args[i + 1].Trim().Trim('"');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[DllImport("shell32.dll", SetLastError = true)]
|
||||
private static extern IntPtr CommandLineToArgvW(
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
|
||||
out int pNumArgs);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr hMem);
|
||||
}
|
||||
@@ -235,6 +235,18 @@ public class LauncherSettings
|
||||
[JsonPropertyName("enableBrowserSessionRestore")]
|
||||
public bool EnableBrowserSessionRestore { get; set; } = true;
|
||||
|
||||
/// <summary>워크스페이스 복원 시 새 창 실행 간격을 시스템 부하에 맞춰 자동 조절합니다. 기본 true.</summary>
|
||||
[JsonPropertyName("enableAdaptiveWorkspaceRestoreThrottle")]
|
||||
public bool EnableAdaptiveWorkspaceRestoreThrottle { get; set; } = true;
|
||||
|
||||
/// <summary>워크스페이스 복원 시 새 창 실행 사이의 기본 대기 시간(ms). 기본 250.</summary>
|
||||
[JsonPropertyName("workspaceRestoreBaseDelayMs")]
|
||||
public int WorkspaceRestoreBaseDelayMs { get; set; } = 250;
|
||||
|
||||
/// <summary>워크스페이스 복원 시 새 창 실행 사이의 최대 대기 시간(ms). 기본 1200.</summary>
|
||||
[JsonPropertyName("workspaceRestoreMaxDelayMs")]
|
||||
public int WorkspaceRestoreMaxDelayMs { get; set; } = 1200;
|
||||
|
||||
/// <summary>단축키 헬프 창에서 아이콘 색상을 테마 AccentColor 기준으로 표시. 기본 true(테마색).</summary>
|
||||
[JsonPropertyName("shortcutHelpUseThemeColor")]
|
||||
public bool ShortcutHelpUseThemeColor { get; set; } = true;
|
||||
@@ -402,6 +414,9 @@ public class WindowSnapshot
|
||||
|
||||
[JsonPropertyName("browser")]
|
||||
public BrowserWindowState? Browser { get; set; }
|
||||
|
||||
[JsonPropertyName("appState")]
|
||||
public AppWindowState? AppState { get; set; }
|
||||
}
|
||||
|
||||
public class BrowserWindowState
|
||||
@@ -425,6 +440,21 @@ public class BrowserWindowState
|
||||
public List<string> TabUrls { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AppWindowState
|
||||
{
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("primaryPath")]
|
||||
public string? PrimaryPath { get; set; }
|
||||
|
||||
[JsonPropertyName("activePathIndex")]
|
||||
public int ActivePathIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("paths")]
|
||||
public List<string> Paths { get; set; } = new();
|
||||
}
|
||||
|
||||
public class WindowRect
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
|
||||
@@ -182,6 +182,9 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
private bool _showWidgetBattery;
|
||||
private bool _showLauncherBottomQuickActions;
|
||||
private bool _enableBrowserSessionRestore;
|
||||
private bool _enableAdaptiveWorkspaceRestoreThrottle;
|
||||
private int _workspaceRestoreBaseDelayMs = 250;
|
||||
private int _workspaceRestoreMaxDelayMs = 1200;
|
||||
private bool _shortcutHelpUseThemeColor;
|
||||
|
||||
// LLM 공통 설정
|
||||
@@ -955,6 +958,49 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
set { _enableBrowserSessionRestore = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public bool EnableAdaptiveWorkspaceRestoreThrottle
|
||||
{
|
||||
get => _enableAdaptiveWorkspaceRestoreThrottle;
|
||||
set { _enableAdaptiveWorkspaceRestoreThrottle = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
public int WorkspaceRestoreBaseDelayMs
|
||||
{
|
||||
get => _workspaceRestoreBaseDelayMs;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 3000);
|
||||
if (_workspaceRestoreBaseDelayMs == clamped)
|
||||
return;
|
||||
|
||||
_workspaceRestoreBaseDelayMs = clamped;
|
||||
if (_workspaceRestoreMaxDelayMs < clamped)
|
||||
{
|
||||
_workspaceRestoreMaxDelayMs = clamped;
|
||||
OnPropertyChanged(nameof(WorkspaceRestoreMaxDelayMs));
|
||||
}
|
||||
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public int WorkspaceRestoreMaxDelayMs
|
||||
{
|
||||
get => _workspaceRestoreMaxDelayMs;
|
||||
set
|
||||
{
|
||||
var clamped = Math.Clamp(value, 0, 5000);
|
||||
if (clamped < _workspaceRestoreBaseDelayMs)
|
||||
clamped = _workspaceRestoreBaseDelayMs;
|
||||
|
||||
if (_workspaceRestoreMaxDelayMs == clamped)
|
||||
return;
|
||||
|
||||
_workspaceRestoreMaxDelayMs = clamped;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool EnableIconAnimation
|
||||
{
|
||||
get => _enableIconAnimation;
|
||||
@@ -1253,6 +1299,9 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
_showWidgetBattery = s.Launcher.ShowWidgetBattery;
|
||||
_showLauncherBottomQuickActions = s.Launcher.ShowLauncherBottomQuickActions;
|
||||
_enableBrowserSessionRestore = s.Launcher.EnableBrowserSessionRestore;
|
||||
_enableAdaptiveWorkspaceRestoreThrottle = s.Launcher.EnableAdaptiveWorkspaceRestoreThrottle;
|
||||
_workspaceRestoreBaseDelayMs = s.Launcher.WorkspaceRestoreBaseDelayMs;
|
||||
_workspaceRestoreMaxDelayMs = Math.Max(_workspaceRestoreBaseDelayMs, s.Launcher.WorkspaceRestoreMaxDelayMs);
|
||||
_shortcutHelpUseThemeColor = s.Launcher.ShortcutHelpUseThemeColor;
|
||||
_enableTextAction = s.Launcher.EnableTextAction;
|
||||
// v1.7.1: 파일 대화상자 통합 기본값을 false로 변경 (브라우저 업로드 오작동 방지)
|
||||
@@ -1729,6 +1778,9 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
s.Launcher.ShowWidgetBattery = _showWidgetBattery;
|
||||
s.Launcher.ShowLauncherBottomQuickActions = _showLauncherBottomQuickActions;
|
||||
s.Launcher.EnableBrowserSessionRestore = _enableBrowserSessionRestore;
|
||||
s.Launcher.EnableAdaptiveWorkspaceRestoreThrottle = _enableAdaptiveWorkspaceRestoreThrottle;
|
||||
s.Launcher.WorkspaceRestoreBaseDelayMs = _workspaceRestoreBaseDelayMs;
|
||||
s.Launcher.WorkspaceRestoreMaxDelayMs = Math.Max(_workspaceRestoreBaseDelayMs, _workspaceRestoreMaxDelayMs);
|
||||
s.Launcher.ShortcutHelpUseThemeColor = _shortcutHelpUseThemeColor;
|
||||
s.Launcher.EnableTextAction = _enableTextAction;
|
||||
s.Launcher.EnableFileDialogIntegration = _enableFileDialogIntegration;
|
||||
|
||||
@@ -3076,6 +3076,75 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<StackPanel HorizontalAlignment="Left">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="복원 속도 자동 조절"/>
|
||||
<TextBlock Style="{StaticResource RowHint}"
|
||||
Text="워크스페이스 복원 시 CPU·메모리 상태를 보고 다음 창 실행 간격을 늘리거나 줄입니다. 느린 PC에서 한꺼번에 창이 뜨며 버벅이는 상황을 줄이는 용도입니다."/>
|
||||
</StackPanel>
|
||||
<CheckBox Style="{StaticResource ToggleSwitch}"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EnableAdaptiveWorkspaceRestoreThrottle, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="140"/>
|
||||
<ColumnDefinition Width="54"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Left" Margin="0,0,18,0">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="복원 기본 간격"/>
|
||||
<TextBlock Style="{StaticResource RowHint}"
|
||||
Text="새 창을 하나 띄운 뒤 다음 창을 실행하기 전 최소 대기 시간입니다."/>
|
||||
</StackPanel>
|
||||
<Slider Grid.Column="1"
|
||||
Minimum="0" Maximum="1000"
|
||||
TickFrequency="50" IsSnapToTickEnabled="True"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding WorkspaceRestoreBaseDelayMs, Mode=TwoWay}"/>
|
||||
<Border Grid.Column="2" Width="64" Height="28" CornerRadius="6"
|
||||
Background="{DynamicResource ItemHoverBackground}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding WorkspaceRestoreBaseDelayMs, StringFormat={}{0}ms}"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="140"/>
|
||||
<ColumnDefinition Width="54"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" HorizontalAlignment="Left" Margin="0,0,18,0">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="복원 최대 간격"/>
|
||||
<TextBlock Style="{StaticResource RowHint}"
|
||||
Text="시스템 부하가 높을 때 늘어날 수 있는 최대 대기 시간입니다."/>
|
||||
</StackPanel>
|
||||
<Slider Grid.Column="1"
|
||||
Minimum="200" Maximum="3000"
|
||||
TickFrequency="100" IsSnapToTickEnabled="True"
|
||||
VerticalAlignment="Center"
|
||||
Value="{Binding WorkspaceRestoreMaxDelayMs, Mode=TwoWay}"/>
|
||||
<Border Grid.Column="2" Width="64" Height="28" CornerRadius="6"
|
||||
Background="{DynamicResource ItemHoverBackground}"
|
||||
VerticalAlignment="Center" HorizontalAlignment="Right">
|
||||
<TextBlock Text="{Binding WorkspaceRestoreMaxDelayMs, StringFormat={}{0}ms}"
|
||||
FontSize="12" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AccentColor}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── 기록 기능 ── -->
|
||||
<TextBlock Style="{StaticResource SectionHeader}" Text="기록 기능"/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user