Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
|---|---|
| 上段 | 起動中ウィンドウ一覧(チェックして保存対象を選択) |
| 中段 | プロファイル名入力 → 「チェックを保存」 |
| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF) |
| 下段 | 保存済みプロファイル一覧(チェック=連動 ON/OFF、名前ダブルクリックでリネーム) |
| ボタン | 適用(配置のみ)/ 一括起動+配置 / 削除 |
| 設定 | 連動機能全体 ON/OFF、起動時 GUI 表示、profiles.json 保存先変更 |

Expand All @@ -91,6 +91,7 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
},
"profiles": [
{
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "作業用レイアウト",
"syncMinMax": 0,
"createdAt": "2026-01-01T20:20:20",
Expand All @@ -117,6 +118,8 @@ GUI で起動中のウィンドウを一覧表示し、チェックしたウィ
}
```

> `id` はプロファイルの内部識別子(UUID)です。旧バージョンで作成された `profiles.json` に `id` がない場合、起動時に自動で付与されます。

## ブラウザ URL 取得

| ブラウザ | 方式 | 備考 |
Expand Down
2 changes: 1 addition & 1 deletion src/WindowController.App/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn Header="Profile" Binding="{Binding Name}" Width="*" IsReadOnly="True"/>
<DataGridTextColumn Header="Profile" Binding="{Binding Name, UpdateSourceTrigger=LostFocus}" Width="*"/>
<DataGridTextColumn Header="Windows" Binding="{Binding WindowCount}" Width="80" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
Expand Down
49 changes: 32 additions & 17 deletions src/WindowController.App/SyncManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, HashSet<nint>> _syncGroups = new();
// profileId -> display name (for logging)
private Dictionary<string, string> _profileNames = new();
private Dictionary<nint, int> _lastMinMaxByHwnd = new();
Comment on lines +18 to 22
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When sync is disabled in UpdateHooksIfNeeded, _syncGroups and _lastMinMaxByHwnd are cleared but _profileNames is left as-is. This can leave stale name mappings around longer than needed; consider clearing _profileNames alongside _syncGroups for consistency.

Copilot uses AI. Check for mistakes.
private Dictionary<string, nint> _lastForegroundByProfile = new();
private Dictionary<string, long> _lastForegroundTickByProfile = new();
Expand Down Expand Up @@ -52,6 +54,8 @@ public void RebuildGroups()
var candidates = GetCandidatesLightweight();
var newGroups = new Dictionary<string, HashSet<nint>>();

var newNames = new Dictionary<string, string>();

foreach (var profile in _store.Data.Profiles)
{
if (profile.SyncMinMax == 0) continue;
Expand All @@ -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;
}

Expand Down Expand Up @@ -110,6 +118,7 @@ public void UpdateHooksIfNeeded(bool skipRebuild = false)
{
_hookManager.Uninstall();
_syncGroups.Clear();
_profileNames.Clear();
_lastMinMaxByHwnd.Clear();
}
}
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
{
Expand All @@ -206,7 +215,7 @@ private void OnForegroundEvent(nint hwnd)
}
}

private void PropagateMinMax(string profileName, HashSet<nint> group, nint sourceHwnd, int mm)
private void PropagateMinMax(string profileId, HashSet<nint> group, nint sourceHwnd, int mm)
{
int count = 0;
foreach (var target in group)
Expand Down Expand Up @@ -236,20 +245,23 @@ private void PropagateMinMax(string profileName, HashSet<nint> 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<nint> group, nint sourceHwnd)
private void PropagateForeground(string profileId, HashSet<nint> 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)
Expand All @@ -267,16 +279,19 @@ private void PropagateForeground(string profileName, HashSet<nint> 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<nint> Group)> GetGroupsContainingHwnd(nint hwnd)
private List<(string Id, HashSet<nint> Group)> GetGroupsContainingHwnd(nint hwnd)
{
var result = new List<(string, HashSet<nint>)>();
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;
}
Expand Down
90 changes: 80 additions & 10 deletions src/WindowController.App/ViewModels/MainViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field '_name' can be 'readonly'.

Copilot uses AI. Check for mistakes.
public string Id { get; init; } = "";
public int WindowCount { get; init; }
}

Expand All @@ -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 = "";
Expand Down Expand Up @@ -143,6 +145,7 @@ private void SaveProfile()

var profile = existing ?? new Profile
{
Id = Guid.NewGuid().ToString("D"),
Name = name,
CreatedAt = now,
SyncMinMax = 0
Expand Down Expand Up @@ -239,7 +242,7 @@ private async Task ApplyProfile()
StatusText = "プロファイルを選択してください。";
return;
}
await DoApplyAsync(SelectedProfile.Name, false);
await DoApplyAsync(SelectedProfile.Id, false);
}

[RelayCommand]
Expand All @@ -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<string>();
Expand Down Expand Up @@ -405,7 +410,7 @@ private void DeleteProfile()
return;
}

if (_store.DeleteProfile(name))
if (_store.DeleteProfileById(SelectedProfile.Id))
{
ReloadProfiles();
_syncManager.ScheduleRebuild();
Expand All @@ -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;
Comment on lines 468 to 475
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting pi.Name = current.Name inside the PropertyChanged handler will trigger PropertyChanged again and re-enter OnProfileNameChanged, causing an extra rename/save/timestamp update (and potentially repeated status updates). Consider suppressing re-entrancy (e.g., a guard flag) or temporarily detaching the handler while reverting the value.

Suggested change
var newName = pi.Name?.Trim();
if (string.IsNullOrEmpty(newName))
{
// Revert to current stored name
var current = _store.FindById(pi.Id);
if (current != null)
pi.Name = current.Name;
// 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))
{
return;
}
var newName = pi.Name?.Trim();
if (string.IsNullOrEmpty(newName))
{
// Revert to current stored name
pi.Name = current.Name;

Copilot uses AI. Check for mistakes.
}
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}";
Comment on lines 492 to 507
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When finalName != newName, assigning pi.Name = finalName will re-trigger OnProfileNameChanged and call _store.RenameProfile a second time, resulting in duplicate saves and timestamp changes. Add a re-entrancy guard or a check against the store's current name before calling rename/setting the property.

Copilot uses AI. Check for mistakes.
}

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")}";
}
}

Expand Down
Loading