commit 81bd94667797153b7be8159d2144c2a1bcd61535
parent 82353b2e2157ce69e994357fd675816605a495e2
Author: triesap <tyson@radroots.org>
Date: Sat, 1 Aug 2026 18:28:41 +0000
accounts: implement confirmed account removal
- track one explicit pending removal target
- support cancellation and reject mismatched confirmation
- preserve selection when removing an unselected account
- select deterministic neighbors when removing the active account
Diffstat:
2 files changed, 162 insertions(+), 1 deletion(-)
diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/state/AccountsReducer.kt
@@ -38,8 +38,13 @@ class AccountsReducer(
accountId = action.accountId,
loginStatus = LoginStatus.LoggedOut,
)
+ is AccountsAction.RequestRemoveAccount -> requestRemoval(state, action)
+ AccountsAction.CancelRemoveAccount -> state.copy(
+ pendingRemovalAccountId = null,
+ problem = null,
+ )
+ is AccountsAction.ConfirmRemoveAccount -> confirmRemoval(state, action)
AccountsAction.DismissProblem -> state.copy(problem = null)
- else -> state
}
return nextState.requireValid()
}
@@ -103,4 +108,44 @@ class AccountsReducer(
problem = null,
)
}
+
+ private fun requestRemoval(
+ state: AccountsState,
+ action: AccountsAction.RequestRemoveAccount,
+ ): AccountsState = if (state.accounts.any { it.id == action.accountId }) {
+ state.copy(
+ pendingRemovalAccountId = action.accountId,
+ problem = null,
+ )
+ } else {
+ state.copy(problem = AccountsProblem.AccountNotFound(action.accountId))
+ }
+
+ private fun confirmRemoval(
+ state: AccountsState,
+ action: AccountsAction.ConfirmRemoveAccount,
+ ): AccountsState {
+ if (state.pendingRemovalAccountId != action.accountId) {
+ return state.copy(
+ problem = AccountsProblem.RemovalTargetMismatch(
+ expectedAccountId = state.pendingRemovalAccountId,
+ actualAccountId = action.accountId,
+ ),
+ )
+ }
+
+ val removedIndex = state.accounts.indexOfFirst { it.id == action.accountId }
+ val remainingAccounts = state.accounts.filterNot { it.id == action.accountId }
+ val selectedAccountId = if (state.selectedAccountId != action.accountId) {
+ state.selectedAccountId
+ } else {
+ remainingAccounts.getOrNull(removedIndex)?.id ?: remainingAccounts.lastOrNull()?.id
+ }
+ return state.copy(
+ accounts = remainingAccounts,
+ selectedAccountId = selectedAccountId,
+ pendingRemovalAccountId = null,
+ problem = null,
+ )
+ }
}
diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/state/AccountsReducerTest.kt
@@ -172,6 +172,114 @@ class AccountsReducerTest {
}
}
+ @Test
+ fun removalRequestTargetsAnExistingAccountAndCanBeCancelled() {
+ val first = testAccount()
+ val second = testAccount(id = "account-2", displayName = "Second Account")
+ val base = testAccountsState(first, second)
+ val reducer = reducerWithIds("unused")
+
+ val firstRequest = reducer.reduce(
+ base,
+ AccountsAction.RequestRemoveAccount(first.id),
+ )
+ val replacedRequest = reducer.reduce(
+ firstRequest,
+ AccountsAction.RequestRemoveAccount(second.id),
+ )
+ val missingId = AccountId("missing")
+ val missingRequest = reducer.reduce(
+ replacedRequest,
+ AccountsAction.RequestRemoveAccount(missingId),
+ )
+ val cancelled = reducer.reduce(
+ replacedRequest.copy(problem = AccountsProblem.InvalidServerUrl),
+ AccountsAction.CancelRemoveAccount,
+ )
+
+ assertEquals(first.id, firstRequest.pendingRemovalAccountId)
+ assertEquals(second.id, replacedRequest.pendingRemovalAccountId)
+ assertEquals(second.id, missingRequest.pendingRemovalAccountId)
+ assertEquals(AccountsProblem.AccountNotFound(missingId), missingRequest.problem)
+ assertEquals(null, cancelled.pendingRemovalAccountId)
+ assertEquals(null, cancelled.problem)
+ assertEquals(base.accounts, cancelled.accounts)
+ }
+
+ @Test
+ fun confirmationRequiresThePendingExplicitTarget() {
+ val first = testAccount()
+ val second = testAccount(id = "account-2", displayName = "Second Account")
+ val reducer = reducerWithIds("unused")
+ val base = testAccountsState(first, second)
+
+ val withoutRequest = reducer.reduce(
+ base,
+ AccountsAction.ConfirmRemoveAccount(first.id),
+ )
+ val requested = reducer.reduce(
+ base,
+ AccountsAction.RequestRemoveAccount(first.id),
+ )
+ val mismatch = reducer.reduce(
+ requested,
+ AccountsAction.ConfirmRemoveAccount(second.id),
+ )
+
+ assertEquals(
+ AccountsProblem.RemovalTargetMismatch(null, first.id),
+ withoutRequest.problem,
+ )
+ assertEquals(base.accounts, withoutRequest.accounts)
+ assertEquals(
+ AccountsProblem.RemovalTargetMismatch(first.id, second.id),
+ mismatch.problem,
+ )
+ assertEquals(requested.accounts, mismatch.accounts)
+ assertEquals(first.id, mismatch.pendingRemovalAccountId)
+ }
+
+ @Test
+ fun removingSelectedAccountChoosesTheDeterministicNeighbor() {
+ val first = testAccount()
+ val second = testAccount(id = "account-2", displayName = "Second Account")
+ val third = testAccount(id = "account-3", displayName = "Third Account")
+ val reducer = reducerWithIds("unused")
+
+ val removedFirst = reducer.removeSelected(testAccountsState(first, second, third), first.id)
+ val removedMiddle = reducer.removeSelected(
+ testAccountsState(first, second, third, selectedAccountId = second.id),
+ second.id,
+ )
+ val removedFinal = reducer.removeSelected(
+ testAccountsState(first, second, third, selectedAccountId = third.id),
+ third.id,
+ )
+
+ assertEquals(listOf(second, third), removedFirst.accounts)
+ assertEquals(second.id, removedFirst.selectedAccountId)
+ assertEquals(listOf(first, third), removedMiddle.accounts)
+ assertEquals(third.id, removedMiddle.selectedAccountId)
+ assertEquals(listOf(first, second), removedFinal.accounts)
+ assertEquals(second.id, removedFinal.selectedAccountId)
+ }
+
+ @Test
+ fun removingUnselectedOrFinalAccountPreservesValidSelection() {
+ val first = testAccount()
+ val second = testAccount(id = "account-2", displayName = "Second Account")
+ val reducer = reducerWithIds("unused")
+
+ val unselected = reducer.removeSelected(testAccountsState(first, second), second.id)
+ val finalAccount = reducer.removeSelected(testAccountsState(first), first.id)
+
+ assertEquals(listOf(first), unselected.accounts)
+ assertEquals(first.id, unselected.selectedAccountId)
+ assertEquals(emptyList(), finalAccount.accounts)
+ assertEquals(null, finalAccount.selectedAccountId)
+ assertEquals(null, finalAccount.pendingRemovalAccountId)
+ }
+
private fun draftState(
displayName: String,
serverUrl: String,
@@ -184,6 +292,14 @@ class AccountsReducerTest {
private fun reducerWithIds(vararg ids: String) =
AccountsReducer(RecordingIdFactory(*ids))
+
+ private fun AccountsReducer.removeSelected(
+ state: AccountsState,
+ accountId: AccountId,
+ ): AccountsState {
+ val requested = reduce(state, AccountsAction.RequestRemoveAccount(accountId))
+ return reduce(requested, AccountsAction.ConfirmRemoveAccount(accountId))
+ }
}
private class RecordingIdFactory(