diff --git a/README.md b/README.md index d073a94..3712991 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ |---|---| | 上段 | 起動中ウィンドウ一覧(チェックして保存対象を選択) | | 中段 | プロファイル名入力 → 「チェックを保存」 | -| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF) | +| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF、名前ダブルクリックでリネーム) | | ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 | | 設定 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先変更 | @@ -91,6 +91,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ }, "profiles": [ { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "name": "作業用レイアウト", "syncMinMax": 0, "createdAt": "2026-01-01T20:20:20", @@ -117,6 +118,8 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ } ``` +> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合、起動時に自動で付与されます。 + ## ブラウザ URL 取得 | ブラウザ | 方式 | 備考 | diff --git a/src/WindowController.App/MainWindow.xaml b/src/WindowController.App/MainWindow.xaml index a57b05b..0b70112 100644 --- a/src/WindowController.App/MainWindow.xaml +++ b/src/WindowController.App/MainWindow.xaml @@ -179,7 +179,7 @@ - + diff --git a/src/WindowController.App/SyncManager.cs b/src/WindowController.App/SyncManager.cs index 3f94009..726407a 100644 --- a/src/WindowController.App/SyncManager.cs +++ b/src/WindowController.App/SyncManager.cs @@ -15,8 +15,10 @@ public class SyncManager : IDisposable private readonly WinEventHookManager _hookManager; private readonly ILogger _log; - // profileName -> set of hwnds + // profileId -> set of hwnds private Dictionary> _syncGroups = new(); + // profileId -> display name (for logging) + private Dictionary _profileNames = new(); private Dictionary _lastMinMaxByHwnd = new(); private Dictionary _lastForegroundByProfile = new(); private Dictionary _lastForegroundTickByProfile = new(); @@ -52,6 +54,8 @@ public void RebuildGroups() var candidates = GetCandidatesLightweight(); var newGroups = new Dictionary>(); + var newNames = new Dictionary(); + foreach (var profile in _store.Data.Profiles) { if (profile.SyncMinMax == 0) continue; @@ -64,10 +68,14 @@ public void RebuildGroups() group.Add(match.Hwnd); } if (group.Count > 0) - newGroups[profile.Name] = group; + { + newGroups[profile.Id] = group; + newNames[profile.Id] = profile.Name; + } } _syncGroups = newGroups; + _profileNames = newNames; _lastRebuildTick = Environment.TickCount64; } @@ -110,6 +118,7 @@ public void UpdateHooksIfNeeded(bool skipRebuild = false) { _hookManager.Uninstall(); _syncGroups.Clear(); + _profileNames.Clear(); _lastMinMaxByHwnd.Clear(); } } @@ -158,8 +167,8 @@ private void OnWinEvent(uint eventType, nint hwnd) _isPropagating = true; try { - foreach (var (name, group) in groups) - PropagateMinMax(name, group, hwnd, mm); + foreach (var (id, group) in groups) + PropagateMinMax(id, group, hwnd, mm); } finally { @@ -191,8 +200,8 @@ private void OnForegroundEvent(nint hwnd) _isPropagating = true; try { - foreach (var (name, group) in groups) - PropagateForeground(name, group, hwnd); + foreach (var (id, group) in groups) + PropagateForeground(id, group, hwnd); } finally { @@ -206,7 +215,7 @@ private void OnForegroundEvent(nint hwnd) } } - private void PropagateMinMax(string profileName, HashSet group, nint sourceHwnd, int mm) + private void PropagateMinMax(string profileId, HashSet group, nint sourceHwnd, int mm) { int count = 0; foreach (var target in group) @@ -236,20 +245,23 @@ private void PropagateMinMax(string profileName, HashSet group, nint sourc catch (Exception ex) { _log.Debug(ex, "PropagateMinMax failed for target {Target}", target); } } if (count > 0) - _log.Information("Sync propagated within profile '{Name}' to {Count} window(s)", profileName, count); + { + var name = _profileNames.GetValueOrDefault(profileId, profileId); + _log.Information("Sync propagated within profile '{Name}' to {Count} window(s)", name, count); + } } - private void PropagateForeground(string profileName, HashSet group, nint sourceHwnd) + private void PropagateForeground(string profileId, HashSet group, nint sourceHwnd) { var now = Environment.TickCount64; - if (_lastForegroundTickByProfile.TryGetValue(profileName, out var lastTick) && + if (_lastForegroundTickByProfile.TryGetValue(profileId, out var lastTick) && now - lastTick < 250 && - _lastForegroundByProfile.TryGetValue(profileName, out var lastHwnd) && + _lastForegroundByProfile.TryGetValue(profileId, out var lastHwnd) && lastHwnd == sourceHwnd) return; - _lastForegroundTickByProfile[profileName] = now; - _lastForegroundByProfile[profileName] = sourceHwnd; + _lastForegroundTickByProfile[profileId] = now; + _lastForegroundByProfile[profileId] = sourceHwnd; int count = 0; foreach (var target in group) @@ -267,16 +279,19 @@ private void PropagateForeground(string profileName, HashSet group, nint s catch (Exception ex) { _log.Debug(ex, "PropagateForeground failed for target {Target}", target); } } if (count > 0) - _log.Information("Foreground sync within profile '{Name}' to {Count} window(s)", profileName, count); + { + var name = _profileNames.GetValueOrDefault(profileId, profileId); + _log.Information("Foreground sync within profile '{Name}' to {Count} window(s)", name, count); + } } - private List<(string Name, HashSet Group)> GetGroupsContainingHwnd(nint hwnd) + private List<(string Id, HashSet Group)> GetGroupsContainingHwnd(nint hwnd) { var result = new List<(string, HashSet)>(); - foreach (var (name, group) in _syncGroups) + foreach (var (id, group) in _syncGroups) { if (group.Contains(hwnd)) - result.Add((name, group)); + result.Add((id, group)); } return result; } diff --git a/src/WindowController.App/ViewModels/MainViewModel.cs b/src/WindowController.App/ViewModels/MainViewModel.cs index 71469d9..cea74d8 100644 --- a/src/WindowController.App/ViewModels/MainViewModel.cs +++ b/src/WindowController.App/ViewModels/MainViewModel.cs @@ -31,7 +31,8 @@ public partial class WindowItem : ObservableObject public partial class ProfileItem : ObservableObject { [ObservableProperty] private bool _syncMinMax; - public string Name { get; init; } = ""; + [ObservableProperty] private string _name = ""; + public string Id { get; init; } = ""; public int WindowCount { get; init; } } @@ -44,6 +45,7 @@ public partial class MainViewModel : ObservableObject private readonly SyncManager _syncManager; private readonly AppSettingsStore _appSettings; private readonly ILogger _log; + private bool _isUpdatingProfileName; [ObservableProperty] private string _statusText = ""; [ObservableProperty] private string _profileName = ""; @@ -143,6 +145,7 @@ private void SaveProfile() var profile = existing ?? new Profile { + Id = Guid.NewGuid().ToString("D"), Name = name, CreatedAt = now, SyncMinMax = 0 @@ -239,7 +242,7 @@ private async Task ApplyProfile() StatusText = "プロファイルを選択してください。"; return; } - await DoApplyAsync(SelectedProfile.Name, false); + await DoApplyAsync(SelectedProfile.Id, false); } [RelayCommand] @@ -250,20 +253,22 @@ private async Task LaunchAndApplyProfile() StatusText = "プロファイルを選択してください。"; return; } - await DoApplyAsync(SelectedProfile.Name, true); + await DoApplyAsync(SelectedProfile.Id, true); } - private async Task DoApplyAsync(string profileName, bool launchMissing) + private async Task DoApplyAsync(string profileId, bool launchMissing) { try { - var profile = _store.FindByName(profileName); + var profile = _store.FindById(profileId); if (profile == null) { - StatusText = $"プロファイルが見つかりません: {profileName}"; + StatusText = $"プロファイルが見つかりません"; return; } + var profileName = profile.Name; + var candidates = GetCandidates(); int applied = 0; var failures = new List(); @@ -405,7 +410,7 @@ private void DeleteProfile() return; } - if (_store.DeleteProfile(name)) + if (_store.DeleteProfileById(SelectedProfile.Id)) { ReloadProfiles(); _syncManager.ScheduleRebuild(); @@ -424,29 +429,94 @@ public void ReloadProfiles() { var item = new ProfileItem { + Id = p.Id, Name = p.Name, SyncMinMax = p.SyncMinMax != 0, WindowCount = p.Windows.Count }; item.PropertyChanged += (s, e) => { - if (e.PropertyName == nameof(ProfileItem.SyncMinMax) && s is ProfileItem pi) + if (s is not ProfileItem pi) return; + if (e.PropertyName == nameof(ProfileItem.SyncMinMax)) OnProfileSyncChanged(pi); + else if (e.PropertyName == nameof(ProfileItem.Name)) + OnProfileNameChanged(pi); }; Profiles.Add(item); } } + private void OnProfileNameChanged(ProfileItem pi) + { + // Prevent re-entrancy when we programmatically update pi.Name + if (_isUpdatingProfileName) + return; + + // Get the current profile from the store + var current = _store.FindById(pi.Id); + if (current == null) + { + StatusText = "プロファイルが見つかりません。"; + return; + } + + // If the name has not actually changed compared to the store, ignore. + if (string.Equals(current.Name, pi.Name, StringComparison.Ordinal)) + return; + + + var newName = pi.Name?.Trim(); + if (string.IsNullOrEmpty(newName)) + { + // Revert to current stored name + _isUpdatingProfileName = true; + try + { + pi.Name = current.Name; + } + finally + { + _isUpdatingProfileName = false; + } + StatusText = "プロファイル名を空にすることはできません。"; + return; + } + + var finalName = _store.RenameProfile(pi.Id, newName); + if (finalName == null) + { + StatusText = "プロファイルが見つかりません。"; + return; + } + + // If the name was adjusted due to conflict, update the UI + if (finalName != newName) + { + _isUpdatingProfileName = true; + try + { + pi.Name = finalName; + } + finally + { + _isUpdatingProfileName = false; + } + } + + _syncManager.ScheduleRebuild(); + StatusText = $"名前を変更しました: {finalName}"; + } + private void OnProfileSyncChanged(ProfileItem pi) { - var profile = _store.FindByName(pi.Name); + var profile = _store.FindById(pi.Id); if (profile != null) { profile.SyncMinMax = pi.SyncMinMax ? 1 : 0; _store.SaveProfile(profile); // UpdateHooksIfNeeded already schedules a rebuild internally _syncManager.UpdateHooksIfNeeded(); - StatusText = $"連動設定({pi.Name}): {(pi.SyncMinMax ? "ON" : "OFF")}"; + StatusText = $"連動設定({profile.Name}): {(pi.SyncMinMax ? "ON" : "OFF")}"; } } diff --git a/src/WindowController.Core.Tests/ProfileStoreTests.cs b/src/WindowController.Core.Tests/ProfileStoreTests.cs new file mode 100644 index 0000000..0076f21 --- /dev/null +++ b/src/WindowController.Core.Tests/ProfileStoreTests.cs @@ -0,0 +1,259 @@ +using WindowController.Core; +using WindowController.Core.Models; +using Serilog; + +namespace WindowController.Core.Tests; + +public class ProfileStoreTests : IDisposable +{ + private readonly string _tempDir; + private readonly ILogger _log = new LoggerConfiguration().CreateLogger(); + + public ProfileStoreTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "WC_Tests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try + { + Directory.Delete(_tempDir, true); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to delete temporary test directory {TempDir}", _tempDir); + } + } + + private string TempFile() => Path.Combine(_tempDir, "profiles.json"); + + private ProfileStore CreateStore(string? json = null) + { + var path = TempFile(); + if (json != null) + File.WriteAllText(path, json); + var store = new ProfileStore(path, _log); + store.Load(); + return store; + } + + // ── ID migration ── + + [Fact] + public void Load_AssignsId_WhenMissing() + { + var json = """ + { + "version": 1, + "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 }, + "profiles": [ + { "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] }, + { "name": "B", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] } + ] + } + """; + var store = CreateStore(json); + + Assert.All(store.Data.Profiles, p => Assert.False(string.IsNullOrEmpty(p.Id))); + // Each Id should be unique + var ids = store.Data.Profiles.Select(p => p.Id).ToList(); + Assert.Equal(ids.Count, ids.Distinct().Count()); + } + + [Fact] + public void Load_ReassignsDuplicateIds() + { + var json = """ + { + "version": 1, + "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 }, + "profiles": [ + { "id": "same-id", "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] }, + { "id": "same-id", "name": "B", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] } + ] + } + """; + var store = CreateStore(json); + + var ids = store.Data.Profiles.Select(p => p.Id).ToList(); + Assert.Equal(2, ids.Distinct().Count()); + } + + [Fact] + public void Load_PreservesExistingValidIds() + { + var existingId = Guid.NewGuid().ToString("D"); + var json = $$""" + { + "version": 1, + "settings": { "syncMinMax": 0, "showGuiOnStartup": 1 }, + "profiles": [ + { "id": "{{existingId}}", "name": "A", "syncMinMax": 0, "createdAt": "", "updatedAt": "", "windows": [] } + ] + } + """; + var store = CreateStore(json); + + Assert.Equal(existingId, store.Data.Profiles[0].Id); + } + + // ── FindById ── + + [Fact] + public void FindById_ReturnsProfile() + { + var store = CreateStore(); + var profile = new Profile { Id = "test-id", Name = "Test", CreatedAt = "", UpdatedAt = "" }; + store.SaveProfile(profile); + + var found = store.FindById("test-id"); + Assert.NotNull(found); + Assert.Equal("Test", found.Name); + } + + [Fact] + public void FindById_ReturnsNull_WhenNotFound() + { + var store = CreateStore(); + Assert.Null(store.FindById("nonexistent")); + } + + // ── SaveProfile (Id-based) ── + + [Fact] + public void SaveProfile_UpdatesById_WhenIdMatches() + { + var store = CreateStore(); + var profile = new Profile { Id = "id-1", Name = "Original", CreatedAt = "", UpdatedAt = "" }; + store.SaveProfile(profile); + + var updated = new Profile { Id = "id-1", Name = "Renamed", CreatedAt = "", UpdatedAt = "" }; + store.SaveProfile(updated); + + Assert.Single(store.Data.Profiles); + Assert.Equal("Renamed", store.Data.Profiles[0].Name); + } + + // ── DeleteProfileById ── + + [Fact] + public void DeleteProfileById_RemovesCorrectProfile() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "A", CreatedAt = "", UpdatedAt = "" }); + store.SaveProfile(new Profile { Id = "id-2", Name = "B", CreatedAt = "", UpdatedAt = "" }); + + Assert.True(store.DeleteProfileById("id-1")); + Assert.Single(store.Data.Profiles); + Assert.Equal("B", store.Data.Profiles[0].Name); + } + + [Fact] + public void DeleteProfileById_ReturnsFalse_WhenNotFound() + { + var store = CreateStore(); + Assert.False(store.DeleteProfileById("nonexistent")); + } + + // ── RenameProfile ── + + [Fact] + public void RenameProfile_ChangesName() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Old", CreatedAt = "", UpdatedAt = "" }); + + var result = store.RenameProfile("id-1", "New"); + + Assert.Equal("New", result); + Assert.Equal("New", store.FindById("id-1")!.Name); + } + + [Fact] + public void RenameProfile_ReturnsNull_WhenNotFound() + { + var store = CreateStore(); + Assert.Null(store.RenameProfile("nonexistent", "Name")); + } + + [Fact] + public void RenameProfile_AutoSuffix_WhenNameConflicts() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" }); + store.SaveProfile(new Profile { Id = "id-2", Name = "Other", CreatedAt = "", UpdatedAt = "" }); + + var result = store.RenameProfile("id-2", "Layout"); + + Assert.Equal("Layout (2)", result); + Assert.Equal("Layout (2)", store.FindById("id-2")!.Name); + // Original is untouched + Assert.Equal("Layout", store.FindById("id-1")!.Name); + } + + [Fact] + public void RenameProfile_AutoSuffix_IncrementsWhenMultipleConflicts() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" }); + store.SaveProfile(new Profile { Id = "id-2", Name = "Layout (2)", CreatedAt = "", UpdatedAt = "" }); + store.SaveProfile(new Profile { Id = "id-3", Name = "Other", CreatedAt = "", UpdatedAt = "" }); + + var result = store.RenameProfile("id-3", "Layout"); + + Assert.Equal("Layout (3)", result); + } + + [Fact] + public void RenameProfile_NoSuffix_WhenSameProfileKeepsSameName() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Layout", CreatedAt = "", UpdatedAt = "" }); + + // Renaming to the same name should not add suffix + var result = store.RenameProfile("id-1", "Layout"); + + Assert.Equal("Layout", result); + } + + [Fact] + public void RenameProfile_UpdatesTimestamp() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Old", CreatedAt = "2020-01-01T00:00:00", UpdatedAt = "2020-01-01T00:00:00" }); + + store.RenameProfile("id-1", "New"); + + var profile = store.FindById("id-1")!; + Assert.NotEqual("2020-01-01T00:00:00", profile.UpdatedAt); + } + + // ── ResolveUniqueName ── + + [Fact] + public void ResolveUniqueName_ReturnsOriginal_WhenNoConflict() + { + var store = CreateStore(); + Assert.Equal("Unique", store.ResolveUniqueName("Unique")); + } + + [Fact] + public void ResolveUniqueName_AddsSuffix_WhenConflicts() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Name", CreatedAt = "", UpdatedAt = "" }); + + Assert.Equal("Name (2)", store.ResolveUniqueName("Name")); + } + + [Fact] + public void ResolveUniqueName_ExcludesOwnId() + { + var store = CreateStore(); + store.SaveProfile(new Profile { Id = "id-1", Name = "Name", CreatedAt = "", UpdatedAt = "" }); + + Assert.Equal("Name", store.ResolveUniqueName("Name", "id-1")); + } +} diff --git a/src/WindowController.Core/Models/Profile.cs b/src/WindowController.Core/Models/Profile.cs index 0dde5ab..5c4d767 100644 --- a/src/WindowController.Core/Models/Profile.cs +++ b/src/WindowController.Core/Models/Profile.cs @@ -4,6 +4,9 @@ namespace WindowController.Core.Models; public class Profile { + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + [JsonPropertyName("name")] public string Name { get; set; } = ""; diff --git a/src/WindowController.Core/ProfileStore.cs b/src/WindowController.Core/ProfileStore.cs index 0840502..05224e9 100644 --- a/src/WindowController.Core/ProfileStore.cs +++ b/src/WindowController.Core/ProfileStore.cs @@ -62,7 +62,17 @@ public void Load() { var json = File.ReadAllText(_filePath); var parsed = JsonSerializer.Deserialize(json, JsonOptions); - Data = NormalizeData(parsed ?? new ProfilesRoot()); + + // Detect migration need before normalization + bool needsMigration = parsed != null && parsed.Profiles.Any(p => + string.IsNullOrEmpty(p.Id) || + parsed.Profiles.Count(x => x.Id == p.Id) > 1); + + var normalized = NormalizeData(parsed ?? new ProfilesRoot()); + Data = normalized; + + if (needsMigration) + Save(); } catch (Exception ex) { @@ -101,9 +111,15 @@ public void Save() public Profile? FindByName(string name) => Data.Profiles.FirstOrDefault(p => p.Name == name); + public Profile? FindById(string id) + => Data.Profiles.FirstOrDefault(p => p.Id == id); + public void SaveProfile(Profile profile) { - var idx = Data.Profiles.FindIndex(p => p.Name == profile.Name); + // Prefer Id-based lookup when the profile has a valid Id + var idx = !string.IsNullOrEmpty(profile.Id) + ? Data.Profiles.FindIndex(p => p.Id == profile.Id) + : Data.Profiles.FindIndex(p => p.Name == profile.Name); if (idx >= 0) Data.Profiles[idx] = profile; else @@ -119,6 +135,53 @@ public bool DeleteProfile(string name) return removed; } + public bool DeleteProfileById(string id) + { + var removed = Data.Profiles.RemoveAll(p => p.Id == id) > 0; + if (removed) + Save(); + return removed; + } + + /// + /// Rename a profile identified by . + /// If conflicts with another profile, + /// a numeric suffix like "(2)" is appended automatically. + /// Returns the final (possibly adjusted) name, or null when the profile was not found. + /// + public string? RenameProfile(string profileId, string desiredName) + { + var profile = FindById(profileId); + if (profile == null) return null; + + var trimmed = desiredName.Trim(); + var finalName = ResolveUniqueName(trimmed, profileId); + + // Skip save if the name hasn't actually changed + if (finalName == profile.Name) + return finalName; + + profile.Name = finalName; + profile.UpdatedAt = DateTime.Now.ToString("yyyy-MM-dd'T'HH:mm:ss"); + Save(); + return finalName; + } + + /// + /// Returns a name that does not collide with existing profiles (excluding the one with ). + /// + internal string ResolveUniqueName(string desiredName, string? excludeId = null) + { + var candidate = desiredName; + int suffix = 2; + while (Data.Profiles.Any(p => p.Name == candidate && p.Id != excludeId)) + { + candidate = $"{desiredName} ({suffix})"; + suffix++; + } + return candidate; + } + private static ProfilesRoot CreateDefault() => new() { Version = 1, @@ -130,6 +193,18 @@ private static ProfilesRoot NormalizeData(ProfilesRoot root) { root.Version = root.Version == 0 ? 1 : root.Version; + // Assign stable Ids to profiles that lack one (migration from older schema) + var seenIds = new HashSet(); + foreach (var p in root.Profiles) + { + if (string.IsNullOrEmpty(p.Id) || !seenIds.Add(p.Id)) + { + // Generate a new unique Id (duplicate Ids are also re-assigned) + p.Id = Guid.NewGuid().ToString("D"); + seenIds.Add(p.Id); + } + } + foreach (var p in root.Profiles) { foreach (var w in p.Windows) diff --git a/src/WindowController.Core/WindowController.Core.csproj b/src/WindowController.Core/WindowController.Core.csproj index 34840bf..74aa857 100644 --- a/src/WindowController.Core/WindowController.Core.csproj +++ b/src/WindowController.Core/WindowController.Core.csproj @@ -8,6 +8,10 @@ embedded + + + +