From 90e051c960c1a81dfa2f589b69ddf7464147aa12 Mon Sep 17 00:00:00 2001 From: GiantForestStudio <119655663+GiantForestStudio@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:37:35 +0900 Subject: [PATCH 1/2] Redesign account manager and hover details with combined weekly usage --- PRIVACY.md | 4 +- README.md | 16 +- docs/combined-usage-delivery.md | 24 ++ src/AccountManagerForm.CombinedUsage.cs | 108 ++++++ src/AccountManagerForm.cs | 358 +++++++----------- src/AccountTable.cs | 104 +++++ src/AccountUiTheme.cs | 77 +--- src/CombinedUsagePanel.cs | 64 ++++ src/CombinedUsageSnapshot.cs | 66 ++++ src/Program.cs | 39 +- src/UsageToolTip.cs | 48 +++ src/UsageTooltipText.cs | 64 ++++ src/WeeklyUsageIndicator.csproj | 2 +- .../AccountUiSmoke.cs | 30 +- .../AccountUsageUiSmoke.cs | 14 +- .../CombinedUsageTests.cs | 43 +++ .../CombinedUsageUiSmoke.cs | 159 ++++++++ .../LiveAccountUsageSmoke.cs | 6 +- .../LiveCombinedUsageSmoke.cs | 63 +++ .../LiveUsageCleanupSmoke.cs | 2 +- tests/WeeklyUsageIndicator.Tests/Program.cs | 10 + .../UsageTooltipTests.cs | 32 ++ .../UsageTooltipUiSmoke.cs | 80 ++++ 23 files changed, 1077 insertions(+), 336 deletions(-) create mode 100644 docs/combined-usage-delivery.md create mode 100644 src/AccountManagerForm.CombinedUsage.cs create mode 100644 src/AccountTable.cs create mode 100644 src/CombinedUsagePanel.cs create mode 100644 src/CombinedUsageSnapshot.cs create mode 100644 src/UsageToolTip.cs create mode 100644 src/UsageTooltipText.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/CombinedUsageTests.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/CombinedUsageUiSmoke.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/LiveCombinedUsageSmoke.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/UsageTooltipTests.cs create mode 100644 tests/WeeklyUsageIndicator.Tests/UsageTooltipUiSmoke.cs diff --git a/PRIVACY.md b/PRIVACY.md index ceb5747..e0e34de 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -18,6 +18,8 @@ Codex Desktop, its local app-server, Claude Code, and Anthropic's API remain gov Optional Codex account management reads local Codex `auth.json` for explicit registration, switching, and identity checks while refreshing registered-account usage. Replacement happens only on an explicit switch. Additional accounts use official Codex browser login in a restricted temporary home; temporary credentials are removed after import or cancellation. The separate account vault and recovery transaction are DPAPI encrypted and ACL restricted to the Windows user; the login staging directory also permits SYSTEM. They are never sent to this project's developers. DPAPI protects data at rest; it does not protect against other software already running as the same Windows user. The active Codex credential remains owned by the live authentication file: saved snapshots never override its newer tokens during recovery. Uninstall intentionally preserves the separate account vault to avoid losing saved logins. -An explicit **사용량 조회** request for an inactive account temporarily places that account's credential in a separate, user-only private home for the official local app-server. It never replaces the active login. The helper may refresh authentication while reading usage; after its confirmed exit, updated credentials are atomically saved back to the DPAPI vault even on request failure or cancellation. A DPAPI recovery journal retains the selected commit credentials before the vault write, including recovery interrupted by another crash. Temporary plaintext files are removed after verified persistence. An interruption before credential commit retains staging and requires **중단된 조회 복구** after Codex writers stop. If only post-commit file cleanup remains, **임시 파일 정리** can retry while Codex stays open without modifying authentication or the vault. A failed cleanup retains only a safe error category and numeric code in the encrypted journal; raw exception text and paths are not logged. Each account retains only its latest successful percentages, reset times, and check time; the application does not attribute usage to people or retain a usage timeline. +A usage read, triggered once by opening a new account manager window or explicitly through **전체 갱신** / **이 계정 사용량 조회**, for an inactive account temporarily places that account's credential in a separate, user-only private home for the official local app-server. It never replaces the active login. The helper may refresh authentication while reading usage; after its confirmed exit, updated credentials are atomically saved back to the DPAPI vault even on request failure or cancellation. A DPAPI recovery journal retains the selected commit credentials before the vault write, including recovery interrupted by another crash. Temporary plaintext files are removed after verified persistence. An interruption before credential commit retains staging and requires **중단된 조회 복구** after Codex writers stop. If only post-commit file cleanup remains, **임시 파일 정리** can retry while Codex stays open without modifying authentication or the vault. A failed cleanup retains only a safe error category and numeric code in the encrypted journal; raw exception text and paths are not logged. Each account retains only its latest successful percentages, reset times, and check time; the application does not attribute usage to people or retain a usage timeline. + +The combined manager observation set exists only in memory and stays separate from automatic active-account polling. It contains no additional credential copy or usage history. Full queries run sequentially and stop when credential recovery or temporary-file cleanup requires attention. When reporting a bug, do not attach Codex or Claude logs, credential/configuration files, tokens, or screenshots containing information you do not want to publish. diff --git a/README.md b/README.md index a40a14b..929cfbf 100644 --- a/README.md +++ b/README.md @@ -23,17 +23,23 @@ Account management is optional. After you register a Codex account, the widget s ## Manual Codex accounts -Open **Codex 계정 관리…** from the right-click menu, or double-click the tray icon. Choose **현재 계정 등록** to save the current login with a unique default name. Select an account in the left list to view its status and usage; use **이름 변경** or F2 to edit its name. Enter saves and Escape cancels. Account names appear only in the manager; the compact indicator shows percentages and bars. Renaming only changes local metadata and does not restart the usage helper. If you sign into an unregistered account directly in Codex, the manager offers registration above the existing list. +Open **Codex 계정 관리…** from the widget menu, or double-click the tray icon. The light-themed manager uses one overview and one account list, sorted by the nearest weekly reset. Its default height fits three accounts without scrolling on a sufficiently tall display. Account names appear in the manager and hover details; the compact indicator remains percentages and bars. Hover details separate the current account, the last combined observation with each account reset, and Claude. Hover never requests usage; the combined observation remains available after closing the manager and lasts for the widget process. -Choose **+ 다른 계정 추가**, optionally name the account, then select **브라우저에서 로그인**. Complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out; **로그인 취소** stops only the login process owned by this tool. The widget maintains its topmost position without activating itself, including when it reappears, so typing in the manager or another app keeps focus. +The overview adds the remaining weekly percentages: for example, **168% of 300%** across three accounts. Each account contributes up to 100%; this is an unweighted sum of account percentages, not a shared service limit or a comparison of different plans' absolute quotas. **다음 초기화** identifies the next confirmed reset and that account's remaining amount. The latest reset appears as secondary context; it is not a common deadline for the whole total. -Select the saved account and click **이 계정으로 전환**. The preparation dialog shows the source and target names and waits while you finish your work and close Codex Desktop and other Codex CLI/engine processes. **전환하고 Codex 열기** becomes available when they have stopped; cancellation keeps the current login. The widget stops its own usage helper, verifies once more that no Codex writers remain, saves the latest current login, and applies the selected login. It attempts to reopen the previously observed packaged desktop; if necessary, launch Codex from the Start menu and confirm the account there. File application and desktop login verification are separate outcomes. +Opening a new manager window starts one sequential usage query for all registered accounts. After that, **전체 갱신** is the only way to refresh the whole observation set. Restoring focus, changing selection, the local five-second UI timer, and the active widget's own polling do not trigger another batch or change this snapshot. Closing the window during a batch cancels the request and waits for safe cleanup. Failure, cancellation, missing/expired weekly data, or changed membership withholds the total and distinguishes the confirmed subtotal from previous values. Cleanup or credential recovery stops the remaining batch. -Select any registered account and click **사용량 조회** to fetch its latest weekly and 5-hour remaining usage, reset times, and last successful check time. **목록 갱신** only reloads saved values. Inactive accounts are queried only on an explicit click, using a short-lived official app-server in a private isolated home. Codex Desktop and the current account stay signed in; the current widget helper is not suspended. Active-account queries reuse that helper and require Codex Desktop to be open. **조회 취소**, a timeout, or a failed request preserves the last successful usage observation. Refreshed credentials are saved even if the usage request fails or is canceled. Expired logins can be renewed through **+ 다른 계정 추가** using the same inactive account; for the active account, sign in again in Codex. +Choose **현재 계정 등록** to save the current login with a default name. The **···** menu on each account contains **이름 변경**, **이 계정 사용량 조회**, **상세 정보**, and **저장된 로그인 삭제**. Enter saves a name and Escape cancels. Renaming only changes local metadata. The detail dialog contains the masked identity and latest 5-hour observation. An individual usage read updates that row and marks the overall total for a new full refresh; other observations retain their check times, while the total requires a full refresh so separate checks cannot silently masquerade as one complete batch. A background active-account poll does not alter the manager's observation set. + +Choose **+ 계정 추가**, optionally name the account, then select **브라우저에서 로그인**. Complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out; **로그인 취소** stops only the login process owned by this tool. If you sign into an unregistered account directly in Codex, the manager offers registration above the list. + +Click **전환** on the desired account. The preparation dialog shows the source and target names and waits while you finish your work and close Codex Desktop and other Codex CLI/engine processes. **전환하고 Codex 열기** becomes available when they have stopped; cancellation keeps the current login. The widget suspends its own helper, checks for remaining writers, preserves the latest current login and applies the selected login, then attempts to reopen Codex. Confirm the account there; file application and desktop login verification are separate outcomes. The widget's show/re-show and topmost maintenance preserve keyboard focus in other apps. + +Inactive usage reads use a short-lived official app-server in a private isolated home. Codex Desktop and the current account stay signed in, and the current helper is not suspended. Active-account reads reuse that helper and require Codex Desktop to be open. A timeout, cancellation or failed request preserves the last successful observation; refreshed credentials are saved even when usage retrieval fails. Expired inactive logins can be renewed through **+ 계정 추가** with that same account; renew the active login in Codex. The manager's background context menu can reload the saved account list without requesting usage. Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account background polling, quota pooling, usage history, or per-person attribution. Values past their reset time are marked **갱신 필요**. A pending switch transaction blocks polling until **미완료 전환 복구** reconciles it with actual live authentication. A query interrupted before credentials are safely saved offers **중단된 조회 복구**; finish Codex work and close remaining Codex writers before this exceptional recovery. Its encrypted journal preserves refreshed credentials before staging is removed. Recovery never overwrites newer live authentication. -If credentials are already saved and only temporary files remain, the manager instead shows **임시 파일 정리 대기** with an **임시 파일 정리** button. This cleanup can run while Codex stays open and does not block account editing or switching. The next inactive usage request also retries cleanup before starting. A temporary file lock is retried automatically; a persistent failure displays its category and code. Cleanup does not turn a failed or canceled usage request into a success, and periodic list refresh preserves the original result. The management window supports display scaling, and its details scroll when the window is made smaller. +If credentials are already saved and only temporary files remain, the manager instead shows **임시 파일 정리 대기** with an **임시 파일 정리** button. This cleanup can run while Codex stays open and does not block account editing or switching. The next inactive usage request also retries cleanup before starting. A temporary file lock is retried automatically; a persistent failure displays its category and code. Cleanup does not turn a failed or canceled usage request into a success, and periodic list refresh preserves the original result. The management window supports display scaling, and its account list scrolls when the window is made smaller. The first version supports local Windows file-based ChatGPT authentication. Unsupported keyring/managed configurations fail closed. The vault is stored separately at `%LOCALAPPDATA%\CodexWeeklyUsageIndicator.Accounts`; uninstall preserves it. Delete inactive accounts from the manager before removing the app if you no longer want their saved credentials. This convenience tool does not establish that any particular multi-account usage pattern is permitted by the service terms. diff --git a/docs/combined-usage-delivery.md b/docs/combined-usage-delivery.md new file mode 100644 index 0000000..0d8911e --- /dev/null +++ b/docs/combined-usage-delivery.md @@ -0,0 +1,24 @@ +# Account overview and light interface — 1.7.0 + +The account manager now has one light overview and one account list, ordered by weekly reset. The user rejected the additive two-pane layout, requested a light palette and readable multi-account hover details, and asked for three accounts to fit when the window opens. Default size is 920 × 740 logical pixels, bounded by the available screen. Small windows retain scrolling. + +## Behavior + +- Each newly opened manager runs one sequential full query. Refocusing the same window, selection, local UI timers and hovering do not query inactive accounts. The full refresh button explicitly repeats the batch. +- The overview sums percentages without averaging: three accounts at 63%, 81%, and 24% show 168% out of 300%. Each account contributes a 100% unit; different plans are not normalized to a common token quota. Account switching remains explicit. +- A complete total requires a successful, newly persisted observation for every member, a seven-day window, future reset times, and unchanged account membership. Failures and cancellations retain prior observations with timestamps. An uncertain earlier reset prevents the header from claiming that another confirmed account is the next reset. The latest reset is context, not a common expiry deadline. +- One-account queries preserve other observations but withhold the total until the next full batch. Metadata updates preserve usage values. Active widget polling never changes the manager observation set. +- The widget retains that in-memory observation set after the manager closes. Its light, non-activating hover window separates current Codex usage, all saved accounts and their reset schedule, and Claude. Up to five accounts are listed before a link hint to the manager. There is no added usage-history file. +- A batch aborts remaining requests when credentials need recovery or temporary files still need cleanup. Closing an in-flight batch requests cancellation and waits for safe cleanup before closing the window. The existing encrypted vault and authentication transaction paths are retained. + +## Validation and review + +The required release build passes 29 regression groups, including native manager registration/rename/focus, manual usage failure/cancellation/cleanup, aggregate coverage/reset boundaries, sequential batches, duplicate suppression, no-op persistence rejection, close cancellation, tooltip freshness boundaries and the native tooltip's non-activating layout. Synthetic UI captures at the target 175% DPI cover normal, partial, in-flight and small-window states. The three-account default layout is asserted to have no vertical scrollbar. + +An explicit opt-in live batch uses the production widget callback. Every registered account receives a new observation while the active authentication bytes remain unchanged, Codex Desktop stays running, no active-helper suspension occurs, and no query staging or journal remains. This is an integration check; it is not a claim that the user personally accepted every new screen. + +Actual Claude Fable reviews examined the aggregate contract and implementation. Adopted findings include validating persistence time before certifying success, preserving actionable failures and previous observation times, handling an uncertain earlier reset, and stopping unresolved cleanup before querying further accounts. Individual queries deliberately do not certify a mixed observation set; this follows the requested full-refresh boundary. + +## Delivery + +The Windows artifact is built and tested by GitHub Actions. Verify the draft release checksums, ZIP manifest, binary version and absence of private build paths before installing through the existing interactive per-user scheduled-task installer. Verify the installed binary and supervisor/widget pair, then publish the verified draft. No vault schema migration is required. Resolve pending account recovery before rollback. diff --git a/src/AccountManagerForm.CombinedUsage.cs b/src/AccountManagerForm.CombinedUsage.cs new file mode 100644 index 0000000..c87d4eb --- /dev/null +++ b/src/AccountManagerForm.CombinedUsage.cs @@ -0,0 +1,108 @@ +namespace WeeklyUsageIndicator; + +internal sealed partial class AccountManagerForm +{ + private async Task ReadAllUsageAsync() + { + if (_busy || _items.Count == 0) return; + await RunAsync(false, "전체 계정의 주간 사용량을 확인하고 있습니다…", async () => + { + var original = _store.ListAccounts(); + var activeId = original.SingleOrDefault(a => a.IsActive)?.Id; + _combined.Initialize(original, batch: true); + _batchQuerying = true; _batchCompleted = 0; + using var cancellation = new CancellationTokenSource(); + _loginCancellation = cancellation; _cancel.Text = "전체 조회 취소"; _cancel.Visible = true; _cancel.Enabled = true; + UpdateActions(); + try + { + if (_store.GetUsageQueryStatus().State == UsageQueryState.CleanupPending) + { + await Task.Run(_store.RetryUsageCleanup); + if (_store.GetUsageQueryStatus().State != UsageQueryState.None) + throw new InvalidOperationException("임시 파일 정리를 완료한 뒤 전체 갱신해 주세요."); + } + foreach (var member in original.OrderByDescending(a => a.IsActive)) + { + cancellation.Token.ThrowIfCancellationRequested(); + var fresh = _store.ListAccounts(); + if (!_combined.Reconcile(fresh) || fresh.SingleOrDefault(a => a.IsActive)?.Id != activeId) + throw new InvalidOperationException("조회 중 계정 구성이 바뀌었습니다. 목록을 확인한 뒤 전체 갱신해 주세요."); + if (_store.HasPendingRecovery || _store.GetUsageQueryStatus().State == UsageQueryState.RecoveryRequired) + throw new InvalidOperationException("중단된 계정 작업을 복구한 뒤 전체 갱신해 주세요."); + var target = fresh.Single(a => a.Id == member.Id); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation.Token, timeout.Token); + try + { + var requestedAt = DateTimeOffset.UtcNow; + await QueryBatchMemberAsync(target, linked.Token); + var observed = _store.ListAccounts().Single(a => a.Id == target.Id); + if (observed.ObservedAt is null || observed.ObservedAt < requestedAt) + throw new InvalidOperationException("새 확인값을 저장하지 못했습니다. 다시 갱신해 주세요."); + _combined.SetResult(target.Id, observed, CombinedReadState.Success); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + _combined.SetResult(target.Id, target, CombinedReadState.Canceled); + throw; + } + catch (Exception ex) + { + _combined.SetResult(target.Id, target, CombinedReadState.Failed, BatchFailure(ex)); + // These states can contain the only refreshed credential. Do not start another writer. + if (ex is UsageHelperShutdownException || _store.HasPendingRecovery || + _store.GetUsageQueryStatus().State == UsageQueryState.RecoveryRequired) throw; + } + _batchCompleted++; + UpdateActions(); + if (_store.GetUsageQueryStatus().State == UsageQueryState.CleanupPending) + throw new InvalidOperationException("로그인 정보는 저장됐지만 임시 파일 정리가 남아 전체 조회를 멈췄습니다. ‘임시 파일 정리’ 후 다시 갱신하세요."); + } + cancellation.Token.ThrowIfCancellationRequested(); + var final = _store.ListAccounts(); + if (!_combined.Reconcile(final) || final.SingleOrDefault(a => a.IsActive)?.Id != activeId) + throw new InvalidOperationException("조회 중 계정 구성이 바뀌었습니다. 전체 갱신이 필요합니다."); + _combined.CompletedAt = DateTimeOffset.UtcNow; + var complete = _combined.IsComplete(DateTimeOffset.UtcNow); + SetStatus(complete ? $"{original.Count}개 계정의 주간 잔여량을 확인했습니다." + : "일부 계정의 확인이 필요합니다. 계정별 상태를 확인하고 다시 갱신하세요.", success: complete); + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { _combined.WasCanceled = true; throw; } + catch { _combined.Interrupted = true; throw; } + finally + { + _batchQuerying = false; _loginCancellation = null; + _combined.CompletedAt ??= DateTimeOffset.UtcNow; + } + }, acquireGate: false, canceledMessage: "전체 조회를 취소했습니다. 확인된 값과 이전 값은 보존했습니다."); + } + + private async Task QueryBatchMemberAsync(SavedCodexAccount account, CancellationToken token) + { + // Inactive queries own this thread-affine mutex on their worker. Active reads run + // on the UI context, so keep only their own transaction gate on this same thread. + if (!account.IsActive) { await _queryUsage(account, token); return; } + using var gate = new Mutex(false, CodexAccountStore.TransactionMutexName); + var owned = false; + try + { + try { owned = gate.WaitOne(0); } catch (AbandonedMutexException) { owned = true; } + if (!owned) throw new AccountStoreBusyException(); + await _queryUsage(account, token); + } + finally { if (owned) gate.ReleaseMutex(); } + } + + private static string BatchFailure(Exception ex) => ex switch + { + OperationCanceledException or TimeoutException => "조회 시간 초과", + CodexUsageAuthenticationException => "로그인 확인 필요", + UsageHelperShutdownException => "중단된 조회 복구 필요", + AccountStoreBusyException => "다른 계정 작업 진행 중", + InvalidOperationException invalid => invalid.Message, + IOException => "조회 실패 · 연결 확인 필요", + _ => "조회 실패 · 계정 상태 확인 필요" + }; +} diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs index f9eb965..c1f299b 100644 --- a/src/AccountManagerForm.cs +++ b/src/AccountManagerForm.cs @@ -1,212 +1,163 @@ namespace WeeklyUsageIndicator; -internal sealed class AccountManagerForm : Form +internal sealed partial class AccountManagerForm : Form { private readonly CodexAccountStore _store; private readonly Func _suspend; private readonly Action _resume; private readonly Action? _accountsChanged; private readonly Func _queryUsage; - private readonly AccountListBox _accounts = new() { Name = "AccountList", Dock = DockStyle.Fill, DisplayMember = nameof(SavedCodexAccount.Label) }; - private readonly Label _count = AccountUiTheme.Label("저장된 계정"); - private readonly Label _status = AccountUiTheme.Label("계정을 선택해 상태를 확인하세요."); - private readonly Label _title = AccountUiTheme.Label("", 18, true); - private readonly Label _identity = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _state = AccountUiTheme.Label("", color: AccountUiTheme.Accent); - private readonly Label _remaining = AccountUiTheme.Label("미확인", 26, true); - private readonly Label _usageTitle = AccountUiTheme.Label("주간 잔여 사용량", color: AccountUiTheme.Muted); - private readonly Label _observed = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _reset = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _shortRemaining = AccountUiTheme.Label("", color: AccountUiTheme.Text); - private readonly Label _shortReset = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly Label _switchHelp = AccountUiTheme.Label("", color: AccountUiTheme.Muted); - private readonly AccountUsageBar _bar = new() { Dock = DockStyle.Fill }; - private readonly Button _add = AccountUiTheme.Button("AddAccountButton", "+ 다른 계정 추가", true); + private readonly AccountTable _accounts = new(); + private readonly Label _count = AccountUiTheme.Label("저장된 계정", 11, true); + private readonly Label _status = AccountUiTheme.Label(""); + private readonly Button _add = AccountUiTheme.Button("AddAccountButton", "+ 계정 추가"); + private readonly Button _refreshAll = AccountUiTheme.Button("RefreshAllUsageButton", "전체 갱신", true); private readonly Button _register = AccountUiTheme.Button("RegisterCurrentButton", "현재 계정 등록", true); private readonly Button _registerActive = AccountUiTheme.Button("RegisterActiveAccountButton", "현재 계정 등록"); - private readonly Button _rename = AccountUiTheme.Button("RenameAccountButton", "이름 변경"); - private readonly Button _switch = AccountUiTheme.Button("SwitchAccountButton", "이 계정으로 전환", true); - private readonly Button _delete = AccountUiTheme.Button("DeleteAccountButton", "저장된 로그인 삭제"); - private readonly Button _refresh = AccountUiTheme.Button("RefreshAccountsButton", "목록 갱신"); - private readonly Button _readUsage = AccountUiTheme.Button("ReadAccountUsageButton", "사용량 조회"); + private readonly ToolStripMenuItem _rename = new("이름 변경") { Name = "RenameAccountButton" }; + private readonly ToolStripMenuItem _readUsage = new("이 계정 사용량 조회") { Name = "ReadAccountUsageButton" }; + private readonly ToolStripMenuItem _delete = new("저장된 로그인 삭제") { Name = "DeleteAccountButton" }; + private readonly ToolStripMenuItem _refresh = new("저장된 계정 목록 다시 읽기") { Name = "RefreshAccountsButton" }; + private readonly ToolStripMenuItem _info = new("상세 정보") { Name = "AccountInfoMenuItem" }; + private readonly ContextMenuStrip _menu = new(); + private readonly ContextMenuStrip _pageMenu = new(); private readonly Button _recover = AccountUiTheme.Button("RecoverAccountsButton", "미완료 전환 복구"); - private readonly Button _cancel = AccountUiTheme.Button("CancelLoginButton", "로그인 취소"); - private readonly Panel _detail = new() { Name = "AccountDetailPanel", Dock = DockStyle.Fill, BackColor = AccountUiTheme.Surface, AutoScroll = true }; + private readonly Button _cancel = AccountUiTheme.Button("CancelLoginButton", "조회 취소"); private readonly Panel _empty = new() { Dock = DockStyle.Fill, BackColor = AccountUiTheme.Surface }; private readonly ProgressBar _progress = new() { Dock = DockStyle.Bottom, Height = 3, Style = ProgressBarStyle.Marquee, Visible = false }; private readonly System.Windows.Forms.Timer _refreshTimer = new() { Interval = 5000 }; + private readonly TableLayoutPanel _root = AccountUiTheme.Stack(6); + private readonly TableLayoutPanel _notice = new() { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0, 0, 0, 12), Padding = new Padding(12, 6, 8, 6), BackColor = AccountUiTheme.Raised }; + private readonly CombinedUsagePanel _overview = new() { Dock = DockStyle.Fill, Margin = new Padding(0, 0, 0, 20) }; + private readonly CombinedUsageSnapshot _combined; + private readonly Action? _usageChanged; private IReadOnlyList _items = Array.Empty(); private CancellationTokenSource? _loginCancellation; - private bool _busy; - private bool _reloading; - private string? _desktopPath; + private bool _busy, _reloading, _overviewInitialized, _batchQuerying, _closeAfterBatch; + private int _batchCompleted; + private string? _desktopPath, _operationMessage; + private bool _operationError, _operationSuccess; + private readonly bool _refreshAllOnOpen; private UsageQueryStatus _usageQueryStatus = new(UsageQueryState.None); - private string? _operationMessage; - private bool _operationError; - private bool _operationSuccess; + internal CombinedUsageSnapshot CombinedUsage => _combined; internal bool IsOperationInProgress => _busy; + internal ToolStripItem MenuAction(string name) => _menu.Items.Cast().Concat(_pageMenu.Items.Cast()).Single(i => i.Name == name); + internal Button? SelectedSwitchButton => _accounts.SelectedSwitchButton; private SavedCodexAccount? Selected => _accounts.SelectedItem as SavedCodexAccount; public AccountManagerForm(CodexAccountStore store, Func suspend, Action resume, Action? accountsChanged = null, - Func? queryUsage = null) + Func? queryUsage = null, bool refreshAllOnOpen = true, CombinedUsageSnapshot? combined = null, Action? usageChanged = null) { - SuspendLayout(); - _store = store; _suspend = suspend; _resume = resume; _accountsChanged = accountsChanged; + SuspendLayout(); _store = store; _suspend = suspend; _resume = resume; _accountsChanged = accountsChanged; _queryUsage = queryUsage ?? ((account, token) => CodexAccountUsageReader.ReadInactiveAsync(store, account.Id, token)); - Name = "AccountManagerForm"; - AccountUiTheme.SetForm(this); - Text = "Codex 계정 관리"; - ClientSize = new Size(980, 660); - MinimumSize = new Size(850, 520); - StartPosition = FormStartPosition.CenterScreen; - KeyPreview = true; - var root = AccountUiTheme.Stack(4); - root.Padding = new Padding(24); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 76)); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 100)); - root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - root.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); - - var header = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; - header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); - var headings = AccountUiTheme.Stack(2); - headings.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); headings.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - headings.Controls.Add(AccountUiTheme.Label("Codex 계정", 22, true), 0, 0); - headings.Controls.Add(AccountUiTheme.Label("직접 고르고, 필요할 때 전환하세요.", color: AccountUiTheme.Muted), 0, 1); - _add.Anchor = AnchorStyles.Right | AnchorStyles.Top; - header.Controls.Add(headings, 0, 0); header.Controls.Add(_add, 1, 0); - root.Controls.Add(header, 0, 0); - - var notice = new Panel { Dock = DockStyle.Fill, BackColor = AccountUiTheme.Raised, Padding = new Padding(14, 10, 14, 10), Margin = new Padding(0, 0, 0, 14) }; - _status.Name = "StatusLabel"; _status.Dock = DockStyle.Fill; _status.AutoSize = false; - var noticeActions = new FlowLayoutPanel { Dock = DockStyle.Right, AutoSize = true, WrapContents = false, FlowDirection = FlowDirection.RightToLeft }; - _cancel.Visible = false; _recover.Visible = false; - _registerActive.Visible = false; - noticeActions.Controls.Add(_cancel); noticeActions.Controls.Add(_recover); noticeActions.Controls.Add(_registerActive); - notice.Controls.Add(_status); notice.Controls.Add(noticeActions); notice.Controls.Add(_progress); - root.Controls.Add(notice, 0, 1); - - var body = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; - body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30)); body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 70)); - var sidebar = AccountUiTheme.Stack(2); - sidebar.BackColor = AccountUiTheme.Surface; sidebar.Margin = new Padding(0, 0, 16, 0); - sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 52)); sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - var listHeader = new Panel { Dock = DockStyle.Fill, Padding = new Padding(14, 8, 10, 6) }; - _count.Dock = DockStyle.Fill; _count.AutoSize = false; _count.TextAlign = ContentAlignment.MiddleLeft; - _refresh.Dock = DockStyle.Right; _refresh.MinimumSize = new Size(70, 32); _refresh.Padding = new Padding(6, 0, 6, 0); - listHeader.Controls.Add(_count); listHeader.Controls.Add(_refresh); - sidebar.Controls.Add(listHeader, 0, 0); sidebar.Controls.Add(_accounts, 0, 1); - body.Controls.Add(sidebar, 0, 0); - var right = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; - BuildDetail(); BuildEmpty(); - right.Controls.Add(_detail); right.Controls.Add(_empty); - body.Controls.Add(right, 1, 0); - root.Controls.Add(body, 0, 2); - var footer = AccountUiTheme.Label("‘사용량 조회’로 선택한 계정의 최신 값을 확인하세요. 다른 계정은 자동 조회하지 않습니다.", color: AccountUiTheme.Muted); - footer.Margin = new Padding(0, 8, 0, 0); - root.Controls.Add(footer, 0, 3); - Controls.Add(root); - - _accounts.SelectedIndexChanged += (_, _) => { if (!_reloading) ShowSelected(); }; - _refresh.Click += (_, _) => { if (!_busy && Reload()) SetStatus("계정 목록을 새로 확인했습니다."); }; - _readUsage.Click += async (_, _) => await ReadSelectedUsageAsync(); - _register.Click += async (_, _) => await RegisterCurrentAsync(); - _registerActive.Click += async (_, _) => await RegisterCurrentAsync(); - _rename.Click += (_, _) => RenameSelected(); - _add.Click += async (_, _) => await AddAccountAsync(); - _switch.Click += async (_, _) => await SwitchSelectedAsync(); - _delete.Click += (_, _) => DeleteSelected(); - _recover.Click += async (_, _) => await RecoverAsync(); + _combined = combined ?? new(); _usageChanged = usageChanged; + _refreshAllOnOpen = refreshAllOnOpen; Name = "AccountManagerForm"; + AccountUiTheme.SetForm(this); Text = "Codex 계정 관리"; + ClientSize = new Size(920, 740); MinimumSize = new Size(780, 550); StartPosition = FormStartPosition.CenterScreen; KeyPreview = true; + _root.Padding = new Padding(24); + foreach (var h in new[] { 66, 192, 0, 42 }) _root.RowStyles.Add(new RowStyle(SizeType.Absolute, h)); + _root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); _root.RowStyles.Add(new RowStyle(SizeType.Absolute, 32)); + var header = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 3, RowCount = 1, Margin = new Padding(0) }; + header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + header.Controls.Add(AccountUiTheme.Label("Codex 계정", 22, true), 0, 0); + _add.Anchor = _refreshAll.Anchor = AnchorStyles.Top | AnchorStyles.Right; _add.Margin = new Padding(0, 0, 10, 0); + header.Controls.Add(_add, 1, 0); header.Controls.Add(_refreshAll, 2, 0); + _root.Controls.Add(header, 0, 0); _root.Controls.Add(_overview, 0, 1); + _status.Name = "StatusLabel"; _status.Dock = DockStyle.Fill; _status.AutoSize = false; _status.TextAlign = ContentAlignment.MiddleLeft; + _notice.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); _notice.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + var noticeActions = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = false, Margin = new Padding(8, 0, 0, 0), FlowDirection = FlowDirection.RightToLeft }; + _cancel.Visible = _recover.Visible = _registerActive.Visible = false; + noticeActions.Controls.AddRange(new Control[] { _cancel, _recover, _registerActive }); + _notice.Controls.Add(_status, 0, 0); _notice.Controls.Add(noticeActions, 1, 0); _root.Controls.Add(_notice, 0, 2); + var listHeader = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; + listHeader.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); listHeader.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + listHeader.Controls.Add(_count, 0, 0); + var order = AccountUiTheme.Label("초기화가 가까운 순", color: AccountUiTheme.Muted); order.Anchor = AnchorStyles.Top | AnchorStyles.Right; + listHeader.Controls.Add(order, 1, 0); _root.Controls.Add(listHeader, 0, 3); + var body = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; BuildEmpty(); body.Controls.Add(_accounts); body.Controls.Add(_empty); _root.Controls.Add(body, 0, 4); + var footer = AccountUiTheme.Label("패널을 새로 열 때 한 번 확인합니다. 이후에는 ‘전체 갱신’을 눌러 주세요.", 8.5f, color: AccountUiTheme.Muted); + footer.Margin = new Padding(0, 12, 0, 0); _root.Controls.Add(footer, 0, 5); Controls.Add(_root); Controls.Add(_progress); + foreach (var menu in new[] { _menu, _pageMenu }) { menu.BackColor = AccountUiTheme.Raised; menu.ForeColor = AccountUiTheme.Text; menu.Font = Font; menu.ShowImageMargin = false; } + _menu.Items.AddRange(new ToolStripItem[] { _readUsage, _rename, _info, new ToolStripSeparator(), _delete }); _pageMenu.Items.Add(_refresh); ContextMenuStrip = _pageMenu; + _accounts.ManageRequested += (_, anchor) => { UpdateActions(); _menu.Show(anchor, new Point(0, anchor.Height)); }; + _accounts.SwitchRequested += async _ => await SwitchSelectedAsync(); + _accounts.SelectedIndexChanged += (_, _) => { if (!_reloading) UpdateActions(); }; + _refresh.Click += (_, _) => { if (!_busy && Reload()) SetStatus("저장된 계정 목록을 다시 읽었습니다."); }; + _refreshAll.Click += async (_, _) => await ReadAllUsageAsync(); + _readUsage.Click += async (_, _) => await ReadSelectedUsageAsync(); _rename.Click += (_, _) => RenameSelected(); + _info.Click += (_, _) => ShowAccountInfo(); _delete.Click += (_, _) => DeleteSelected(); + _register.Click += async (_, _) => await RegisterCurrentAsync(); _registerActive.Click += async (_, _) => await RegisterCurrentAsync(); + _add.Click += async (_, _) => await AddAccountAsync(); _recover.Click += async (_, _) => await RecoverAsync(); _cancel.Click += (_, _) => { _loginCancellation?.Cancel(); _cancel.Enabled = false; SetStatus("요청을 취소하고 로그인 정보를 안전하게 정리하고 있습니다…"); }; KeyDown += (_, e) => { if (e.KeyCode == Keys.F2 && !_busy && Selected is not null) { e.Handled = true; RenameSelected(); } }; - _refreshTimer.Tick += (_, _) => { if (!_busy && !OwnedForms.Any(f => f.Visible)) Reload(quiet: true); }; - Shown += (_, _) => + _refreshTimer.Tick += (_, _) => { if (!_busy && !_menu.Visible && !_pageMenu.Visible && !OwnedForms.Any(f => f.Visible)) Reload(quiet: true); }; + Shown += async (_, _) => { - var area = Screen.FromControl(this).WorkingArea; - Size = new Size(Math.Min(Width, area.Width), Math.Min(Height, area.Height)); + var area = Screen.FromControl(this).WorkingArea; Size = new Size(Math.Min(Width, area.Width), Math.Min(Height, area.Height)); Location = new Point(Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)), Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height))); _desktopPath = CodexAccountRuntime.CaptureDesktopLaunchPath(); Reload(); _refreshTimer.Start(); + if (_refreshAllOnOpen && _items.Count > 0 && !_store.HasPendingRecovery && _usageQueryStatus.State == UsageQueryState.None) await ReadAllUsageAsync(); }; - FormClosing += (_, e) => { if (_busy) { e.Cancel = true; SetStatus("진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요."); } }; - FormClosed += (_, _) => _refreshTimer.Stop(); - ResumeLayout(performLayout: true); + FormClosing += (_, e) => + { + if (!_busy) return; e.Cancel = true; + if (_batchQuerying) { _closeAfterBatch = true; _loginCancellation?.Cancel(); SetStatus("전체 조회를 취소하고 안전하게 정리한 뒤 닫습니다…"); } + else SetStatus("진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요."); + }; + FormClosed += (_, _) => _refreshTimer.Stop(); ResumeLayout(true); } - private Task RegisterCurrentAsync() => RunAsync(true, "현재 계정을 등록하고 있습니다…", async () => - { - var account = _store.RegisterCurrent(NextName()); - Reload(account.Id); - SetStatus($"‘{account.Label}’ 등록 완료. 이름은 오른쪽 ‘이름 변경’에서 바꿀 수 있습니다.", success: true); - _accountsChanged?.Invoke(); - await Task.CompletedTask; - }); - - private void BuildDetail() { - var layout = AccountUiTheme.Stack(7); - layout.Dock = DockStyle.Top; - layout.MinimumSize = new Size(0, 496); - layout.Height = 496; - layout.Padding = new Padding(22); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 234)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); - var titleRow = new Panel { Dock = DockStyle.Fill }; - _rename.Dock = DockStyle.Right; - _title.Dock = DockStyle.Fill; _title.AutoSize = false; _title.AutoEllipsis = true; - titleRow.Controls.Add(_title); titleRow.Controls.Add(_rename); - layout.Controls.Add(titleRow, 0, 0); - var identity = AccountUiTheme.Stack(2); - identity.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); identity.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); - _state.Dock = DockStyle.Fill; _identity.Dock = DockStyle.Fill; - identity.Controls.Add(_state, 0, 0); identity.Controls.Add(_identity, 0, 1); - layout.Controls.Add(identity, 0, 1); - var usage = AccountUiTheme.Stack(7); - usage.BackColor = AccountUiTheme.Raised; usage.Padding = new Padding(16, 10, 16, 10); - foreach (var height in new[] { 36, 47, 10, 28, 28, 30, 28 }) usage.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); - var usageHeader = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; - _usageTitle.Dock = DockStyle.Fill; _usageTitle.AutoSize = false; _usageTitle.TextAlign = ContentAlignment.MiddleLeft; - _readUsage.Dock = DockStyle.Right; _readUsage.MinimumSize = new Size(98, 32); _readUsage.Padding = new Padding(8, 0, 8, 0); - usageHeader.Controls.Add(_usageTitle); usageHeader.Controls.Add(_readUsage); - usage.Controls.Add(usageHeader, 0, 0); - usage.Controls.Add(_remaining, 0, 1); usage.Controls.Add(_bar, 0, 2); - _observed.Dock = DockStyle.Fill; _observed.TextAlign = ContentAlignment.BottomLeft; - usage.Controls.Add(_observed, 0, 3); usage.Controls.Add(_reset, 0, 4); - usage.Controls.Add(_shortRemaining, 0, 5); usage.Controls.Add(_shortReset, 0, 6); - layout.Controls.Add(usage, 0, 2); - _switchHelp.Dock = DockStyle.Fill; _switchHelp.AutoSize = false; - layout.Controls.Add(_switchHelp, 0, 4); - _switch.Dock = DockStyle.Fill; - layout.Controls.Add(_switch, 0, 5); - _delete.Anchor = AnchorStyles.Left | AnchorStyles.Bottom; - _delete.MinimumSize = new Size(140, 28); _delete.Padding = new Padding(0); _delete.BackColor = AccountUiTheme.Surface; - _delete.ForeColor = AccountUiTheme.Muted; - layout.Controls.Add(_delete, 0, 6); - _detail.Controls.Add(layout); - _detail.Resize += (_, _) => layout.Height = Math.Max(layout.MinimumSize.Height, _detail.ClientSize.Height); - } - + var account = _store.RegisterCurrent(NextName()); Reload(account.Id); + SetStatus($"‘{account.Label}’ 등록 완료. 계정의 ··· 메뉴에서 이름을 바꿀 수 있습니다.", success: true); + _accountsChanged?.Invoke(); await Task.CompletedTask; + }); private void BuildEmpty() { - var layout = AccountUiTheme.Stack(5); layout.Padding = new Padding(32); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 35)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 80)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); - layout.RowStyles.Add(new RowStyle(SizeType.Percent, 65)); - layout.Controls.Add(AccountUiTheme.Label("첫 계정을 등록하세요", 19, true), 0, 1); - var description = AccountUiTheme.Label("지금 Codex에서 사용하는 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다.", color: AccountUiTheme.Muted); - description.Dock = DockStyle.Fill; description.AutoSize = false; - layout.Controls.Add(description, 0, 2); - _register.Dock = DockStyle.Fill; - layout.Controls.Add(_register, 0, 3); - _empty.Controls.Add(layout); + var layout = AccountUiTheme.Stack(4); layout.Padding = new Padding(24); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 60)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.Controls.Add(AccountUiTheme.Label("첫 계정을 등록하세요", 18, true), 0, 0); + var description = AccountUiTheme.Label("지금 사용하는 Codex 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다.", color: AccountUiTheme.Muted); + layout.Controls.Add(description, 0, 1); layout.Controls.Add(_register, 0, 2); _empty.Controls.Add(layout); + } + private void ShowAccountInfo() + { + if (Selected is not { } account) return; + using var dialog = new Form { Name = "AccountInfoDialog", Text = "계정 상세", ClientSize = new Size(460, 280), StartPosition = FormStartPosition.CenterParent, FormBorderStyle = FormBorderStyle.FixedDialog, MaximizeBox = false, MinimizeBox = false }; + AccountUiTheme.SetForm(dialog); + var layout = AccountUiTheme.Stack(5); layout.Padding = new Padding(24); + foreach (var h in new[] { 44, 34, 58, 52, 44 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, h)); + layout.Controls.Add(AccountUiTheme.Label(account.Label, 16, true), 0, 0); layout.Controls.Add(AccountUiTheme.Label(account.IdentityHint, color: AccountUiTheme.Muted), 0, 1); + var shortWindow = account.Usage?.ShortWindow; var now = DateTimeOffset.Now; + var remaining = shortWindow is null ? "정보 없음" : shortWindow.ResetsAt <= now ? "갱신 필요" : $"{100 - shortWindow.UsedPercent}%"; + layout.Controls.Add(AccountUiTheme.Label($"5시간 잔여 {remaining}\n초기화 {(shortWindow?.ResetsAt is { } reset ? reset.ToLocalTime().ToString("MM/dd HH:mm") : "—")}"), 0, 2); + layout.Controls.Add(AccountUiTheme.Label(account.ObservedAt is { } observed ? $"개별 조회의 마지막 확인 {observed.ToLocalTime():MM/dd HH:mm}\n전체 잔여량은 마지막 전체 확인 기준입니다." : "아직 사용량을 확인하지 않았습니다.", color: AccountUiTheme.Muted), 0, 3); + var close = AccountUiTheme.Button("CloseAccountInfoButton", "닫기"); close.DialogResult = DialogResult.OK; layout.Controls.Add(close, 0, 4); dialog.AcceptButton = dialog.CancelButton = close; + dialog.Controls.Add(layout); dialog.ShowDialog(this); + } + private void UpdateNotice() + { + var show = (_status.Text.Length > 0 && !_operationSuccess) || _loginCancellation is not null || + _store.HasPendingRecovery || _usageQueryStatus.State != UsageQueryState.None || (_items.Count > 0 && !_items.Any(a => a.IsActive)); + _notice.Visible = show; + var height = show ? (int)Math.Round((_operationError || _usageQueryStatus.State != UsageQueryState.None ? 100 : 72) * DeviceDpi / 96f) : 0; + if (_root.RowStyles.Count > 2 && _root.RowStyles[2].Height != height) _root.RowStyles[2].Height = height; + } + private void UpdateActions() + { + var pending = _store.HasPendingRecovery || _usageQueryStatus.State == UsageQueryState.RecoveryRequired; + _accounts.Enabled = !_busy; _refresh.Enabled = !_busy; _readUsage.Enabled = !_busy && !pending && Selected is not null; + _add.Enabled = !_busy && !pending && _items.Count > 0; _register.Enabled = !_busy && !pending; + _registerActive.Visible = _items.Count > 0 && !_items.Any(a => a.IsActive) && !pending; _registerActive.Enabled = !_busy; + _rename.Enabled = _info.Enabled = !_busy && !pending && Selected is not null; + _delete.Enabled = !_busy && !pending && Selected is { IsActive: false }; + _recover.Visible = pending || _usageQueryStatus.State == UsageQueryState.CleanupPending; _recover.Enabled = !_busy; + _recover.Text = _store.HasPendingRecovery ? "미완료 전환 복구" : _usageQueryStatus.State == UsageQueryState.CleanupPending ? "임시 파일 정리" : "중단된 조회 복구"; + _refreshAll.Enabled = !_busy && !pending && _items.Count > 0; + _overview.Display(_combined, _busy || pending, _batchCompleted, _batchQuerying); + _accounts.Display(_combined, _busy, !pending, _items.Any(a => a.IsActive)); UpdateNotice(); _usageChanged?.Invoke(); } - private string NextName() { for (var n = 1; ; n++) if (!_items.Any(a => a.Label == $"계정 {n}")) return $"계정 {n}"; @@ -253,7 +204,12 @@ await RunAsync(false, $"‘{account.Label}’ 사용량을 조회하고 있습 _loginCancellation = cancellation; _cancel.Text = "조회 취소"; _cancel.Visible = true; _cancel.Enabled = true; try { + var requestedAt = DateTimeOffset.UtcNow; await _queryUsage(account, linked.Token); + var observed = _store.ListAccounts().Single(a => a.Id == account.Id); + if (observed.ObservedAt is null || observed.ObservedAt < requestedAt) + throw new InvalidOperationException("새 확인값을 저장하지 못했습니다. 다시 조회해 주세요."); + _combined.RecordIndividual(observed); Reload(account.Id); SetStatus($"‘{account.Label}’ 사용량을 확인했습니다.", success: true); } @@ -352,10 +308,11 @@ private async Task RunAsync(bool suspend, string message, Func action, boo try { _resume(); } catch { SetStatus("계정 작업은 끝났지만 사용량 조회를 재개하지 못했습니다. 위젯을 다시 열어주세요.", error: true); } } + if (_closeAfterBatch) { _closeAfterBatch = false; BeginInvoke(new Action(Close)); } } } - private bool Reload(string? selectId = null, bool quiet = false) + internal bool Reload(string? selectId = null, bool quiet = false) { try { @@ -373,54 +330,18 @@ private bool Reload(string? selectId = null, bool quiet = false) _accounts.EndUpdate(); _reloading = false; } _count.Text = $"계정 {_items.Count}개"; - _empty.Visible = _items.Count == 0; _detail.Visible = _items.Count > 0; + _empty.Visible = _items.Count == 0; _accounts.Visible = _items.Count > 0; _usageQueryStatus = _store.GetUsageQueryStatus(); + if (!_overviewInitialized) { if (_combined.Accounts.Count == 0) _combined.Initialize(fresh, batch: false); else _combined.Reconcile(fresh); _overviewInitialized = true; } + else _combined.Reconcile(fresh); if (!quiet) { _operationMessage = null; _operationError = false; _operationSuccess = false; } - ShowSelected(); UpdateActions(); + UpdateActions(); RenderStatus(); return !_store.HasPendingRecovery && _usageQueryStatus.State != UsageQueryState.RecoveryRequired && (_items.Count == 0 || _items.Any(a => a.IsActive)); } catch (Exception ex) { SetStatus(ex.Message, error: true); return false; } } - private void ShowSelected() - { - if (Selected is not { } account) { UpdateActions(); return; } - _title.Text = account.Label; - _state.Text = account.IsActive ? "● 현재 사용 중" : "저장된 계정"; - _identity.Text = account.IdentityHint; - _usageTitle.Text = "주간 잔여 사용량"; - var expired = account.Usage?.ResetsAt is { } resetAt && resetAt <= DateTimeOffset.Now; - _remaining.Text = expired ? "갱신 필요" : account.Usage is { } usage ? $"{Math.Clamp(100 - usage.UsedPercent, 0, 100)}%" : "미확인"; - _bar.Remaining = !expired && account.Usage is { } snapshot ? Math.Clamp(100 - snapshot.UsedPercent, 0, 100) : null; - _observed.Text = account.ObservedAt is { } observed ? $"마지막 확인 {observed.ToLocalTime():MM-dd HH:mm}" : "아직 사용량을 확인하지 않았습니다."; - _reset.Text = expired ? "초기화 시점이 지났습니다. ‘사용량 조회’로 확인하세요." : account.Usage?.ResetsAt is { } reset ? $"초기화 예정 {reset.ToLocalTime():MM-dd HH:mm}" : "초기화 예정 —"; - var shortWindow = account.Usage?.ShortWindow; - var shortExpired = shortWindow?.ResetsAt is { } shortReset && shortReset <= DateTimeOffset.Now; - _shortRemaining.Text = shortExpired ? "5시간 잔여 갱신 필요" : shortWindow is null ? "5시간 잔여 정보 없음" : $"5시간 잔여 {100 - shortWindow.UsedPercent}%"; - _shortReset.Text = shortWindow?.ResetsAt is { } shortAt ? $"5시간 초기화 {shortAt.ToLocalTime():MM-dd HH:mm}" : "5시간 초기화 —"; - _switchHelp.Text = account.IsActive ? "이 계정을 사용하고 있습니다. 이름은 언제든 바꿀 수 있습니다." : "전환 준비 화면에서 Codex 종료 상태를 확인합니다."; - _switch.Text = account.IsActive ? "현재 사용 중인 계정" : "이 계정으로 전환"; - UpdateActions(); - } - - private void UpdateActions() - { - var pending = _store.HasPendingRecovery || _usageQueryStatus.State == UsageQueryState.RecoveryRequired; - _accounts.Enabled = !_busy; - _refresh.Enabled = !_busy; - _readUsage.Enabled = !_busy && !pending && Selected is not null; - _add.Enabled = !_busy && !pending && _items.Count > 0; - _register.Enabled = !_busy && !pending; - _registerActive.Visible = _items.Count > 0 && !_items.Any(a => a.IsActive) && !pending; - _registerActive.Enabled = !_busy; - _rename.Enabled = !_busy && !pending && Selected is not null; - _switch.Enabled = !_busy && !pending && _items.Any(a => a.IsActive) && Selected is { IsActive: false }; - _delete.Enabled = !_busy && !pending && Selected is { IsActive: false }; - _recover.Visible = pending || _usageQueryStatus.State == UsageQueryState.CleanupPending; _recover.Enabled = !_busy; - _recover.Text = _store.HasPendingRecovery ? "미완료 전환 복구" : _usageQueryStatus.State == UsageQueryState.CleanupPending ? "임시 파일 정리" : "중단된 조회 복구"; - } - private void SetStatus(string message, bool error = false, bool success = false) { _operationMessage = message; _operationError = error; _operationSuccess = success; @@ -435,16 +356,17 @@ private void RenderStatus() ? "로그인 정보 저장 완료 · 임시 파일 정리 대기" + (_usageQueryStatus.Failure is { } failure ? $" ({failure.Summary})" : "") : null; var fallback = _items.Count == 0 ? "현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다." : !_items.Any(a => a.IsActive) ? "현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요." - : "계정을 선택해 상태를 확인하세요."; - _status.Text = string.Join("\n", new[] { _operationMessage, recovery, cleanup }.Where(text => text is not null)); + : ""; + _status.Text = string.Join("\n", new[] { _operationMessage, recovery, cleanup, _items.Count > 0 && !_items.Any(a => a.IsActive) ? "현재 로그인은 미등록 상태입니다. 먼저 등록해 주세요." : null }.Where(text => text is not null)); if (_status.Text.Length == 0) _status.Text = fallback; _status.ForeColor = _operationError || recovery is not null ? AccountUiTheme.Error : _operationSuccess && cleanup is null ? AccountUiTheme.Accent : AccountUiTheme.Text; + UpdateNotice(); } protected override void Dispose(bool disposing) { - if (disposing) _refreshTimer.Dispose(); + if (disposing) { _refreshTimer.Dispose(); _menu.Dispose(); _pageMenu.Dispose(); } base.Dispose(disposing); } } diff --git a/src/AccountTable.cs b/src/AccountTable.cs new file mode 100644 index 0000000..954aea3 --- /dev/null +++ b/src/AccountTable.cs @@ -0,0 +1,104 @@ +namespace WeeklyUsageIndicator; + +// Native controls keep keyboard actions accessible; refresh updates existing rows without stealing focus. +internal sealed class AccountTable : UserControl +{ + private readonly TableLayoutPanel _rows = AccountUiTheme.Stack(0); + private readonly Dictionary _controls = new(StringComparer.Ordinal); + private int _selectedIndex = -1; + internal List Items { get; } = new(); + internal object? SelectedItem => _selectedIndex >= 0 && _selectedIndex < Items.Count ? Items[_selectedIndex] : null; + internal int SelectedIndex { get => _selectedIndex; set { _selectedIndex = value; SelectedIndexChanged?.Invoke(this, EventArgs.Empty); } } + internal event EventHandler? SelectedIndexChanged; + internal event Action? ManageRequested; + internal event Action? SwitchRequested; + internal Button? SelectedSwitchButton => SelectedItem is SavedCodexAccount a && _controls.TryGetValue(a.Id, out var row) ? row.Switch : null; + internal AccountTable() + { + AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); + Name = "AccountList"; Dock = DockStyle.Fill; AutoScroll = true; + _rows.Dock = DockStyle.Top; _rows.AutoSize = true; _rows.GrowStyle = TableLayoutPanelGrowStyle.AddRows; Controls.Add(_rows); + } + internal void BeginUpdate() => SuspendLayout(); + internal void EndUpdate() => ResumeLayout(); + internal void Display(CombinedUsageSnapshot snapshot, bool busy, bool mutationsAllowed, bool hasActive) + { + var ordered = snapshot.ByReset(); var ids = ordered.Select(r => r.Account.Id).ToHashSet(StringComparer.Ordinal); + foreach (var removed in _controls.Keys.Where(id => !ids.Contains(id)).ToArray()) { _controls[removed].Dispose(); _controls.Remove(removed); } + _rows.SuspendLayout(); _rows.RowCount = ordered.Count; _rows.RowStyles.Clear(); + for (var i = 0; i < ordered.Count; i++) + { + var value = ordered[i]; var id = value.Account.Id; + if (!_controls.TryGetValue(id, out var row)) + { + row = new AccountRow(id); + row.Switch.Click += (_, _) => { SelectId(id); SwitchRequested?.Invoke(id); }; + row.Manage.Click += (_, _) => { SelectId(id); ManageRequested?.Invoke(id, row.Manage); }; + _controls.Add(id, row); _rows.Controls.Add(row); + } + row.TabIndex = i; _rows.SetCellPosition(row, new TableLayoutPanelCellPosition(0, i)); _rows.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + row.Display(value, busy, mutationsAllowed, hasActive); + } + _rows.ResumeLayout(true); + } + private void SelectId(string id) => SelectedIndex = Items.FindIndex(a => a.Id == id); + private sealed class AccountRow : UserControl + { + private readonly Label _name = AccountUiTheme.Label("", 12, true); + private readonly Label _state = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _remaining = AccountUiTheme.Label("", 18, true); + private readonly Label _reset = AccountUiTheme.Label(""); + private readonly Label _relative = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _observed = AccountUiTheme.Label("", 8.5f, color: AccountUiTheme.Muted); + private readonly AccountUsageBar _bar = new() { Dock = DockStyle.Fill }; + internal Button Switch { get; } + internal Button Manage { get; } + internal AccountRow(string id) + { + AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); + Name = "AccountRow-" + id; Dock = DockStyle.Fill; AutoSize = false; Height = 92; Margin = new Padding(0, 0, 0, 2); BackColor = AccountUiTheme.Surface; + Switch = AccountUiTheme.Button("SwitchAccount-" + id, "전환"); Manage = AccountUiTheme.Button("ManageAccount-" + id, "···"); + Switch.AutoSize = Manage.AutoSize = false; Switch.Size = Switch.MinimumSize = new Size(72, 34); Manage.Size = Manage.MinimumSize = new Size(38, 34); Manage.Padding = new Padding(4); + Switch.Anchor = Manage.Anchor = AnchorStyles.Right; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, AutoSize = false, ColumnCount = 5, RowCount = 1, Padding = new Padding(20, 12, 16, 12), Margin = new Padding(0) }; + foreach (var percent in new[] { 37, 25, 38 }) layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, percent)); + layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + var identity = Stack(_name, _state, _observed); identity.Margin = new Padding(0, 0, 14, 0); + var usage = AccountUiTheme.Stack(3); _remaining.Dock = DockStyle.Fill; _remaining.AutoSize = false; usage.Margin = new Padding(0, 0, 30, 0); + foreach (var h in new[] { 34, 5, 26 }) usage.RowStyles.Add(new RowStyle(SizeType.Percent, h)); + usage.Controls.Add(_remaining, 0, 0); usage.Controls.Add(_bar, 0, 1); + var reset = Stack(_reset, _relative); reset.Margin = new Padding(0, 0, 12, 0); + layout.Controls.Add(identity, 0, 0); layout.Controls.Add(usage, 1, 0); layout.Controls.Add(reset, 2, 0); + Switch.Margin = new Padding(0, 0, 8, 0); layout.Controls.Add(Switch, 3, 0); layout.Controls.Add(Manage, 4, 0); Controls.Add(layout); + } + private static TableLayoutPanel Stack(params Label[] labels) + { + var stack = AccountUiTheme.Stack(labels.Length); stack.AutoSize = false; + for (var i = 0; i < labels.Length; i++) + { + stack.RowStyles.Add(new RowStyle(SizeType.Percent, i == 0 ? 28 : 20)); labels[i].Dock = DockStyle.Fill; + labels[i].AutoSize = false; labels[i].AutoEllipsis = true; labels[i].TextAlign = ContentAlignment.MiddleLeft; stack.Controls.Add(labels[i], 0, i); + } + return stack; + } + internal void Display(CombinedAccountUsage value, bool busy, bool mutationsAllowed, bool hasActive) + { + var scale = DeviceDpi / 96f; + Height = (int)Math.Round(88 * scale); + Switch.Size = Switch.MinimumSize = new Size((int)(72 * scale), (int)(34 * scale)); + Manage.Size = Manage.MinimumSize = new Size((int)(38 * scale), (int)(34 * scale)); + var now = DateTimeOffset.Now; var a = value.Account; var expired = a.Usage?.ResetsAt is { } at && at <= now; + _name.Text = a.Label; _state.Text = a.IsActive ? "● 사용 중" : "저장된 계정"; _state.ForeColor = a.IsActive ? AccountUiTheme.Accent : AccountUiTheme.Muted; + _observed.Text = value.State == CombinedReadState.Success && a.ObservedAt is { } observed ? $"{observed.ToLocalTime():MM/dd HH:mm} 확인" : + (a.ObservedAt is { } savedAt ? $"{savedAt.ToLocalTime():MM/dd HH:mm} 값 · " : "") + value.Status(now); + _observed.ForeColor = value.State is CombinedReadState.Failed or CombinedReadState.Canceled ? AccountUiTheme.Warning : AccountUiTheme.Muted; + _remaining.Text = expired ? "갱신 필요" : value.HasWeeklyValue ? $"{(value.State != CombinedReadState.Success ? "이전 " : "")}{100 - a.Usage!.UsedPercent}%" : "—"; + _remaining.ForeColor = value.IsCurrent(now) ? AccountUiTheme.Text : AccountUiTheme.Muted; + _bar.Remaining = !expired && value.HasWeeklyValue ? 100 - a.Usage!.UsedPercent : null; + _reset.Text = a.Usage?.ResetsAt is { } reset ? $"{reset.ToLocalTime():MM/dd HH:mm}" : "초기화 미확인"; + _relative.Text = a.Usage?.ResetsAt is { } r ? CombinedUsagePanel.TimeUntil(r, now) : "전체 갱신으로 확인"; + Switch.Text = a.IsActive ? "사용 중" : "전환"; Switch.Enabled = !busy && mutationsAllowed && hasActive && !a.IsActive; + Switch.AccessibleName = a.Label + (a.IsActive ? " 사용 중" : " 계정으로 전환"); Manage.Enabled = !busy; Manage.AccessibleName = a.Label + " 계정 관리 메뉴"; + } + } +} diff --git a/src/AccountUiTheme.cs b/src/AccountUiTheme.cs index 404fd63..b449842 100644 --- a/src/AccountUiTheme.cs +++ b/src/AccountUiTheme.cs @@ -4,15 +4,15 @@ namespace WeeklyUsageIndicator; internal static class AccountUiTheme { - internal static readonly Color Background = Color.FromArgb(22, 24, 28); - internal static readonly Color Surface = Color.FromArgb(29, 32, 38); - internal static readonly Color Raised = Color.FromArgb(40, 44, 52); - internal static readonly Color Border = Color.FromArgb(58, 64, 74); - internal static readonly Color Text = Color.FromArgb(238, 241, 244); - internal static readonly Color Muted = Color.FromArgb(161, 172, 186); - internal static readonly Color Accent = Color.FromArgb(145, 218, 195); - internal static readonly Color Error = Color.FromArgb(255, 166, 158); - internal static readonly Color Warning = Color.FromArgb(243, 203, 137); + internal static readonly Color Background = Color.FromArgb(246, 247, 244); + internal static readonly Color Surface = Color.FromArgb(255, 255, 253); + internal static readonly Color Raised = Color.FromArgb(233, 237, 232); + internal static readonly Color Border = Color.FromArgb(210, 218, 211); + internal static readonly Color Text = Color.FromArgb(34, 43, 37); + internal static readonly Color Muted = Color.FromArgb(94, 108, 99); + internal static readonly Color Accent = Color.FromArgb(34, 115, 86); + internal static readonly Color Error = Color.FromArgb(171, 52, 42); + internal static readonly Color Warning = Color.FromArgb(143, 86, 13); internal static Label Label(string text, float size = 9.5f, bool bold = false, Color? color = null) => new() { @@ -27,7 +27,7 @@ internal static class AccountUiTheme Padding = new Padding(12, 4, 12, 4), Margin = new Padding(0), FlatStyle = FlatStyle.Flat, BackColor = primary ? Accent : Raised, ForeColor = primary ? Background : Text, Cursor = Cursors.Hand, - UseVisualStyleBackColor = false, FlatAppearance = { BorderSize = 0, MouseOverBackColor = primary ? Color.FromArgb(175, 235, 216) : Color.FromArgb(56, 61, 72) } + UseVisualStyleBackColor = false, FlatAppearance = { BorderSize = 0, MouseOverBackColor = primary ? Color.FromArgb(26, 98, 72) : Color.FromArgb(220, 228, 221) } }; internal static void SetForm(Form form) @@ -65,63 +65,6 @@ protected override void OnPaint(PaintEventArgs e) } } -internal sealed class AccountListBox : ListBox -{ - internal AccountListBox() - { - DrawMode = DrawMode.OwnerDrawFixed; - BorderStyle = BorderStyle.None; - BackColor = AccountUiTheme.Surface; - ForeColor = AccountUiTheme.Text; - IntegralHeight = false; - ItemHeight = 82; - DoubleBuffered = true; - } - - protected override void OnHandleCreated(EventArgs e) - { - base.OnHandleCreated(e); - ItemHeight = (int)Math.Round(82 * DeviceDpi / 96.0); - } - - protected override void OnDpiChangedAfterParent(EventArgs e) - { - base.OnDpiChangedAfterParent(e); - ItemHeight = (int)Math.Round(82 * DeviceDpi / 96.0); - } - - protected override void OnDrawItem(DrawItemEventArgs e) - { - if (e.Index < 0 || e.Index >= Items.Count || Items[e.Index] is not SavedCodexAccount account) return; - var selected = (e.State & DrawItemState.Selected) != 0; - var scale = DeviceDpi / 96f; - int S(int value) => (int)Math.Round(value * scale); - using var background = new SolidBrush(selected ? Color.FromArgb(43, 57, 57) : AccountUiTheme.Surface); - e.Graphics.FillRectangle(background, e.Bounds); - if (selected) - { - using var accent = new SolidBrush(AccountUiTheme.Accent); - e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + S(8), S(3), e.Bounds.Height - S(16)); - } - var inset = S(16); - var badgeWidth = account.IsActive ? S(48) : 0; - var title = new Rectangle(e.Bounds.Left + inset, e.Bounds.Top + S(13), e.Bounds.Width - inset * 2 - badgeWidth, S(25)); - using var titleFont = new Font(Font, FontStyle.Bold); - TextRenderer.DrawText(e.Graphics, account.Label, titleFont, title, AccountUiTheme.Text, - TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); - if (account.IsActive) - { - var badge = new Rectangle(e.Bounds.Right - inset - badgeWidth, title.Top, badgeWidth, title.Height); - TextRenderer.DrawText(e.Graphics, "사용 중", Font, badge, AccountUiTheme.Accent, - TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); - } - var hint = new Rectangle(title.Left, e.Bounds.Top + S(43), e.Bounds.Width - inset * 2, S(24)); - TextRenderer.DrawText(e.Graphics, account.IdentityHint, Font, hint, AccountUiTheme.Muted, - TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | TextFormatFlags.SingleLine); - if ((e.State & DrawItemState.Focus) != 0 && Focused) e.DrawFocusRectangle(); - } -} - internal sealed class AccountUsageBar : Control { private int? _remaining; diff --git a/src/CombinedUsagePanel.cs b/src/CombinedUsagePanel.cs new file mode 100644 index 0000000..5767618 --- /dev/null +++ b/src/CombinedUsagePanel.cs @@ -0,0 +1,64 @@ +namespace WeeklyUsageIndicator; + +internal sealed class CombinedUsagePanel : UserControl +{ + private readonly Label _total = AccountUiTheme.Label("—", 34, true); + private readonly Label _coverage = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _next = AccountUiTheme.Label("—", 18, true); + private readonly Label _nextAccount = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _stamp = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _last = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + internal CombinedUsagePanel() + { + AutoScaleMode = AutoScaleMode.Dpi; AutoScaleDimensions = new SizeF(96, 96); + Name = "CombinedUsagePanel"; BackColor = AccountUiTheme.Surface; + _total.Name = "CombinedRemainingLabel"; _coverage.Name = "CombinedCoverageLabel"; + _next.Name = "NextResetLabel"; _stamp.Name = "CombinedCheckedAtLabel"; _last.Name = "LatestResetLabel"; + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 4, + Padding = new Padding(24, 18, 24, 16), Margin = new Padding(0) }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + foreach (var height in new[] { 24, 60, 28, 26 }) layout.RowStyles.Add(new RowStyle(SizeType.Percent, height)); + layout.Controls.Add(AccountUiTheme.Label("전체 주간 잔여", color: AccountUiTheme.Muted), 0, 0); + layout.Controls.Add(AccountUiTheme.Label("다음 초기화", color: AccountUiTheme.Muted), 1, 0); + layout.Controls.Add(_total, 0, 1); layout.Controls.Add(_next, 1, 1); + layout.Controls.Add(_coverage, 0, 2); layout.Controls.Add(_nextAccount, 1, 2); + layout.Controls.Add(_stamp, 0, 3); layout.Controls.Add(_last, 1, 3); + foreach (var label in new[] { _total, _next, _coverage, _nextAccount, _stamp, _last }) + { label.Dock = DockStyle.Fill; label.AutoSize = false; label.AutoEllipsis = true; label.TextAlign = ContentAlignment.MiddleLeft; } + Controls.Add(layout); + } + internal void Display(CombinedUsageSnapshot snapshot, bool busy, int completed = 0, bool querying = false) + { + var now = DateTimeOffset.Now; var complete = snapshot.IsComplete(now); var count = snapshot.ConfirmedCount(now); + _total.Text = complete ? $"{snapshot.ConfirmedRemaining(now)}%" : "—"; + _total.ForeColor = complete ? AccountUiTheme.Accent : AccountUiTheme.Muted; + _coverage.Text = complete ? $"총 {snapshot.Accounts.Count * 100}% 중 · 계정당 100% 기준" : !snapshot.IsBatch ? "개별 확인값 · 전체 갱신 필요" : count > 0 + ? $"확인된 잔여 {snapshot.ConfirmedRemaining(now)}% · {count}/{snapshot.Accounts.Count}개" : "전체 합계 확인 필요"; + _coverage.ForeColor = complete ? AccountUiTheme.Muted : AccountUiTheme.Warning; + var dated = snapshot.ByReset().Where(row => row.IsCurrent(now)).ToArray(); var next = dated.FirstOrDefault(); + _next.Text = next?.Account.Usage?.ResetsAt is { } reset ? TimeUntil(reset, now) : "확인 필요"; + _nextAccount.Text = next is null ? "계정별 확인 상태를 살펴보세요." : $"{next.Account.Label} · 잔여 {100 - next.Account.Usage!.UsedPercent}% · {next.Account.Usage.ResetsAt!.Value.ToLocalTime():MM/dd HH:mm}"; + var uncertain = snapshot.ByReset().FirstOrDefault(row => !row.IsCurrent(now) && + (row.Account.Usage?.ResetsAt is null || next is null || row.Account.Usage.ResetsAt <= next.Account.Usage!.ResetsAt)); + if (uncertain is not null) + { + _next.Text = "확인 필요"; + _nextAccount.Text = uncertain.Account.Label + (uncertain.Account.Usage?.ResetsAt is { } previous + ? $" · 이전 일정 {previous.ToLocalTime():MM/dd HH:mm}" : " · 초기화 시각 미확인"); + } + var latest = dated.LastOrDefault()?.Account.Usage?.ResetsAt; + _last.Text = latest is { } last ? $"{(complete ? "가장 늦은 초기화" : "확인된 계정 중 마지막")} {last.ToLocalTime():MM/dd HH:mm}" : "초기화 시각은 계정마다 다릅니다."; + _stamp.Text = querying ? $"전체 조회 중 · {completed}/{snapshot.Accounts.Count}개 확인" : snapshot.MembershipChanged ? "계정 구성 변경 · 전체 갱신 필요" + : snapshot.WasCanceled ? "전체 조회 취소 · 다시 갱신해 주세요." : snapshot.Interrupted ? "전체 조회 중단 · 확인 필요" + : !snapshot.IsBatch && count > 0 ? "개별 확인 · 전체 합계 갱신 필요" + : snapshot.CompletedAt is { } checkedAt ? $"{(complete ? "전체 확인" : "일부 확인")} {checkedAt.ToLocalTime():MM/dd HH:mm} 기준" : "저장된 값 · 전체 갱신 필요"; + } + internal static string TimeUntil(DateTimeOffset reset, DateTimeOffset now) + { + var span = reset - now; + if (span <= TimeSpan.Zero) return "갱신 필요"; + if (span.TotalDays >= 1) return $"{(int)span.TotalDays}일 {span.Hours}시간 뒤"; + if (span.TotalHours >= 1) return $"{(int)span.TotalHours}시간 {span.Minutes}분 뒤"; + return $"{Math.Max(1, (int)Math.Ceiling(span.TotalMinutes))}분 뒤"; + } +} diff --git a/src/CombinedUsageSnapshot.cs b/src/CombinedUsageSnapshot.cs new file mode 100644 index 0000000..72fdfe1 --- /dev/null +++ b/src/CombinedUsageSnapshot.cs @@ -0,0 +1,66 @@ +namespace WeeklyUsageIndicator; + +internal enum CombinedReadState { Saved, Waiting, Success, Failed, Canceled } +internal sealed record CombinedAccountUsage(SavedCodexAccount Account, CombinedReadState State, string? Error = null) +{ + internal bool HasWeeklyValue => Account.Usage is { UsedPercent: >= 0 and <= 100, WindowDurationMinutes: 10080 }; + internal bool IsCurrent(DateTimeOffset now) => State == CombinedReadState.Success && HasWeeklyValue && + Account.ObservedAt is not null && Account.Usage!.ResetsAt is { } reset && reset > now; + internal string Status(DateTimeOffset now) + { + if (State == CombinedReadState.Failed) return Error ?? "조회 실패"; + if (State == CombinedReadState.Canceled) return "조회 취소 · 이전 값"; + if (State == CombinedReadState.Waiting) return "이번 전체 조회에서 미확인"; + if (!HasWeeklyValue) return "주간 사용량 미확인"; + if (Account.Usage!.ResetsAt is null) return "초기화 시각 미확인"; + if (Account.Usage.ResetsAt <= now) return "초기화 시점 지남 · 갱신 필요"; + return State == CombinedReadState.Saved ? "저장된 값 · 전체 갱신 필요" : "확인 완료"; + } +} + +// A single in-memory observation set, never a usage history or a live sum of independently polling accounts. +internal sealed class CombinedUsageSnapshot +{ + internal IReadOnlyList Accounts { get; private set; } = Array.Empty(); + internal DateTimeOffset? CompletedAt { get; set; } + internal bool WasCanceled { get; set; } + internal bool Interrupted { get; set; } + internal bool MembershipChanged { get; private set; } + internal bool IsBatch { get; private set; } + + internal void Initialize(IReadOnlyList accounts, bool batch) + { + Accounts = accounts.Select(a => new CombinedAccountUsage(a, batch ? CombinedReadState.Waiting : CombinedReadState.Saved)).ToArray(); + CompletedAt = null; WasCanceled = false; Interrupted = false; MembershipChanged = false; IsBatch = batch; + } + + internal void SetResult(string id, SavedCodexAccount account, CombinedReadState state, string? error = null) => + Accounts = Accounts.Select(row => row.Account.Id == id ? new CombinedAccountUsage(account, state, error) : row).ToArray(); + + internal void RecordIndividual(SavedCodexAccount account) + { + // An explicit individual read changes the visible list. Require a new batch to certify the total. + SetResult(account.Id, account, CombinedReadState.Success); + IsBatch = false; CompletedAt = DateTimeOffset.UtcNow; WasCanceled = false; Interrupted = false; + } + + internal bool Reconcile(IReadOnlyList current) + { + var ids = Accounts.Select(row => row.Account.Id).ToHashSet(StringComparer.Ordinal); + var changed = !ids.SetEquals(current.Select(a => a.Id)); + if (changed) MembershipChanged = true; + var old = Accounts.ToDictionary(row => row.Account.Id, StringComparer.Ordinal); + // Names and active markers may change locally; percentages and check times stay frozen. + Accounts = current.Select(a => old.TryGetValue(a.Id, out var row) + ? row with { Account = row.Account with { Label = a.Label, IsActive = a.IsActive } } + : new CombinedAccountUsage(a, CombinedReadState.Saved)).ToArray(); + return !changed; + } + + internal bool IsComplete(DateTimeOffset now) => Accounts.Count > 0 && IsBatch && CompletedAt is not null && + !WasCanceled && !Interrupted && !MembershipChanged && Accounts.All(row => row.IsCurrent(now)); + internal int ConfirmedCount(DateTimeOffset now) => Accounts.Count(row => row.IsCurrent(now)); + internal int ConfirmedRemaining(DateTimeOffset now) => Accounts.Where(row => row.IsCurrent(now)).Sum(row => 100 - row.Account.Usage!.UsedPercent); + internal IReadOnlyList ByReset() => Accounts.OrderBy(row => row.Account.Usage?.ResetsAt ?? DateTimeOffset.MaxValue) + .ThenBy(row => row.Account.Label, StringComparer.CurrentCulture).ToArray(); +} diff --git a/src/Program.cs b/src/Program.cs index 3048264..d97554e 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -62,7 +62,7 @@ internal sealed class UsageIndicatorForm : Form private readonly CodexDesktopStateReader _codexStateReader = new(); private readonly System.Windows.Forms.Timer _pollTimer = new() { Interval = 60_000 }; private readonly System.Windows.Forms.Timer _codexStateTimer = new() { Interval = 1_000 }; - private readonly ToolTip _toolTip = new() + private readonly UsageToolTip _toolTip = new() { InitialDelay = 350, ReshowDelay = 100, @@ -86,6 +86,7 @@ internal sealed class UsageIndicatorForm : Form private bool _positionInitialized; private readonly CodexAccountStore _accountStore = new(); private AccountManagerForm? _accountManager; + private readonly CombinedUsageSnapshot _combinedUsage = new(); private NotifyIcon? _accountTray; private bool _accountBusy; private long _accountGeneration; @@ -146,7 +147,7 @@ public UsageIndicatorForm(bool previewMode, bool openAccounts = false) if (openAccounts) ShowAccountManager(); }; - MouseEnter += (_, _) => { _isHovered = true; Invalidate(); }; + MouseEnter += (_, _) => { _isHovered = true; UpdateToolTip(); Invalidate(); }; MouseLeave += (_, _) => { _isHovered = false; Invalidate(); }; MouseDown += HandleMouseDown; MouseMove += HandleMouseMove; @@ -397,12 +398,20 @@ private async Task RefreshUsageAsync(bool force = false) private void UpdateToolTip() { - _toolTip.SetToolTip(this, BuildTooltipText( - _codexSnapshot, - _claudeUsage, - _codexError, - _claudeError, - _showClaude)); + IReadOnlyList accounts = Array.Empty(); + if (!_previewMode) + { + try + { + accounts = _accountStore.IsEnabled ? _accountStore.ListAccounts() : Array.Empty(); + if (_combinedUsage.Accounts.Count == 0) _combinedUsage.Initialize(accounts, batch: false); + else _combinedUsage.Reconcile(accounts); + } + catch (AccountStoreBusyException) { } + catch (Exception) { } + } + _toolTip.SetToolTip(this, BuildTooltipText(_codexSnapshot, _claudeUsage, _codexError, _claudeError, + _showClaude, _combinedUsage, accounts.FirstOrDefault(a => a.IsActive)?.Label)); } private async Task RefreshCodexAsync() @@ -446,7 +455,7 @@ private void ShowAccountManager() if (_previewMode) return; if (_accountManager is null || _accountManager.IsDisposed) _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts, - queryUsage: QueryAccountUsageAsync); + queryUsage: QueryAccountUsageAsync, combined: _combinedUsage, usageChanged: UpdateToolTip); _accountManager.Show(); _accountManager.Activate(); } @@ -630,15 +639,9 @@ private void DrawProvider( } } - internal static string BuildTooltipText( - UsageSnapshot? codex, - ClaudeUsageResult? claude, - string? codexError, - string? claudeError, - bool showClaude) - { - return BuildDetailsText(codex, claude, codexError, claudeError, showClaude, includeControls: true); - } + internal static string BuildTooltipText(UsageSnapshot? codex, ClaudeUsageResult? claude, + string? codexError, string? claudeError, bool showClaude, CombinedUsageSnapshot? combined = null, string? activeLabel = null) + => UsageTooltipText.Build(codex, claude, codexError, claudeError, showClaude, combined, activeLabel); private static string BuildClipboardText( UsageSnapshot? codex, diff --git a/src/UsageToolTip.cs b/src/UsageToolTip.cs new file mode 100644 index 0000000..9b186f2 --- /dev/null +++ b/src/UsageToolTip.cs @@ -0,0 +1,48 @@ +namespace WeeklyUsageIndicator; + +// The native ToolTip window stays non-activating; only text layout and light colors are customized. +internal sealed class UsageToolTip : ToolTip +{ + private readonly Font _font = new("맑은 고딕", 9.5f); + private readonly Font _heading = new("맑은 고딕", 9.5f, FontStyle.Bold); + private float _scale = 1; + internal UsageToolTip() + { + OwnerDraw = true; UseAnimation = false; UseFading = false; + Popup += (_, e) => + { + _scale = (e.AssociatedControl?.DeviceDpi ?? 96) / 96f; + var text = GetToolTip(e.AssociatedControl) ?? ""; + e.ToolTipSize = Measure(text, _scale); + }; + Draw += (_, e) => Render(e.Graphics, e.Bounds, e.ToolTipText ?? "", _scale); + } + private static bool IsHeading(string line) => line.StartsWith("CODEX", StringComparison.Ordinal) || + line.StartsWith("CLAUDE", StringComparison.Ordinal) || line.StartsWith("전체 계정", StringComparison.Ordinal); + internal Size Measure(string text, float scale) + { + var padding = (int)(16 * scale); var width = (int)(490 * scale); var height = padding * 2; + foreach (var line in text.Split('\n')) + height += line.Length == 0 ? (int)(10 * scale) : LineHeight(line, width, scale); + return new Size(width + padding * 2, height); + } + private int LineHeight(string line, int width, float scale) => TextRenderer.MeasureText(line.TrimEnd('\r'), + IsHeading(line) ? _heading : _font, new Size(width, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.NoPrefix).Height + (int)(3 * scale); + internal void Render(Graphics graphics, Rectangle bounds, string text, float scale) + { + using var background = new SolidBrush(AccountUiTheme.Surface); using var border = new Pen(AccountUiTheme.Border); + graphics.FillRectangle(background, bounds); graphics.DrawRectangle(border, bounds.X, bounds.Y, bounds.Width - 1, bounds.Height - 1); + var padding = (int)(16 * scale); var width = bounds.Width - padding * 2; var y = bounds.Top + padding; + foreach (var line in text.Split('\n')) + { + if (line.Length == 0) { y += (int)(10 * scale); continue; } + var height = LineHeight(line, width, scale); var heading = IsHeading(line); + TextRenderer.DrawText(graphics, line.TrimEnd('\r'), heading ? _heading : _font, + new Rectangle(bounds.Left + padding, y, width, height), heading ? AccountUiTheme.Accent : AccountUiTheme.Text, + TextFormatFlags.WordBreak | TextFormatFlags.NoPrefix); + y += height; + } + } + protected override void Dispose(bool disposing) + { if (disposing) { _font.Dispose(); _heading.Dispose(); } base.Dispose(disposing); } +} diff --git a/src/UsageTooltipText.cs b/src/UsageTooltipText.cs new file mode 100644 index 0000000..b754b33 --- /dev/null +++ b/src/UsageTooltipText.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace WeeklyUsageIndicator; + +internal static class UsageTooltipText +{ + internal static string Build(UsageSnapshot? codex, ClaudeUsageResult? claude, string? codexError, + string? claudeError, bool showClaude, CombinedUsageSnapshot? combined = null, string? activeLabel = null) + { + var now = DateTimeOffset.Now; var text = new StringBuilder(); + text.AppendLine("CODEX · " + (activeLabel is null ? "현재 계정" : Name(activeLabel))); + text.AppendLine(Window("주간", codex?.UsedPercent, codex?.ResetsAt, now)); + if (codex?.ShortWindow is { } shortWindow) text.AppendLine(Window("5시간", shortWindow.UsedPercent, shortWindow.ResetsAt, now)); + if (codexError is not null) text.AppendLine("확인 필요: " + codexError); + if (combined is { Accounts.Count: > 0 }) + { + text.AppendLine(); + text.AppendLine(combined.IsComplete(now) + ? $"전체 계정 · 주간 잔여 {combined.ConfirmedRemaining(now)}% / {combined.Accounts.Count * 100}%" + : "전체 계정 · 합계 갱신 필요"); + text.AppendLine(!combined.IsBatch && combined.CompletedAt is not null ? "개별 확인값 · 전체 합계 갱신 필요" : combined.CompletedAt is { } at + ? $"{(combined.IsComplete(now) ? "전체 확인" : "마지막 조회")} {at.ToLocalTime():MM/dd HH:mm} 기준 · 계정당 100%" + : "저장된 값 · 계정 관리에서 전체 갱신해 주세요."); + if (combined.IsBatch && !combined.IsComplete(now) && combined.ConfirmedCount(now) > 0) + text.AppendLine($"확인된 잔여 {combined.ConfirmedRemaining(now)}% · {combined.ConfirmedCount(now)}/{combined.Accounts.Count}개"); + foreach (var row in combined.ByReset().Take(5)) + { + var a = row.Account; + var remaining = a.Usage?.ResetsAt <= now ? "갱신 필요" : row.HasWeeklyValue + ? $"{(row.State == CombinedReadState.Success ? "" : "이전 ")}{100 - a.Usage!.UsedPercent}%" : "미확인"; + var state = row.State == CombinedReadState.Failed ? " · 조회 실패" : row.State == CombinedReadState.Canceled ? " · 조회 취소" : ""; + text.AppendLine($"{Name(a.Label)}{(a.IsActive ? " [사용 중]" : "")} {remaining}{state}"); + text.AppendLine($" 초기화 {Reset(a.Usage?.ResetsAt, now)}" + + (row.State != CombinedReadState.Success && a.ObservedAt is { } savedAt ? $" · {savedAt.ToLocalTime():MM/dd HH:mm} 값" : "")); + } + if (combined.Accounts.Count > 5) text.AppendLine($"외 {combined.Accounts.Count - 5}개 · 계정 관리에서 모두 보기"); + } + if (showClaude) + { + text.AppendLine(); text.AppendLine("CLAUDE"); + var snapshot = claudeError is null ? claude?.Snapshot : null; + text.AppendLine(Window("Fable", snapshot?.Fable?.UsedPercent, snapshot?.Fable?.ResetsAt, now)); + text.AppendLine(Window("5시간", snapshot?.FiveHour?.UsedPercent, snapshot?.FiveHour?.ResetsAt, now)); + text.AppendLine(Window("전체 주간", snapshot?.Weekly?.UsedPercent, snapshot?.Weekly?.ResetsAt, now)); + if (claudeError is not null) text.AppendLine("확인 필요: " + claudeError); + else if (claude is { IsStale: true }) + { + text.AppendLine($"업데이트 지연 · 마지막 성공 {claude.LastUpdatedAt.ToLocalTime():MM/dd HH:mm}"); + if (claude.RetryAfter is { } retry) text.AppendLine($"다음 시도 {Reset(retry, now)}"); + } + } + text.AppendLine(); text.Append("우클릭 → Codex 계정 관리 · 전체 갱신 / 계정 전환"); + return text.ToString(); + } + private static string Window(string label, double? used, DateTimeOffset? reset, DateTimeOffset now) + { + if (used is null) return label + ": 정보 없음"; + var value = reset <= now ? "갱신 필요" : $"{100 - (int)Math.Round(Math.Clamp(used.Value, 0, 100), MidpointRounding.AwayFromZero)}% 남음"; + return $"{label}: {value} · 초기화: {Reset(reset, now)}"; + } + private static string Reset(DateTimeOffset? reset, DateTimeOffset now) => reset is { } at + ? $"{at.ToLocalTime():MM/dd HH:mm} ({CombinedUsagePanel.TimeUntil(at, now)})" : "정보 없음"; + private static string Name(string text) => text.Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' '); +} diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index 9e5ca26..a6e4cbd 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.6.1 + 1.7.0 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs index 629795e..840da24 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs @@ -27,19 +27,19 @@ internal static Task RunAsync() File.WriteAllBytes(Path.Combine(home, "auth.json"), Fixture("a")); var store = new CodexAccountStore(Path.Combine(root, "vault"), home); var suspended = 0; var resumed = 0; - using var form = new AccountManagerForm(store, () => { suspended++; return Task.CompletedTask; }, () => resumed++); + using var form = new AccountManagerForm(store, () => { suspended++; return Task.CompletedTask; }, () => resumed++, refreshAllOnOpen: false); form.Show(); Application.DoEvents(); Capture(form, output, "-empty"); Button(form, "RegisterCurrentButton").PerformClick(); Check(store.IsEnabled && store.ListAccounts().Single().Label == "계정 1", "registration needs no name entry"); - var list = Find(form, "AccountList"); + var list = Find(form, "AccountList"); Check(list.Items.Count == 1 && list.SelectedIndex == 0, "registration immediately selects the account"); Check(!form.IsOperationInProgress && suspended == 1 && resumed == 1, "registration restores operation state"); - Check(!Button(form, "SwitchAccountButton").Enabled && !Button(form, "DeleteAccountButton").Enabled, "current account cannot switch or delete"); - Check(Button(form, "RenameAccountButton").Enabled, "current account can rename"); + Check(!form.SelectedSwitchButton!.Enabled && !form.MenuAction("DeleteAccountButton").Enabled, "current account cannot switch or delete"); + Check(form.MenuAction("RenameAccountButton").Enabled, "current account can rename"); - RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + RespondToDialog(() => form.MenuAction("RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => { var input = Find(dialog, "AccountNameTextBox"); Check(input.Text == "계정 1", "rename opens with current name"); @@ -67,7 +67,7 @@ internal static Task RunAsync() }); Check(store.ListAccounts().Single().Label == "Pro A · 주 계정", "save renames current account"); Check(suspended == 1 && resumed == 1, "metadata rename must not restart helper"); - RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + RespondToDialog(() => form.MenuAction("RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => { Find(dialog, "AccountNameTextBox").Text = "discard this"; Button(dialog, "CancelNameButton").PerformClick(); @@ -82,7 +82,7 @@ internal static Task RunAsync() Button(dialog, "CancelNameButton").PerformClick(); }); Check(store.ListAccounts().Count == 1 && suspended == 1, "canceling add must not start login or interrupt helper"); - RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + RespondToDialog(() => form.MenuAction("RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => { Find(dialog, "AccountNameTextBox").Text = " "; Button(dialog, "SaveNameButton").PerformClick(); @@ -93,12 +93,12 @@ internal static Task RunAsync() var second = Path.Combine(root, "second.json"); File.WriteAllBytes(second, Fixture("b")); store.ImportLoginFile(second, "Pro B · 추가 계정"); - Button(form, "RefreshAccountsButton").PerformClick(); + form.MenuAction("RefreshAccountsButton").PerformClick(); Check(list.Items.Count == 2, "refresh loads saved accounts"); list.SelectedIndex = 1; Application.DoEvents(); - Check(Button(form, "SwitchAccountButton").Enabled && Button(form, "DeleteAccountButton").Enabled, "saved account exposes switch and delete"); - RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + Check(form.SelectedSwitchButton!.Enabled && form.MenuAction("DeleteAccountButton").Enabled, "saved account exposes switch and delete"); + RespondToDialog(() => form.MenuAction("RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => { Find(dialog, "AccountNameTextBox").Text = "Pro B · 보조 계정"; Button(dialog, "SaveNameButton").PerformClick(); @@ -142,14 +142,14 @@ internal static Task RunAsync() Capture(form, output, ""); form.Size = form.MinimumSize; Capture(form, output, "-minimum"); - var detail = Find(form, "AccountDetailPanel"); - detail.ScrollControlIntoView(Button(form, "DeleteAccountButton")); + var switchButton = form.SelectedSwitchButton!; + list.ScrollControlIntoView(switchButton); Application.DoEvents(); - Check(detail.RectangleToScreen(detail.ClientRectangle).Contains(Button(form, "DeleteAccountButton").RectangleToScreen(Button(form, "DeleteAccountButton").ClientRectangle)), "minimum-size detail scroll exposes lower actions"); + Check(list.RectangleToScreen(list.ClientRectangle).Contains(switchButton.RectangleToScreen(switchButton.ClientRectangle)), "minimum-size account list exposes row actions by scrolling"); Capture(form, output, "-minimum-actions"); File.WriteAllBytes(Path.Combine(home, "auth.json"), Fixture("c")); - Button(form, "RefreshAccountsButton").PerformClick(); - Check(Button(form, "RegisterActiveAccountButton").Visible && !Button(form, "SwitchAccountButton").Enabled, "unregistered current account has a registration path before switching"); + form.MenuAction("RefreshAccountsButton").PerformClick(); + Check(Button(form, "RegisterActiveAccountButton").Visible && !form.SelectedSwitchButton!.Enabled, "unregistered current account has a registration path before switching"); Capture(form, output, "-unregistered"); Button(form, "RegisterActiveAccountButton").PerformClick(); Check(store.ListAccounts().Count == 3 && store.ListAccounts().Single(a => a.IsActive).Label == "계정 1", "external current account can register without re-login"); diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs index 22a7b32..d32948d 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs @@ -35,19 +35,19 @@ internal static Task RunAsync() if (mode is "failure" or "cleanup-failure") throw new IOException("synthetic failure"); if (mode == "cleanup-cancel") throw new OperationCanceledException(); return AccountUsageQueryTests.Result(); - }, token), token)); + }, token), token), refreshAllOnOpen: false); T Find(string name) where T : Control => (T)form.Controls.Find(name, true).Single(); form.Shown += async (_, _) => { try { - var list = Find("AccountList"); + var list = Find("AccountList"); list.SelectedIndex = list.Items.Cast().ToList().FindIndex(a => a.Id == target.Id); - var button = Find