From e2ae4ef79fa1b724fe0b4873217b16d138f1119a Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:53:50 +0900 Subject: [PATCH 01/61] =?UTF-8?q?[#76]=20fix:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=97=90=20=EB=A7=8C=EB=A3=8C=EC=9D=BC=C2=B7?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A0=95=EA=B7=9C=ED=99=94=EA=B0=80=20?= =?UTF-8?q?=EB=B9=A0=EC=A0=B8=20=EC=9E=88=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성은 normalizedDraft가 만료일을 그 날 23:59:59로 고정하는데 patch overload 4개에는 그 단계가 없었다. 수정 화면이 붙으면 DatePicker가 준 시각이 그대로 저장돼, 같은 날짜를 골라도 생성한 시크릿과 수정한 시크릿의 만료 시각이 달라지고 만료 임박 표기와 Expired 컬렉션 판정이 하루씩 어긋남 - 이름 trim과 빈 이름 거부도 생성 경로에만 있어 patch로는 공백만 있는 이름이 저장될 수 있었다. 규칙을 normalizedName으로 뽑아 두 경로가 같은 정의를 공유하게 함 - 만료일 삭제 요청(.set(nil))과 .unchanged는 그대로 둔다 — 앵커링 대상은 실제 날짜가 실린 경우뿐 - 정규화를 암호화·인코딩보다 앞에 두어 거부될 patch에 크립토 작업이 들어가지 않게 함 --- .../Impl/Secret/PatchSecretUseCaseImpl.swift | 9 +- .../Impl/Secret/SecretUseCaseHelper.swift | 32 +++++- .../Interface/Secret/PatchSecretUseCase.swift | 6 + .../Helper/SecretUseCaseHelperTests.swift | 81 ++++++++++++++ .../Secret/PatchSecretUseCaseImplTests.swift | 105 ++++++++++++++++++ 5 files changed, 225 insertions(+), 8 deletions(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift index 56b0064e..07814078 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift @@ -23,7 +23,7 @@ public struct PatchSecretUseCaseImpl: PatchSecretUseCase { projectIDs: PatchField<[UUID]> ) async throws -> Secret { do { - var fullPatch = patch + var fullPatch = try SecretUseCaseHelper.normalizedPatch(patch) fullPatch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(fullPatch, now: dateProvider()) return try await applyPatch(id: id, patch: fullPatch, projectIDs: projectIDs) } catch { @@ -38,8 +38,9 @@ public struct PatchSecretUseCaseImpl: PatchSecretUseCase { projectIDs: PatchField<[UUID]> ) async throws -> Secret { do { + // 정규화를 인코딩·암호화보다 먼저 한다 — 거부될 patch에 크립토 작업을 들일 이유가 없다. + var fullPatch = try SecretUseCaseHelper.normalizedPatch(patch) let encodedMetadata = try cryptoService.encodeMetadata(metadata) - var fullPatch = patch fullPatch.metadata = .set(encodedMetadata) fullPatch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(fullPatch, now: dateProvider()) return try await applyPatch(id: id, patch: fullPatch, projectIDs: projectIDs) @@ -55,8 +56,8 @@ public struct PatchSecretUseCaseImpl: PatchSecretUseCase { projectIDs: PatchField<[UUID]> ) async throws -> Secret { do { + var fullPatch = try SecretUseCaseHelper.normalizedPatch(patch) let encryptedPayload = try await cryptoService.encryptPayload(payload) - var fullPatch = patch fullPatch.payload = .set(encryptedPayload) fullPatch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(fullPatch, now: dateProvider()) return try await applyPatch(id: id, patch: fullPatch, projectIDs: projectIDs) @@ -73,9 +74,9 @@ public struct PatchSecretUseCaseImpl: PatchSecretUseCase { projectIDs: PatchField<[UUID]> ) async throws -> Secret { do { + var fullPatch = try SecretUseCaseHelper.normalizedPatch(patch) let encryptedPayload = try await cryptoService.encryptPayload(payload) let encodedMetadata = try cryptoService.encodeMetadata(metadata) - var fullPatch = patch fullPatch.payload = .set(encryptedPayload) fullPatch.metadata = .set(encodedMetadata) fullPatch = SecretUseCaseHelper.settingUpdatedAtIfNeeded(fullPatch, now: dateProvider()) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift index aa38ec85..16334b42 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift @@ -5,15 +5,39 @@ import Foundation enum SecretUseCaseHelper { /// Secret 초안의 이름을 정규화하고, expiresAt이 있으면 그 날의 23:59:59로 고정한 초안을 반환합니다. static func normalizedDraft(_ draft: SecretDraft) throws -> SecretDraft { - let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { throw SecretUseCaseError.invalidName } - var normalized = draft - normalized.name = name + normalized.name = try Self.normalizedName(draft.name) normalized.expiresAt = normalized.expiresAt.map(Self.endOfDay) return normalized } + /// 변경 요청의 이름과 만료일을 생성 경로(``normalizedDraft(_:)``)와 같은 규칙으로 정규화합니다. + /// `.unchanged`인 필드는 건드리지 않습니다. + /// + /// 두 경로가 갈리면 사용자가 같은 날짜를 골라도 **생성한 시크릿과 수정한 시크릿의 만료 시각이 달라져**, + /// 만료 임박 표기와 Expired 컬렉션 판정이 하루씩 어긋납니다. + static func normalizedPatch(_ patch: SecretPatch) throws -> SecretPatch { + var normalized = patch + + if case .set(let rawName) = normalized.name { + normalized.name = .set(try Self.normalizedName(rawName)) + } + + // `.set(nil)`은 만료일을 지우는 요청이므로 그대로 둡니다. + if case .set(let expiresAt?) = normalized.expiresAt { + normalized.expiresAt = .set(Self.endOfDay(expiresAt)) + } + + return normalized + } + + /// 생성·수정이 공유하는 이름 규칙. 앞뒤 공백을 제거하고, 남는 것이 없으면 거부합니다. + private static func normalizedName(_ rawName: String) throws -> String { + let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw SecretUseCaseError.invalidName } + return name + } + private static func endOfDay(_ date: Date) -> Date { Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: date) ?? date } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift index 64fad7db..8212476a 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -2,6 +2,12 @@ import Foundation +/// 이미 저장된 Secret의 일부를 변경합니다. +/// +/// 네 overload 모두 `patch`를 저장 전에 정규화합니다 — `name`은 앞뒤 공백이 제거되고(공백만 남으면 +/// ``SecretUseCaseError/invalidName``), `expiresAt`은 그 날의 23:59:59로 고정됩니다. 생성 경로가 +/// 적용하는 규칙과 같습니다. `.unchanged`인 필드는 건드리지 않고, `expiresAt`의 `.set(nil)`은 +/// 만료일을 지우는 요청이므로 그대로 전달됩니다. public protocol PatchSecretUseCase: Sendable { /// payload·metadata 변경 없이 일반 필드와 Project 연결만 수정한다. /// payload 복호화가 발생하지 않으므로 생체인증 없이 호출 가능하다. diff --git a/Projects/DVDomain/Tests/Core/UseCase/Helper/SecretUseCaseHelperTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Helper/SecretUseCaseHelperTests.swift index 76e3e386..2309622e 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Helper/SecretUseCaseHelperTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Helper/SecretUseCaseHelperTests.swift @@ -79,6 +79,87 @@ struct SecretUseCaseHelperTests { } } + // MARK: - normalizedPatch + + @Test("normalizedPatch는 set된 이름의 앞뒤 공백을 제거하고 나머지 필드는 그대로 둔다") + func normalizedPatchTrimsName() throws { + let patch = SecretPatch( + name: .set(" Hello "), + service: .set("GitHub"), + memo: .set("note"), + liked: .set(true) + ) + + let normalized = try SecretUseCaseHelper.normalizedPatch(patch) + + #expect(normalized.name == .set("Hello")) + #expect(normalized.service == .set("GitHub")) + #expect(normalized.memo == .set("note")) + #expect(normalized.liked == .set(true)) + } + + @Test("normalizedPatch는 이름이 unchanged면 건드리지 않는다") + func normalizedPatchKeepsUnchangedName() throws { + let normalized = try SecretUseCaseHelper.normalizedPatch(SecretPatch(liked: .set(true))) + + #expect(normalized.name == .unchanged) + } + + @Test("normalizedPatch는 expiresAt의 날짜는 유지한 채 시:분:초를 23:59:59로 고정한다") + func normalizedPatchAnchorsExpiresAtToEndOfDay() throws { + let pickedDate = DateComponents( + calendar: .current, year: 2026, month: 8, day: 14, hour: 9, minute: 0, second: 0 + ).date! + + let normalized = try SecretUseCaseHelper.normalizedPatch( + SecretPatch(expiresAt: .set(pickedDate)) + ) + + guard case .set(let anchored?) = normalized.expiresAt else { + Issue.record("expiresAt이 set이어야 한다") + return + } + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: anchored + ) + #expect(components.year == 2026) + #expect(components.month == 8) + #expect(components.day == 14) + #expect(components.hour == 23) + #expect(components.minute == 59) + #expect(components.second == 59) + } + + @Test("normalizedPatch는 만료일 삭제 요청(.set(nil))을 그대로 전달한다") + func normalizedPatchKeepsExpiresAtClearRequest() throws { + let normalized = try SecretUseCaseHelper.normalizedPatch( + SecretPatch(expiresAt: .set(nil)) + ) + + #expect(normalized.expiresAt == .set(nil)) + } + + @Test("normalizedPatch는 expiresAt이 unchanged면 건드리지 않는다") + func normalizedPatchKeepsUnchangedExpiresAt() throws { + let normalized = try SecretUseCaseHelper.normalizedPatch(SecretPatch(liked: .set(true))) + + #expect(normalized.expiresAt == .unchanged) + } + + @Test("normalizedPatch는 빈 이름을 invalidName으로 거부한다") + func normalizedPatchRejectsEmptyName() { + #expect(throws: SecretUseCaseError.invalidName) { + _ = try SecretUseCaseHelper.normalizedPatch(SecretPatch(name: .set(""))) + } + } + + @Test("normalizedPatch는 공백만 있는 이름을 invalidName으로 거부한다") + func normalizedPatchRejectsWhitespaceName() { + #expect(throws: SecretUseCaseError.invalidName) { + _ = try SecretUseCaseHelper.normalizedPatch(SecretPatch(name: .set(" \n\t "))) + } + } + // MARK: - settingUpdatedAtIfNeeded @Test("updatedAt이 unchanged면 주입 시각으로 채운다") diff --git a/Projects/DVDomain/Tests/Core/UseCase/Secret/PatchSecretUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Secret/PatchSecretUseCaseImplTests.swift index 7835ffd2..c32ad672 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Secret/PatchSecretUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Secret/PatchSecretUseCaseImplTests.swift @@ -210,4 +210,109 @@ struct PatchSecretUseCaseImplTests { _ = try await sut.update(id: missingID, patch: SecretPatch(), projectIDs: .set([UUID()])) } } + + // MARK: - 정규화 + + /// 네 overload 중 하나만 정규화를 빠뜨려도 그 경로로 저장한 시크릿의 만료 시각이 어긋난다. + /// 한 곳에만 적용하고 나머지를 잊는 것이 이 종류 버그의 전형이라 전부를 한 테스트로 훑는다. + @Test("네 overload 모두 expiresAt을 그 날 23:59:59로 고정한다") + func allOverloadsAnchorExpiresAtToEndOfDay() async throws { + let pickedDate = DateComponents( + calendar: .current, year: 2026, month: 8, day: 20, hour: 9, minute: 30, second: 0 + ).date! + let patch = SecretPatch(expiresAt: .set(pickedDate)) + + let overloads: [(name: String, call: @Sendable (PatchSecretUseCaseImpl, UUID) async throws -> Void)] = [ + ("update(id:patch:projectIDs:)", { sut, id in + _ = try await sut.update(id: id, patch: patch, projectIDs: .unchanged) + }), + ("update(id:patch:metadata:projectIDs:)", { sut, id in + _ = try await sut.update( + id: id, + patch: patch, + metadata: APIKeyMetadata(scope: "read"), + projectIDs: .unchanged + ) + }), + ("update(id:patch:payload:projectIDs:)", { sut, id in + _ = try await sut.update( + id: id, + patch: patch, + payload: APIKeyPayload(value: "sk"), + projectIDs: .unchanged + ) + }), + ("update(id:patch:payload:metadata:projectIDs:)", { sut, id in + _ = try await sut.update( + id: id, + patch: patch, + payload: APIKeyPayload(value: "sk"), + metadata: APIKeyMetadata(scope: "read"), + projectIDs: .unchanged + ) + }), + ] + + for overload in overloads { + let repo = InMemorySecretRepository() + let secret = SecretFixture.make() + repo.seed(secret) + let sut = PatchSecretUseCaseImpl(repository: repo, cryptoService: FakeSecretCryptoService()) + + try await overload.call(sut, secret.id) + + guard case .set(let anchored?) = repo.lastPatch?.expiresAt else { + Issue.record("\(overload.name): expiresAt이 set이어야 한다") + continue + } + let components = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: anchored + ) + #expect(components.year == 2026, "\(overload.name)") + #expect(components.month == 8, "\(overload.name)") + #expect(components.day == 20, "\(overload.name)") + #expect(components.hour == 23, "\(overload.name)") + #expect(components.minute == 59, "\(overload.name)") + #expect(components.second == 59, "\(overload.name)") + } + } + + @Test("공백만 있는 이름은 invalidName으로 거부되고 암호화·Repository 호출이 일어나지 않는다") + func rejectsBlankNameBeforeEncrypting() async { + let repo = InMemorySecretRepository() + let secret = SecretFixture.make() + repo.seed(secret) + let crypto = FakeSecretCryptoService() + let sut = PatchSecretUseCaseImpl(repository: repo, cryptoService: crypto) + + await #expect(throws: SecretUseCaseError.invalidName) { + _ = try await sut.update( + id: secret.id, + patch: SecretPatch(name: .set(" \n ")), + payload: APIKeyPayload(value: "sk"), + projectIDs: .unchanged + ) + } + // 정규화가 암호화보다 먼저 걸리는지 확인한다 — 거부될 patch에 크립토 작업이 들어가면 안 된다. + #expect(crypto.encryptCount == 0) + #expect(repo.patchCount == 0) + #expect(repo.patchWithProjectsCount == 0) + } + + @Test("만료일 삭제 요청(.set(nil))은 그대로 Repository에 전달된다") + func forwardsExpiresAtClearRequest() async throws { + let repo = InMemorySecretRepository() + let secret = SecretFixture.make(expiresAt: Date(timeIntervalSince1970: 1_800_000_000)) + repo.seed(secret) + let sut = PatchSecretUseCaseImpl(repository: repo, cryptoService: FakeSecretCryptoService()) + + let updated = try await sut.update( + id: secret.id, + patch: SecretPatch(expiresAt: .set(nil)), + projectIDs: .unchanged + ) + + #expect(repo.lastPatch?.expiresAt == .set(nil)) + #expect(updated.expiresAt == nil) + } } From 45c882d4bce4894dabcd77331cd825b502f394af Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:42:08 +0900 Subject: [PATCH 02/61] =?UTF-8?q?[#76]=20fix:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20UI=20=EB=AF=B8=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20metadata=20=ED=95=84=EB=93=9C=EA=B0=80=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toCreateSecretPayload에 preserving: 파라미터 추가. 폼이 입력받지 않는 metadata 필드는 원본에서 이어받는다 — 폼 값만으로 재조립하면 저장할 때마다 사라진다. 생성 경로는 기본값 nil이라 동작이 바뀌지 않는다 - 이어받는 9필드: DatabaseMetadata host·port·databaseName·username / ServiceAccountMetadata projectId·accountEmail / SSLCertMetadata domain·issuer / SSHKeyMetadata keyType. 입력 경로가 있는 필드는 폼 값이 이긴다 - databaseMetadata를 non-Optional로. SSL Required를 끄면 metadata 레코드가 통째로 사라져 위 네 필드까지 함께 날아갔고, "SSL 안 씀"과 "미기록"도 구분되지 않았다. licenseKeyMetadata가 같은 이유로 이미 non-Optional이다 - 이어받은 값이 있으면 폼 값이 비어도 레코드를 남기도록 nil 붕괴 가드 수정 — keyType만 남은 SSH 키가 사라지지 않는다 - 지금은 네 필드군에 값을 넣는 경로가 없어 실제로 잃을 값이 없다. 값이 생기는 순간(인증서 PEM에서 domain·issuer 산출 등) 조용히 지우는 버그가 되므로 규칙을 먼저 고정한다 --- .../Model/SecretMetaFields+Mapping.swift | 101 +++++- .../SecretMetaFieldsMappingTests.swift | 308 ++++++++++++++++++ 2 files changed, 392 insertions(+), 17 deletions(-) create mode 100644 Projects/DVPresentation/Tests/Features/CreateSecret/SecretMetaFieldsMappingTests.swift diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+Mapping.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+Mapping.swift index 1073a6df..fd5e37fe 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+Mapping.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+Mapping.swift @@ -43,9 +43,15 @@ extension SecretMetaFields { /// content case별로 payload + metadata를 조립. 필수 필드 누락 시 `.missingRequired`로 폼에 인라인 경고를 트리거. /// `apiKeyToken` case는 payload가 세 subType에 걸쳐 공유되므로 `subType` 인자로 CreateSecretPayload case를 최종 분기한다. + /// + /// - Parameter baseline: 수정 저장에서 넘기는 원본 payload. metadata에는 폼이 입력받지 않는 필드가 섞여 있어 + /// (`DatabaseMetadata.host`·`ServiceAccountMetadata.projectId` 등) 폼 값만으로 재조립하면 저장할 때마다 + /// 그 값들이 사라진다. 입력 경로가 있는 필드는 폼 값이 이기고, 없는 필드만 baseline에서 이어받는다. + /// 생성 화면은 이어받을 원본이 없으므로 `nil`이다. func toCreateSecretPayload( secretType: CreatableSecretType, - subType: CreatableSecretSubType? + subType: CreatableSecretSubType?, + preserving baseline: CreateSecretPayload? = nil ) -> Result { var missing: [FieldID] = [] if name.isBlank { missing.append(.name) } @@ -74,19 +80,19 @@ extension SecretMetaFields { case .serviceAccount(let f): return .success(.serviceAccount( ServiceAccountPayload(credentialJSON: f.credentialJSON), - f.serviceAccountMetadata + f.serviceAccountMetadata(preserving: baseline?.serviceAccountMetadata) )) case .database(let f): return .success(.database( DatabasePayload(linkString: f.linkString), - f.databaseMetadata + f.databaseMetadata(preserving: baseline?.databaseMetadata) )) case .sshKey(let f): return .success(.sshKey( SSHKeyPayload(privateKey: f.privateKey, passphrase: f.passphrase.nilIfEmpty), - f.sshKeyMetadata + f.sshKeyMetadata(preserving: baseline?.sshKeyMetadata) )) case .sslTlsCertificate(let f): @@ -96,7 +102,7 @@ extension SecretMetaFields { privateKey: f.sslPrivateKey, certificateChain: f.certificateChain.nilIfEmpty ), - f.sslCertMetadata + f.sslCertMetadata(preserving: baseline?.sslCertMetadata) )) case .envSet(let f): @@ -133,6 +139,9 @@ extension SecretMetaFields { // MARK: - Metadata builders (per sub-struct, nil-collapsing) +// `preserving:`를 받는 것은 UI 입력 경로가 없는 필드를 가진 네 타입뿐이다. +// APIKey / OAuthClient / LicenseKey는 metadata의 모든 필드를 폼이 입력받으므로 이어받을 것이 없다. + private extension APIKeyTokenFields { /// authorityScope가 비어 있으면 `nil`. 도메인 필드명은 `APIKeyMetadata.scope`. var apiKeyMetadata: APIKeyMetadata? { @@ -141,9 +150,19 @@ private extension APIKeyTokenFields { } private extension ServiceAccountFields { - /// authority가 비어 있으면 `nil`. projectId / accountEmail은 아직 UI 미노출로 항상 nil. - var serviceAccountMetadata: ServiceAccountMetadata? { - authority.nilIfEmpty.map { ServiceAccountMetadata(authority: $0) } + /// authority가 비어 있으면 `nil`. 단 `projectId` / `accountEmail`은 UI 입력 경로가 없어 + /// baseline에서 이어받는다 — 폼 값만으로 재조립하면 수정할 때마다 사라진다. + /// 이어받은 값이 있으면 authority가 비어도 레코드를 남긴다. + func serviceAccountMetadata(preserving baseline: ServiceAccountMetadata?) -> ServiceAccountMetadata? { + let authority = authority.nilIfEmpty + let projectId = baseline?.projectId + let accountEmail = baseline?.accountEmail + guard authority != nil || projectId != nil || accountEmail != nil else { return nil } + return ServiceAccountMetadata( + projectId: projectId, + accountEmail: accountEmail, + authority: authority + ) } } @@ -159,27 +178,45 @@ private extension OAuthClientFields { private extension SSHKeyFields { /// publicKey / host / username 하나라도 있으면 build, 아니면 `nil`. - var sshKeyMetadata: SSHKeyMetadata? { + /// `keyType`은 UI 입력 경로가 없어 baseline에서 이어받는다. + func sshKeyMetadata(preserving baseline: SSHKeyMetadata?) -> SSHKeyMetadata? { let pub = publicKey.nilIfEmpty let hst = host.nilIfEmpty let usr = username.nilIfEmpty - guard pub != nil || hst != nil || usr != nil else { return nil } - return SSHKeyMetadata(publicKey: pub, keyType: nil, host: hst, username: usr) + let keyType = baseline?.keyType + guard pub != nil || hst != nil || usr != nil || keyType != nil else { return nil } + return SSHKeyMetadata(publicKey: pub, keyType: keyType, host: hst, username: usr) } } private extension DatabaseFields { - /// 사용자가 명시적으로 SSL Required를 켰을 때만 metadata build. - var databaseMetadata: DatabaseMetadata? { - guard isSSLRequired else { return nil } - return DatabaseMetadata(sslRequired: true) + /// `sslRequired`는 `false`도 유의미한 값이므로 항상 build한다. + /// `nil`로 붕괴시키면 "SSL 안 씀"과 "미기록"이 구분되지 않고, 수정에서 SSL을 끄는 순간 + /// metadata 레코드가 통째로 사라져 아래 네 필드까지 함께 날아간다. + /// (`licenseKeyMetadata`가 같은 이유로 이미 non-Optional이다.) + /// + /// `host` / `port` / `databaseName` / `username`은 UI 입력 경로가 없어 baseline에서 이어받는다. + func databaseMetadata(preserving baseline: DatabaseMetadata?) -> DatabaseMetadata { + DatabaseMetadata( + host: baseline?.host, + port: baseline?.port, + databaseName: baseline?.databaseName, + username: baseline?.username, + sslRequired: isSSLRequired + ) } } private extension SSLCertFields { /// renewCommand가 비어 있으면 `nil`. - var sslCertMetadata: SSLCertMetadata? { - renewCommand.nilIfEmpty.map { SSLCertMetadata(renewCommand: $0) } + /// `domain` / `issuer`는 UI 입력 경로가 없어 baseline에서 이어받는다 — + /// 인증서 PEM에서 산출해 채우게 되면(별도 이슈) 그 값이 수정 저장에서 지워지면 안 된다. + func sslCertMetadata(preserving baseline: SSLCertMetadata?) -> SSLCertMetadata? { + let renewCommand = renewCommand.nilIfEmpty + let domain = baseline?.domain + let issuer = baseline?.issuer + guard renewCommand != nil || domain != nil || issuer != nil else { return nil } + return SSLCertMetadata(domain: domain, issuer: issuer, renewCommand: renewCommand) } } @@ -195,6 +232,36 @@ private extension LicenseKeyFields { } } +// MARK: - Baseline metadata 추출 + +/// 보존 병합의 원본을 꺼낸다. +/// +/// case가 어긋나면 `nil`을 돌려준다 — 수정 화면은 서브타입을 바꿀 수 없으므로(payload 스키마가 달라진다) +/// baseline과 편집 결과는 항상 같은 case다. 다른 case가 들어오는 것은 넘겨준 쪽의 버그이고, +/// 그때 남의 타입 metadata를 억지로 끼워 넣는 것보다 보존을 포기하는 편이 안전하다. +private extension CreateSecretPayload { + + var serviceAccountMetadata: ServiceAccountMetadata? { + guard case .serviceAccount(_, let metadata) = self else { return nil } + return metadata + } + + var databaseMetadata: DatabaseMetadata? { + guard case .database(_, let metadata) = self else { return nil } + return metadata + } + + var sshKeyMetadata: SSHKeyMetadata? { + guard case .sshKey(_, let metadata) = self else { return nil } + return metadata + } + + var sslCertMetadata: SSLCertMetadata? { + guard case .sslTlsCertificate(_, let metadata) = self else { return nil } + return metadata + } +} + private extension String { var nilIfEmpty: String? { isEmpty ? nil : self } diff --git a/Projects/DVPresentation/Tests/Features/CreateSecret/SecretMetaFieldsMappingTests.swift b/Projects/DVPresentation/Tests/Features/CreateSecret/SecretMetaFieldsMappingTests.swift new file mode 100644 index 00000000..32867159 --- /dev/null +++ b/Projects/DVPresentation/Tests/Features/CreateSecret/SecretMetaFieldsMappingTests.swift @@ -0,0 +1,308 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import DVDomain +@testable import DVPresentation + +/// 폼 → 도메인 매핑에서 **UI에 입력 경로가 없는 metadata 필드**가 살아남는지 검증한다. +/// +/// 수정 저장은 폼 값으로 metadata를 재조립하므로, 이어받지 않으면 저장할 때마다 그 필드들이 사라진다. +/// 지금은 네 필드군에 값을 넣는 경로가 없어 실제로 잃을 값이 없지만, 값이 생기는 순간 +/// (예: 인증서 PEM에서 domain·issuer 산출) 조용히 지우는 버그가 되므로 규칙을 먼저 고정한다. +@Suite("SecretMetaFields 도메인 매핑 — metadata 보존") +struct SecretMetaFieldsMappingTests { + + // MARK: - Database + + @Test("SSL Required를 꺼도 metadata가 nil로 붕괴하지 않는다") + func databaseMetadataSurvivesSSLOff() throws { + let fields = SecretMetaFields( + content: .database(DatabaseFields(linkString: "postgres://db", isSSLRequired: false)), + name: "DB" + ) + + let payload = try #require(mapped(fields, secretType: .database)) + + guard case .database(_, let metadata) = payload else { + Issue.record("database case여야 한다") + return + } + // nil로 붕괴하면 "SSL 안 씀"과 "미기록"이 구분되지 않는다. + #expect(metadata?.sslRequired == false) + } + + @Test("database: baseline의 host·port·databaseName·username을 이어받는다") + func databasePreservesUnexposedFields() throws { + let baseline = CreateSecretPayload.database( + DatabasePayload(linkString: "postgres://old"), + DatabaseMetadata( + host: "db.example.com", + port: 5432, + databaseName: "main", + username: "admin", + sslRequired: false + ) + ) + let fields = SecretMetaFields( + content: .database(DatabaseFields(linkString: "postgres://new", isSSLRequired: true)), + name: "DB" + ) + + let payload = try #require(mapped(fields, secretType: .database, preserving: baseline)) + + guard case .database(_, let metadata) = payload else { + Issue.record("database case여야 한다") + return + } + #expect(metadata?.host == "db.example.com") + #expect(metadata?.port == 5432) + #expect(metadata?.databaseName == "main") + #expect(metadata?.username == "admin") + // 입력 경로가 있는 필드는 폼 값이 이긴다. + #expect(metadata?.sslRequired == true) + } + + @Test("database: SSL 값은 baseline이 아니라 폼이 결정한다") + func databaseSSLFollowsForm() throws { + let baseline = CreateSecretPayload.database( + DatabasePayload(linkString: "postgres://old"), + DatabaseMetadata(sslRequired: true) + ) + let fields = SecretMetaFields( + content: .database(DatabaseFields(linkString: "postgres://new", isSSLRequired: false)), + name: "DB" + ) + + let payload = try #require(mapped(fields, secretType: .database, preserving: baseline)) + + guard case .database(_, let metadata) = payload else { + Issue.record("database case여야 한다") + return + } + #expect(metadata?.sslRequired == false) + } + + // MARK: - ServiceAccount + + @Test("serviceAccount: baseline의 projectId·accountEmail을 이어받고 authority는 폼 값을 쓴다") + func serviceAccountPreservesUnexposedFields() throws { + let baseline = CreateSecretPayload.serviceAccount( + ServiceAccountPayload(credentialJSON: "{}"), + ServiceAccountMetadata( + projectId: "devault-prod", + accountEmail: "bot@devault.iam.gserviceaccount.com", + authority: "old-authority" + ) + ) + let fields = SecretMetaFields( + content: .serviceAccount( + ServiceAccountFields(credentialJSON: #"{"type":"service_account"}"#, authority: "new-authority") + ), + name: "SA" + ) + + let payload = try #require( + mapped(fields, secretType: .oauth, subType: .serviceAccount, preserving: baseline) + ) + + guard case .serviceAccount(_, let metadata) = payload else { + Issue.record("serviceAccount case여야 한다") + return + } + #expect(metadata?.projectId == "devault-prod") + #expect(metadata?.accountEmail == "bot@devault.iam.gserviceaccount.com") + #expect(metadata?.authority == "new-authority") + } + + @Test("serviceAccount: authority를 비워도 이어받을 값이 있으면 레코드를 남긴다") + func serviceAccountKeepsRecordForPreservedFieldsOnly() throws { + let baseline = CreateSecretPayload.serviceAccount( + ServiceAccountPayload(credentialJSON: "{}"), + ServiceAccountMetadata(projectId: "devault-prod") + ) + let fields = SecretMetaFields( + content: .serviceAccount(ServiceAccountFields(credentialJSON: "{}", authority: "")), + name: "SA" + ) + + let payload = try #require( + mapped(fields, secretType: .oauth, subType: .serviceAccount, preserving: baseline) + ) + + guard case .serviceAccount(_, let metadata) = payload else { + Issue.record("serviceAccount case여야 한다") + return + } + #expect(metadata?.projectId == "devault-prod") + #expect(metadata?.authority == nil) + } + + @Test("serviceAccount: 폼 값도 이어받을 값도 없으면 metadata는 nil이다") + func serviceAccountCollapsesWhenNothingToKeep() throws { + let fields = SecretMetaFields( + content: .serviceAccount(ServiceAccountFields(credentialJSON: "{}", authority: "")), + name: "SA" + ) + + let payload = try #require(mapped(fields, secretType: .oauth, subType: .serviceAccount)) + + guard case .serviceAccount(_, let metadata) = payload else { + Issue.record("serviceAccount case여야 한다") + return + } + #expect(metadata == nil) + } + + // MARK: - SSHKey + + @Test("sshKey: baseline의 keyType을 이어받는다") + func sshKeyPreservesKeyType() throws { + let baseline = CreateSecretPayload.sshKey( + SSHKeyPayload(privateKey: "old", passphrase: nil), + SSHKeyMetadata(publicKey: "ssh-rsa OLD", keyType: "ed25519", host: "old.host", username: "old") + ) + let fields = SecretMetaFields( + content: .sshKey( + SSHKeyFields( + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----", + publicKey: "ssh-rsa NEW", + host: "new.host", + username: "deploy" + ) + ), + name: "SSH" + ) + + let payload = try #require( + mapped(fields, secretType: .sshAndCredentials, subType: .sshKey, preserving: baseline) + ) + + guard case .sshKey(_, let metadata) = payload else { + Issue.record("sshKey case여야 한다") + return + } + #expect(metadata?.keyType == "ed25519") + #expect(metadata?.publicKey == "ssh-rsa NEW") + #expect(metadata?.host == "new.host") + #expect(metadata?.username == "deploy") + } + + @Test("sshKey: 폼 필드가 전부 비어도 baseline의 keyType만으로 레코드를 남긴다") + func sshKeyKeepsRecordForKeyTypeOnly() throws { + let baseline = CreateSecretPayload.sshKey( + SSHKeyPayload(privateKey: "old", passphrase: nil), + SSHKeyMetadata(keyType: "ed25519") + ) + let fields = SecretMetaFields( + content: .sshKey(SSHKeyFields(privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----")), + name: "SSH" + ) + + let payload = try #require( + mapped(fields, secretType: .sshAndCredentials, subType: .sshKey, preserving: baseline) + ) + + guard case .sshKey(_, let metadata) = payload else { + Issue.record("sshKey case여야 한다") + return + } + #expect(metadata?.keyType == "ed25519") + } + + // MARK: - SSL/TLS Certificate + + @Test("sslTlsCertificate: baseline의 domain·issuer를 이어받는다") + func sslCertPreservesDomainAndIssuer() throws { + let baseline = CreateSecretPayload.sslTlsCertificate( + SSLCertPayload(certificate: "old", privateKey: "old", certificateChain: nil), + SSLCertMetadata(domain: "devault.app", issuer: "Let's Encrypt", renewCommand: "old cmd") + ) + let fields = SecretMetaFields( + content: .sslTlsCertificate( + SSLCertFields( + certificate: "-----BEGIN CERTIFICATE-----", + sslPrivateKey: "-----BEGIN PRIVATE KEY-----", + renewCommand: "certbot renew" + ) + ), + name: "TLS" + ) + + let payload = try #require( + mapped(fields, secretType: .sshAndCredentials, subType: .sslTlsCertificate, preserving: baseline) + ) + + guard case .sslTlsCertificate(_, let metadata) = payload else { + Issue.record("sslTlsCertificate case여야 한다") + return + } + #expect(metadata?.domain == "devault.app") + #expect(metadata?.issuer == "Let's Encrypt") + #expect(metadata?.renewCommand == "certbot renew") + } + + // MARK: - baseline 부재 · 불일치 + + @Test("baseline이 없으면(생성 경로) 폼 값만으로 조립된다") + func withoutBaselineNothingIsInherited() throws { + let fields = SecretMetaFields( + content: .sshKey( + SSHKeyFields(privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----", host: "host") + ), + name: "SSH" + ) + + let payload = try #require(mapped(fields, secretType: .sshAndCredentials, subType: .sshKey)) + + guard case .sshKey(_, let metadata) = payload else { + Issue.record("sshKey case여야 한다") + return + } + #expect(metadata?.keyType == nil) + #expect(metadata?.host == "host") + } + + /// 서브타입은 수정 화면에서 바뀌지 않으므로 실제로는 일어나지 않는 조합이다. + /// 그래도 남의 타입 metadata를 억지로 끼워 넣지 않는다는 것을 고정한다. + @Test("baseline의 case가 다르면 이어받지 않는다") + func mismatchedBaselineIsIgnored() throws { + let baseline = CreateSecretPayload.apiKey( + APIKeyPayload(value: "sk"), + APIKeyMetadata(scope: "repo:read") + ) + let fields = SecretMetaFields( + content: .database(DatabaseFields(linkString: "postgres://db", isSSLRequired: true)), + name: "DB" + ) + + let payload = try #require(mapped(fields, secretType: .database, preserving: baseline)) + + guard case .database(_, let metadata) = payload else { + Issue.record("database case여야 한다") + return + } + #expect(metadata?.host == nil) + #expect(metadata?.sslRequired == true) + } + + // MARK: - Helpers + + /// 매핑 성공분만 꺼낸다. 실패(필수 필드 누락)는 이 Suite의 관심사가 아니므로 `nil`로 접는다. + private func mapped( + _ fields: SecretMetaFields, + secretType: CreatableSecretType, + subType: CreatableSecretSubType? = nil, + preserving baseline: CreateSecretPayload? = nil + ) -> CreateSecretPayload? { + guard case .success(let payload) = fields.toCreateSecretPayload( + secretType: secretType, + subType: subType, + preserving: baseline + ) else { + return nil + } + return payload + } +} From 9b27090a0514fd740cf5b812807db32455c0c435 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:45:17 +0900 Subject: [PATCH 03/61] =?UTF-8?q?[#76]=20feat:=20=EB=B3=B5=ED=98=B8?= =?UTF-8?q?=ED=99=94=EB=90=9C=20payload=EB=A5=BC=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=8F=BC=20=ED=95=84=EB=93=9C=EB=A1=9C=20=EB=90=98=EB=8F=8C?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EC=97=AD=EB=A7=A4=ED=95=91=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CreateSecretPayload.contentFields — payload·metadata 조립의 역방향. 11개 payload case가 폼의 9개 content case로 접힌다(apiKey/accessToken/webhookSecret이 스키마를 공유). 어느 서브타입이었는지는 Secret.subType이 들고 있다 - SecretMetaFields(secret:payload:projectIds:) — 수정 진입 시 폼 초기값. payload를 따로 받는 것은 secret.payload가 암호문이기 때문이고, 그래서 수정 진입은 복호화(=인증)를 먼저 통과해야 한다 - 도메인 nil은 폼에서 빈 문자열이 된다. 저장할 때 nilIfEmpty가 다시 접으므로 왕복해도 같은 값으로 돌아온다 - 저장된 문자열이 폼 enum에 없으면 기본값으로 떨어뜨린다(environment→dev, licenseType→individual). 화면에서 다시 고를 수 있어 진입을 막을 사유가 아니다 - 라운드트립 테스트로 두 방향을 고정한다 — 22개 조합(전 필드 채움 11 + 필수만 11)에서 폼→payload→폼→payload가 같은 값인지, payload뿐 아니라 폼 필드 자체가 복원되는지 함께 본다. 어긋나면 수정 화면을 열었다 그대로 저장하는 것만으로 값이 바뀐다 --- .../Model/SecretMetaFields+FromSecret.swift | 30 ++ .../CreateSecretPayload+ContentFields.swift | 102 ++++++ .../SecretMetaFieldsFromSecretTests.swift | 320 ++++++++++++++++++ 3 files changed, 452 insertions(+) create mode 100644 Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+FromSecret.swift create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+ContentFields.swift create mode 100644 Projects/DVPresentation/Tests/SecretDetail/SecretMetaFieldsFromSecretTests.swift diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+FromSecret.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+FromSecret.swift new file mode 100644 index 00000000..4f864ef4 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/SecretMetaFields+FromSecret.swift @@ -0,0 +1,30 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation + +extension SecretMetaFields { + + /// 조회 중인 시크릿을 수정 폼의 초기값으로 되돌린다. + /// `toSecretDraft` / `toCreateSecretPayload`의 역방향이다. + /// + /// **`payload`를 따로 받는 이유는 `secret.payload`가 암호문이기 때문이다.** type-specific 필드는 + /// 평문에서만 만들 수 있으므로, 수정 진입은 복호화(= 인증)를 먼저 통과해야 한다. + /// + /// - Parameter projectIds: 이 시크릿에 연결된 Project ID. `Secret` 엔티티에 프로젝트 정보가 없어 + /// 별도 조회한 값을 넘긴다(조회 화면은 chip 라벨 때문에 엔티티로 들고 있으므로 ID만 뽑아 전달). + init(secret: Secret, payload: CreateSecretPayload, projectIds: [Project.ID]) { + self.init( + content: payload.contentFields, + name: secret.name, + projectIds: projectIds, + service: secret.service ?? "", + // 도메인은 `expiresAt`, 폼은 `expireDate` — 이름이 다르다. + expireDate: secret.expiresAt, + // 저장된 문자열이 폼 enum에 없으면 기본값으로 떨어뜨린다. 화면에서 다시 고를 수 있으므로 + // 진입을 막을 사유는 아니다 (`licenseTier`도 같은 규칙). + environment: secret.environment.flatMap(SecretEnvironment.init(rawValue:)) ?? .dev, + memo: secret.memo ?? "" + ) + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+ContentFields.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+ContentFields.swift new file mode 100644 index 00000000..02ff35f8 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+ContentFields.swift @@ -0,0 +1,102 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain + +// MARK: - payload → 폼 필드 역변환 + +/// 복호화된 payload를 수정 폼의 type-specific 필드로 되돌린다. +/// `SecretMetaFields+Mapping`의 payload·metadata 조립을 정확히 되감는 방향이다. +/// +/// **11 → 9로 줄어든다.** `apiKey` / `accessToken` / `webhookSecret`은 payload 스키마가 같아 +/// `.apiKeyToken` 한 case를 공유하기 때문이다(`SecretContentFields`). 어느 서브타입이었는지는 +/// 이 값이 아니라 `Secret.subType`이 들고 있다. +/// +/// **`nil`은 빈 문자열이 된다.** 폼은 "미입력"을 `""`로만 표현하고, 저장할 때 `nilIfEmpty`가 다시 +/// `nil`로 접는다. 그래서 왕복해도 같은 값으로 돌아온다. +extension CreateSecretPayload { + + var contentFields: SecretContentFields { + switch self { + // 세 서브타입이 payload·metadata 스키마를 공유하므로 한 case로 접는다. + case .apiKey(let payload, let metadata), + .accessToken(let payload, let metadata), + .webhookSecret(let payload, let metadata): + return .apiKeyToken( + APIKeyTokenFields( + value: payload.value, + authorityScope: metadata?.scope ?? "" + ) + ) + + case .oauthClient(let payload, let metadata): + return .oauthClient( + OAuthClientFields( + clientId: payload.clientId, + clientSecret: payload.clientSecret, + redirectUri: metadata?.redirectUri ?? "", + scopes: metadata?.scopes ?? "" + ) + ) + + case .serviceAccount(let payload, let metadata): + return .serviceAccount( + ServiceAccountFields( + credentialJSON: payload.credentialJSON, + authority: metadata?.authority ?? "" + ) + ) + + // metadata가 없는 기존 레코드는 "SSL 안 씀"으로 읽는다 — + // `sslRequired`가 non-Optional로 항상 기록되기 전에 만들어진 시크릿이 있다. + case .database(let payload, let metadata): + return .database( + DatabaseFields( + linkString: payload.linkString, + isSSLRequired: metadata?.sslRequired ?? false + ) + ) + + case .sshKey(let payload, let metadata): + return .sshKey( + SSHKeyFields( + privateKey: payload.privateKey, + passphrase: payload.passphrase ?? "", + publicKey: metadata?.publicKey ?? "", + host: metadata?.host ?? "", + username: metadata?.username ?? "" + ) + ) + + // 도메인은 `privateKey`, 폼은 `sslPrivateKey` — SSH 필드명과 충돌해 폼에서만 개명돼 있다. + case .sslTlsCertificate(let payload, let metadata): + return .sslTlsCertificate( + SSLCertFields( + certificate: payload.certificate, + sslPrivateKey: payload.privateKey, + certificateChain: payload.certificateChain ?? "", + renewCommand: metadata?.renewCommand ?? "" + ) + ) + + case .environmentVariableSet(let payload): + return .envSet(EnvSetFields(envContent: payload.content)) + + // licenseType은 String으로 저장돼 있어 폼 enum으로 되돌릴 때 실패할 수 있다 + // (앱이 tier 목록을 바꾼 뒤 이전 값이 남은 경우). 그때는 기본값으로 떨어뜨린다 — + // 저장을 막을 만한 사유가 아니고, 사용자가 화면에서 다시 고를 수 있다. + case .licenseKey(let payload, let metadata): + return .licenseKey( + LicenseKeyFields( + licenseKey: payload.licenseKey, + licenseTier: metadata?.licenseType.flatMap(LicenseTier.init(rawValue:)) ?? .individual, + registrationEmail: metadata?.registrationEmail ?? "", + orderNumber: metadata?.orderNumber ?? "", + website: metadata?.website ?? "" + ) + ) + + case .custom(let payload): + return .custom(CustomFields(value: payload.value)) + } + } +} diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretMetaFieldsFromSecretTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretMetaFieldsFromSecretTests.swift new file mode 100644 index 00000000..c83f764d --- /dev/null +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretMetaFieldsFromSecretTests.swift @@ -0,0 +1,320 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import DVDomain +@testable import DVPresentation + +/// 수정 화면 진입에 쓰는 역매핑을 검증한다. +/// +/// 정방향(`toCreateSecretPayload`)과 역방향(`contentFields`)이 어긋나면 수정 화면을 열었다 그대로 +/// 저장하는 것만으로 값이 바뀐다. 두 방향은 서로를 되감아야 하므로 **라운드트립**으로 고정한다. +@Suite("SecretMetaFields 역매핑") +struct SecretMetaFieldsFromSecretTests { + + // MARK: - 라운드트립 + + @Test("모든 조합에서 폼 → payload → 폼 → payload가 같은 값으로 돌아온다") + func roundTripAcrossAllCombinations() throws { + for combination in Combination.filled + Combination.minimal { + let fields = combination.makeFields() + + let first = try #require( + mapped(fields, secretType: combination.secretType, subType: combination.subType), + "\(combination.label): 정방향 매핑이 성공해야 한다" + ) + + var reversed = fields + reversed.content = first.contentFields + + // 폼 필드 자체가 복원되는지 — payload만 같고 폼이 달라지면 화면에 다른 값이 뜬다. + #expect(reversed.content == fields.content, "\(combination.label): content 복원") + + let second = try #require( + mapped(reversed, secretType: combination.secretType, subType: combination.subType), + "\(combination.label): 역방향 결과의 재매핑이 성공해야 한다" + ) + #expect(first == second, "\(combination.label): payload 복원") + } + } + + @Test("apiKeyToken 세 서브타입은 같은 폼 필드로 접히고 서브타입은 payload case가 결정한다") + func apiKeyTokenSubTypesShareContentFields() throws { + let fields = SecretMetaFields( + content: .apiKeyToken(APIKeyTokenFields(value: "ghp_x", authorityScope: "repo:read")), + name: "Token" + ) + + let apiKey = try #require(mapped(fields, secretType: .apiKeyToken, subType: .apiKey)) + let accessToken = try #require(mapped(fields, secretType: .apiKeyToken, subType: .accessToken)) + let webhook = try #require(mapped(fields, secretType: .apiKeyToken, subType: .webhookSecret)) + + // payload case는 다르지만 + #expect(apiKey != accessToken) + #expect(accessToken != webhook) + // 되돌린 폼 필드는 셋 다 같다 + #expect(apiKey.contentFields == accessToken.contentFields) + #expect(accessToken.contentFields == webhook.contentFields) + } + + @Test("metadata가 없는 payload는 폼의 해당 필드가 빈 문자열이 된다") + func missingMetadataBecomesEmptyStrings() { + let payload = CreateSecretPayload.sshKey( + SSHKeyPayload(privateKey: "pk", passphrase: nil), + nil + ) + + guard case .sshKey(let f) = payload.contentFields else { + Issue.record("sshKey case여야 한다") + return + } + #expect(f.privateKey == "pk") + #expect(f.passphrase == "") + #expect(f.publicKey == "") + #expect(f.host == "") + #expect(f.username == "") + } + + @Test("database metadata가 없으면 SSL Required는 꺼진 것으로 읽는다") + func missingDatabaseMetadataMeansSSLOff() { + let payload = CreateSecretPayload.database(DatabasePayload(linkString: "postgres://x"), nil) + + guard case .database(let f) = payload.contentFields else { + Issue.record("database case여야 한다") + return + } + #expect(f.isSSLRequired == false) + } + + @Test("알 수 없는 licenseType은 기본 tier로 떨어진다") + func unknownLicenseTypeFallsBackToDefault() { + let payload = CreateSecretPayload.licenseKey( + LicenseKeyPayload(licenseKey: "AAAA"), + LicenseKeyMetadata(licenseType: "platinum") + ) + + guard case .licenseKey(let f) = payload.contentFields else { + Issue.record("licenseKey case여야 한다") + return + } + #expect(f.licenseTier == .individual) + } + + // MARK: - init(secret:payload:projectIds:) + + @Test("Secret의 공통 필드가 폼 초기값으로 옮겨진다") + func initMapsCommonFields() throws { + let expiresAt = Date(timeIntervalSince1970: 1_800_000_000) + let projectIds = [UUID(), UUID()] + let secret = makeSecret( + name: "GitHub Token", + service: "GitHub", + environment: "prod", + expiresAt: expiresAt, + memo: "배포용" + ) + + let fields = SecretMetaFields( + secret: secret, + payload: .apiKey(APIKeyPayload(value: "ghp_x"), APIKeyMetadata(scope: "repo:read")), + projectIds: projectIds + ) + + #expect(fields.name == "GitHub Token") + #expect(fields.service == "GitHub") + #expect(fields.environment == .prod) + // 도메인 expiresAt → 폼 expireDate + #expect(fields.expireDate == expiresAt) + #expect(fields.memo == "배포용") + #expect(fields.projectIds == projectIds) + #expect(fields.content == .apiKeyToken(APIKeyTokenFields(value: "ghp_x", authorityScope: "repo:read"))) + } + + @Test("Secret의 optional 필드가 nil이면 폼에서는 빈 문자열이 된다") + func initMapsNilOptionalsToEmptyStrings() { + let secret = makeSecret(name: "Bare", service: nil, environment: nil, expiresAt: nil, memo: nil) + + let fields = SecretMetaFields( + secret: secret, + payload: .custom(CustomPayload(value: "v")), + projectIds: [] + ) + + #expect(fields.service == "") + #expect(fields.memo == "") + #expect(fields.expireDate == nil) + #expect(fields.projectIds.isEmpty) + } + + @Test("알 수 없는 environment 문자열은 기본값으로 떨어진다") + func initFallsBackForUnknownEnvironment() { + let secret = makeSecret(name: "X", service: nil, environment: "canary", expiresAt: nil, memo: nil) + + let fields = SecretMetaFields( + secret: secret, + payload: .custom(CustomPayload(value: "v")), + projectIds: [] + ) + + #expect(fields.environment == .dev) + } + + @Test("폼 초기값을 그대로 저장하면 원본과 같은 payload가 나온다") + func initThenSaveReproducesPayload() throws { + let payload = CreateSecretPayload.sslTlsCertificate( + SSLCertPayload( + certificate: "-----BEGIN CERTIFICATE-----", + privateKey: "-----BEGIN PRIVATE KEY-----", + certificateChain: "-----BEGIN CERTIFICATE-----chain" + ), + SSLCertMetadata(renewCommand: "certbot renew") + ) + let secret = makeSecret( + name: "TLS", + secretType: .sshAndCredentials, + subType: .sslTlsCertificate, + service: nil, + environment: nil, + expiresAt: nil, + memo: nil + ) + + let fields = SecretMetaFields(secret: secret, payload: payload, projectIds: []) + let remapped = try #require( + mapped(fields, secretType: .sshAndCredentials, subType: .sslTlsCertificate, preserving: payload) + ) + + #expect(remapped == payload) + } + + // MARK: - Fixtures + + private struct Combination { + let label: String + let secretType: CreatableSecretType + let subType: CreatableSecretSubType? + let content: SecretContentFields + + func makeFields() -> SecretMetaFields { + SecretMetaFields(content: content, name: "Fixture") + } + + /// optional 필드까지 모두 채운 경우. + static let filled: [Combination] = [ + .init(label: "apiKey", secretType: .apiKeyToken, subType: .apiKey, + content: .apiKeyToken(APIKeyTokenFields(value: "ghp_1", authorityScope: "repo:read"))), + .init(label: "accessToken", secretType: .apiKeyToken, subType: .accessToken, + content: .apiKeyToken(APIKeyTokenFields(value: "ghp_2", authorityScope: "user:email"))), + .init(label: "webhookSecret", secretType: .apiKeyToken, subType: .webhookSecret, + content: .apiKeyToken(APIKeyTokenFields(value: "whsec_3", authorityScope: "push"))), + .init(label: "oauthClient", secretType: .oauth, subType: .oauthClient, + content: .oauthClient(OAuthClientFields( + clientId: "Iv1.abc", + clientSecret: "ghs_secret", + redirectUri: "https://app.example/callback", + scopes: "read:user, write:issue" + ))), + .init(label: "serviceAccount", secretType: .oauth, subType: .serviceAccount, + content: .serviceAccount(ServiceAccountFields( + credentialJSON: #"{"type":"service_account"}"#, + authority: "organization-admin" + ))), + .init(label: "database", secretType: .database, subType: nil, + content: .database(DatabaseFields(linkString: "postgres://u:p@h:5432/db", isSSLRequired: true))), + .init(label: "sshKey", secretType: .sshAndCredentials, subType: .sshKey, + content: .sshKey(SSHKeyFields( + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----", + passphrase: "secret-phrase", + publicKey: "ssh-rsa AAAA", + host: "bastion.example.com", + username: "ubuntu" + ))), + .init(label: "sslTlsCertificate", secretType: .sshAndCredentials, subType: .sslTlsCertificate, + content: .sslTlsCertificate(SSLCertFields( + certificate: "-----BEGIN CERTIFICATE-----", + sslPrivateKey: "-----BEGIN PRIVATE KEY-----", + certificateChain: "-----BEGIN CERTIFICATE-----chain", + renewCommand: "certbot renew --cert-name example.com" + ))), + .init(label: "envSet", secretType: .environmentVariableSet, subType: nil, + content: .envSet(EnvSetFields(envContent: "DATABASE_URL=postgres://x\nSECRET_KEY=abc"))), + .init(label: "licenseKey", secretType: .etc, subType: .licenseKey, + content: .licenseKey(LicenseKeyFields( + licenseKey: "AAAA-BBBB", + licenseTier: .team, + registrationEmail: "team@example.com", + orderNumber: "ORD-42", + website: "https://jetbrains.com" + ))), + .init(label: "custom", secretType: .etc, subType: .custom, + content: .custom(CustomFields(value: "legacy-value"))), + ] + + /// 필수 필드만 채운 경우. optional이 `nil`로 접혔다가 `""`로 돌아오는 경로를 훑는다. + static let minimal: [Combination] = [ + .init(label: "apiKey(최소)", secretType: .apiKeyToken, subType: .apiKey, + content: .apiKeyToken(APIKeyTokenFields(value: "v"))), + .init(label: "accessToken(최소)", secretType: .apiKeyToken, subType: .accessToken, + content: .apiKeyToken(APIKeyTokenFields(value: "v"))), + .init(label: "webhookSecret(최소)", secretType: .apiKeyToken, subType: .webhookSecret, + content: .apiKeyToken(APIKeyTokenFields(value: "v"))), + .init(label: "oauthClient(최소)", secretType: .oauth, subType: .oauthClient, + content: .oauthClient(OAuthClientFields(clientId: "i", clientSecret: "s"))), + .init(label: "serviceAccount(최소)", secretType: .oauth, subType: .serviceAccount, + content: .serviceAccount(ServiceAccountFields(credentialJSON: "{}"))), + .init(label: "database(최소)", secretType: .database, subType: nil, + content: .database(DatabaseFields(linkString: "postgres://x"))), + .init(label: "sshKey(최소)", secretType: .sshAndCredentials, subType: .sshKey, + content: .sshKey(SSHKeyFields(privateKey: "pk"))), + .init(label: "sslTlsCertificate(최소)", secretType: .sshAndCredentials, subType: .sslTlsCertificate, + content: .sslTlsCertificate(SSLCertFields(certificate: "c", sslPrivateKey: "k"))), + .init(label: "envSet(최소)", secretType: .environmentVariableSet, subType: nil, + content: .envSet(EnvSetFields(envContent: "A=1"))), + .init(label: "licenseKey(최소)", secretType: .etc, subType: .licenseKey, + content: .licenseKey(LicenseKeyFields(licenseKey: "L"))), + .init(label: "custom(최소)", secretType: .etc, subType: .custom, + content: .custom(CustomFields(value: "v"))), + ] + } + + private func makeSecret( + name: String, + secretType: SecretType = .apiKeyToken, + subType: SecretSubType? = .apiKey, + service: String?, + environment: String?, + expiresAt: Date?, + memo: String? + ) -> Secret { + Secret( + id: UUID(), + name: name, + secretType: secretType, + subType: subType, + service: service, + environment: environment, + expiresAt: expiresAt, + memo: memo, + createdAt: Date(timeIntervalSince1970: 1_700_000_000), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + } + + private func mapped( + _ fields: SecretMetaFields, + secretType: CreatableSecretType, + subType: CreatableSecretSubType? = nil, + preserving baseline: CreateSecretPayload? = nil + ) -> CreateSecretPayload? { + guard case .success(let payload) = fields.toCreateSecretPayload( + secretType: secretType, + subType: subType, + preserving: baseline + ) else { + return nil + } + return payload + } +} From 108e4af0bb849f3448b1acb12beb75d4333e7a6d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:45:46 +0900 Subject: [PATCH 04/61] =?UTF-8?q?[#76]=20refactor:=20=ED=8F=BC=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EB=94=94=EC=8A=A4=ED=8C=A8=EC=B2=98=EB=A5=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=B0=96=EC=9C=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecretFormSectionsView 신설 — (secretType, subType) 조합에 대응하는 입력 SectionView를 고르는 분기를 CreateSecretView에서 떼어냈다. 수정 화면이 같은 폼을 재사용하므로, 분기가 화면 안에 인라인이면 SectionView 시그니처가 바뀔 때마다 두 곳을 따로 고쳐야 하고 한쪽만 고친 것이 드러나지 않는다 - TCA store에 결합하지 않고 바인딩·값·콜백만 받는다. 두 Feature의 State 모양이 다르고, 그래야 SectionView들처럼 독립 프리뷰가 가능하다 - 조회 화면의 DetailPayloadSectionView가 표시 섹션에 대해 맡는 역할과 대칭이다 - 동작 변경 없음 — 9개 SectionView 호출을 그대로 옮겼고 하위 분기의 default도 현행 유지했다(전 case 명시로 바꾸는 것은 별건) --- .../Sections/SecretFormSectionsView.swift | 214 ++++++++++++++++++ .../CreateSecret/CreateSecretView.swift | 191 +--------------- 2 files changed, 225 insertions(+), 180 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Features/CreateSecret/Components/Sections/SecretFormSectionsView.swift diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Sections/SecretFormSectionsView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Sections/SecretFormSectionsView.swift new file mode 100644 index 00000000..8a792075 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Sections/SecretFormSectionsView.swift @@ -0,0 +1,214 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import SwiftUI + +// MARK: - SecretFormSectionsView + +/// `(secretType, subType)` 조합에 대응하는 입력 SectionView를 고르는 디스패처. +/// +/// 생성 화면과 수정 화면이 **같은 폼을 쓴다.** 분기가 화면 안에 인라인으로 있으면 SectionView의 +/// 시그니처가 바뀔 때마다 두 곳을 따로 고쳐야 하고, 한쪽만 고친 것이 컴파일로도 드러나지 않는다. +/// +/// 조회 화면의 `DetailPayloadSectionView`가 표시 섹션에 대해 맡는 역할과 대칭이다. +/// +/// TCA store에 결합하지 않고 바인딩·값·콜백만 받는다 — 두 Feature의 State 모양이 다르고, +/// 그래야 SectionView들처럼 독립 프리뷰가 가능하다. +struct SecretFormSectionsView: View { + + // MARK: - Properties + + let secretType: CreatableSecretType + /// 서브탭이 없는 타입(database · envSet)에서는 `nil`. + let subType: CreatableSecretSubType? + @Binding var meta: SecretMetaFields + let availableProjects: [Project] + let serviceCandidates: [String] + let validationErrors: [SecretFieldID: String] + let detectedServices: [SecretFieldID: String] + let onCreateProject: () -> Void + + // MARK: - Body + + @ViewBuilder + var body: some View { + switch secretType { + case .apiKeyToken: + APIKeysTokenSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + environment: $meta.environment, + memo: $meta.memo, + apiKeyToken: $meta.content.typed(\.apiKeyToken, default: APIKeyTokenFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .oauth: + oauthSection + + case .database: + DatabaseSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + environment: $meta.environment, + memo: $meta.memo, + database: $meta.content.typed(\.database, default: DatabaseFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .sshAndCredentials: + sshAndCredentialsSection + + case .environmentVariableSet: + EnvSetSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + environment: $meta.environment, + memo: $meta.memo, + envSet: $meta.content.typed(\.envSet, default: EnvSetFields()), + availableProjects: availableProjects, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .etc: + etcSection + } + } +} + +// MARK: - Subviews + +extension SecretFormSectionsView { + + /// OAuth는 2개 subtype(oauthClient / serviceAccount)이 서로 다른 필드 구성 — + /// SectionView를 분리하고 subType으로 분기. + @ViewBuilder + private var oauthSection: some View { + switch subType { + case .oauthClient: + OAuthClientSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + environment: $meta.environment, + memo: $meta.memo, + oauthClient: $meta.content.typed(\.oauthClient, default: OAuthClientFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .serviceAccount: + ServiceAccountSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + memo: $meta.memo, + serviceAccount: $meta.content.typed(\.serviceAccount, default: ServiceAccountFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + default: + EmptyView() + } + } + + /// sshAndCredentials는 2개 subtype(sshKey / sslTlsCertificate)이 서로 다른 필드 구성 — + /// SectionView를 분리하고 subType으로 분기. + @ViewBuilder + private var sshAndCredentialsSection: some View { + switch subType { + case .sshKey: + SSHKeySectionView( + name: $meta.name, + projectIds: $meta.projectIds, + environment: $meta.environment, + memo: $meta.memo, + sshKey: $meta.content.typed(\.sshKey, default: SSHKeyFields()), + availableProjects: availableProjects, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .sslTlsCertificate: + SSLTLSCertSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + environment: $meta.environment, + memo: $meta.memo, + sslCert: $meta.content.typed(\.sslTlsCertificate, default: SSLCertFields()), + availableProjects: availableProjects, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + default: + EmptyView() + } + } + + /// etc는 2개 subtype(licenseKey / custom)이 서로 다른 필드 구성 — + /// SectionView를 분리하고 subType으로 분기. + @ViewBuilder + private var etcSection: some View { + switch subType { + case .licenseKey: + LicenseKeySectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + memo: $meta.memo, + licenseKey: $meta.content.typed(\.licenseKey, default: LicenseKeyFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + case .custom: + CustomSectionView( + name: $meta.name, + projectIds: $meta.projectIds, + service: $meta.service, + expireDate: $meta.expireDate, + environment: $meta.environment, + memo: $meta.memo, + custom: $meta.content.typed(\.custom, default: CustomFields()), + availableProjects: availableProjects, + serviceCandidates: serviceCandidates, + validationErrors: validationErrors, + detectedServices: detectedServices, + onCreateProject: onCreateProject + ) + + default: + EmptyView() + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift index cc175d01..ab82939f 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift @@ -53,10 +53,19 @@ extension CreateSecretView { ) } - /// 중간 스크롤 영역. secretType에 따라 대응 SectionView로 분기. + /// 중간 스크롤 영역. 타입별 분기는 `SecretFormSectionsView`가 맡는다 — 수정 화면과 공유한다. private var scrollContent: some View { ScrollView { - typeSpecificSection + SecretFormSectionsView( + secretType: store.secretType, + subType: store.selectedSubType, + meta: $store.meta, + availableProjects: store.availableProjects, + serviceCandidates: store.serviceCandidates, + validationErrors: store.validationErrors, + detectedServices: store.detectedServices, + onCreateProject: { store.send(.didTapCreateProject) } + ) } .scrollIndicators(.hidden) .disabled(store.isSaving) @@ -72,184 +81,6 @@ extension CreateSecretView { } } - @ViewBuilder - private var typeSpecificSection: some View { - switch store.secretType { - case .apiKeyToken: - APIKeysTokenSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - environment: $store.meta.environment, - memo: $store.meta.memo, - apiKeyToken: $store.meta.content.typed(\.apiKeyToken, default: APIKeyTokenFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .oauth: - oauthSection - - case .database: - DatabaseSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - environment: $store.meta.environment, - memo: $store.meta.memo, - database: $store.meta.content.typed(\.database, default: DatabaseFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .sshAndCredentials: - sshAndCredentialsSection - - case .environmentVariableSet: - EnvSetSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - environment: $store.meta.environment, - memo: $store.meta.memo, - envSet: $store.meta.content.typed(\.envSet, default: EnvSetFields()), - availableProjects: store.availableProjects, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .etc: - etcSection - } - } - - /// etc는 2개 subtype(licenseKey / custom)이 서로 다른 필드 구성 — - /// SectionView를 분리하고 selectedSubType으로 분기. - @ViewBuilder - private var etcSection: some View { - switch store.selectedSubType { - case .licenseKey: - LicenseKeySectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - memo: $store.meta.memo, - licenseKey: $store.meta.content.typed(\.licenseKey, default: LicenseKeyFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .custom: - CustomSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - environment: $store.meta.environment, - memo: $store.meta.memo, - custom: $store.meta.content.typed(\.custom, default: CustomFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - default: - EmptyView() - } - } - - /// sshAndCredentials는 2개 subtype(sshKey / sslTlsCertificate)이 서로 다른 필드 구성 — - /// SectionView를 분리하고 selectedSubType으로 분기. - @ViewBuilder - private var sshAndCredentialsSection: some View { - switch store.selectedSubType { - case .sshKey: - SSHKeySectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - environment: $store.meta.environment, - memo: $store.meta.memo, - sshKey: $store.meta.content.typed(\.sshKey, default: SSHKeyFields()), - availableProjects: store.availableProjects, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .sslTlsCertificate: - SSLTLSCertSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - environment: $store.meta.environment, - memo: $store.meta.memo, - sslCert: $store.meta.content.typed(\.sslTlsCertificate, default: SSLCertFields()), - availableProjects: store.availableProjects, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - default: - EmptyView() - } - } - - /// OAuth는 2개 subtype(oauthClient / serviceAccount)이 서로 다른 필드 구성 — - /// SectionView를 분리하고 selectedSubType으로 분기. - @ViewBuilder - private var oauthSection: some View { - switch store.selectedSubType { - case .oauthClient: - OAuthClientSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - environment: $store.meta.environment, - memo: $store.meta.memo, - oauthClient: $store.meta.content.typed(\.oauthClient, default: OAuthClientFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - case .serviceAccount: - ServiceAccountSectionView( - name: $store.meta.name, - projectIds: $store.meta.projectIds, - service: $store.meta.service, - expireDate: $store.meta.expireDate, - memo: $store.meta.memo, - serviceAccount: $store.meta.content.typed(\.serviceAccount, default: ServiceAccountFields()), - availableProjects: store.availableProjects, - serviceCandidates: store.serviceCandidates, - validationErrors: store.validationErrors, - detectedServices: store.detectedServices, - onCreateProject: { store.send(.didTapCreateProject) } - ) - - default: - EmptyView() - } - } - - /// FIXED BOTTOM. Cancel + Save 액션 바. private var footer: some View { FooterActionsView( From 50110af963b4a1718996cee11293765ec3b9059a Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:36:10 +0900 Subject: [PATCH 05/61] =?UTF-8?q?[#76]=20fix:=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=84=20=EC=A7=80=EC=9B=8C=EB=8F=84=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=EB=90=9C=20=EC=95=8C=EB=A6=BC=EC=9D=B4=20=EB=82=A8?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schedule(secret:)의 취소를 expiresAt guard보다 앞으로 옮긴다. 뒤에 있으면 만료일이 없는 Secret에 대해 함수가 곧바로 리턴해 이전 7일·3일 알림이 그대로 살아남았다 - syncAll은 이미 expiresAt 유무로 갈라 cancel을 따로 부르고 있었다. 같은 UseCase 안에서 두 경로의 규칙이 갈려 있던 셈이라 schedule 쪽으로 통일하고 syncAll의 분기를 걷어냈다 - 프로토콜 doc에 "이전 예약을 먼저 전부 취소하고 다시 예약한다"를 계약으로 명시한다. 호출부가 만료일 유무를 보고 cancel과 갈라 부를 필요가 없다 - 수정 화면 저장이 붙으면 만료일 삭제가 처음으로 이 경로를 타므로 먼저 고쳐 둔다 - 회귀 테스트 2건 — 만료일 없는 Secret도 취소가 돌 것, 예약된 Secret의 만료일을 지우면 이전 마크가 사라질 것 --- ...SecretExpiryNotificationsUseCaseImpl.swift | 17 ++++--- ...duleSecretExpiryNotificationsUseCase.swift | 8 +++- ...tExpiryNotificationsUseCaseImplTests.swift | 47 +++++++++++++++++-- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift index 15fb073d..95b814ae 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Notification/ScheduleSecretExpiryNotificationsUseCaseImpl.swift @@ -29,12 +29,9 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry do { let secrets = try await repository.fetch(SecretQuery(collection: .all)) for secret in secrets { - if secret.expiresAt != nil { - await schedule(secret: secret) - } else { - // expiresAt이 나중에 제거됐을 수 있으므로 이전 예약을 명시적으로 취소한다. - await cancel(secretID: secret.id) - } + // schedule이 만료일 유무와 무관하게 이전 예약을 먼저 취소하므로 분기가 필요 없다 — + // 만료일이 나중에 제거된 Secret의 정리도 같은 호출이 처리한다. + await schedule(secret: secret) } } catch { throw SecretUseCaseError.map(error) @@ -42,10 +39,12 @@ public struct ScheduleSecretExpiryNotificationsUseCaseImpl: ScheduleSecretExpiry } public func schedule(secret: Secret) async { - guard let expiresAt = secret.expiresAt else { return } - - // expiresAt이 바뀌었을 수 있어 이전 마크가 stale하게 남지 않도록 먼저 전부 취소한다. + // 취소가 guard보다 **앞에** 있어야 한다. expiresAt이 바뀌었으면 옛 마크가 남지 않아야 하고, + // 지워졌으면 예약을 전부 걷어야 한다 — guard 뒤에 두면 만료일을 지운 Secret에 대해 + // 이 함수가 곧바로 리턴해 이전 알림이 그대로 살아남는다. await cancel(secretID: secret.id) + + guard let expiresAt = secret.expiresAt else { return } guard settingsRepository.isExpiryAlertsEnabled() else { return } let now = dateProvider() diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift index 9af65594..75ded582 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Notification/ScheduleSecretExpiryNotificationsUseCase.swift @@ -7,8 +7,12 @@ public protocol ScheduleSecretExpiryNotificationsUseCase: Sendable { /// 만료일이 있는 모든 Secret의 알림을 다시 계산해 예약한다. 앱 시작 시 1회 호출. func syncAll() async throws - /// 특정 Secret의 만료 알림을 예약한다(생성/복원 시 호출). - /// - Parameter secret: 알림을 예약할 Secret + /// 특정 Secret의 만료 알림을 현재 상태에 맞게 다시 맞춘다(생성·복원·수정 시 호출). + /// + /// **이전 예약을 먼저 전부 취소하고 다시 예약한다.** 만료일이 바뀌었으면 옛 마크가 남지 않고, + /// 만료일이 지워졌으면(`expiresAt == nil`) 취소만 하고 끝난다. 호출부가 만료일 유무를 보고 + /// `cancel`과 갈라 부를 필요가 없다. + /// - Parameter secret: 알림을 맞출 Secret func schedule(secret: Secret) async /// 특정 Secret의 예약된 만료 알림을 모두 취소한다(삭제 시 호출). diff --git a/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift index 80f2d963..c97db3f1 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Notification/ScheduleSecretExpiryNotificationsUseCaseImplTests.swift @@ -130,8 +130,8 @@ struct ScheduleSecretExpiryNotificationsUseCaseImplTests { }) } - @Test("만료일이 없으면 아무 알림도 예약하지 않는다") - func scheduleDoesNothingWithoutExpiresAt() async { + @Test("만료일이 없으면 예약하지 않고, 이전 예약은 취소한다") + func scheduleCancelsAndSchedulesNothingWithoutExpiresAt() async { let notificationService = FakeSecurityNotificationService() let sut = ScheduleSecretExpiryNotificationsUseCaseImpl( repository: InMemorySecretRepository(), @@ -139,11 +139,52 @@ struct ScheduleSecretExpiryNotificationsUseCaseImplTests { settingsRepository: FakeSettingsRepository(), dateProvider: { self.now } ) - let secret = SecretFixture.make(expiresAt: nil) + let secretID = UUID(uuidString: "00000000-0000-0000-0000-0000000000CC")! + let secret = SecretFixture.make(id: secretID, expiresAt: nil) await sut.schedule(secret: secret) #expect(notificationService.scheduled.isEmpty) + // 수정 화면에서 만료일을 지우는 경로가 이 취소에 의존한다. 취소가 만료일 guard보다 + // 뒤에 있으면 이 함수가 곧바로 리턴해 이전 알림이 그대로 남는다. + #expect(notificationService.cancelledIdentifiers == [[ + "secret-expiry-\(secretID.uuidString)-30d", + "secret-expiry-\(secretID.uuidString)-7d", + "secret-expiry-\(secretID.uuidString)-3d", + "secret-expiry-\(secretID.uuidString)-0d", + ]]) + } + + @Test("만료일을 지운 Secret으로 다시 예약을 맞추면 이전 알림이 사라진다") + func scheduleClearsPreviousMarksWhenExpiryRemoved() async { + let notificationService = FakeSecurityNotificationService() + let sut = ScheduleSecretExpiryNotificationsUseCaseImpl( + repository: InMemorySecretRepository(), + notificationService: notificationService, + settingsRepository: FakeSettingsRepository(), + dateProvider: { self.now } + ) + let secretID = UUID(uuidString: "00000000-0000-0000-0000-0000000000DD")! + let scheduled = SecretFixture.make(id: secretID, expiresAt: now.addingTimeInterval(10 * day)) + + // 30일 전 마크는 이미 지났고 7일 전·3일 전·당일이 남는다. + await sut.schedule(secret: scheduled) + #expect(notificationService.scheduled.count == 3) + + // 사용자가 만료일을 지우고 저장한 상황. + var cleared = scheduled + cleared.expiresAt = nil + await sut.schedule(secret: cleared) + + // 두 번째 호출은 새로 예약하지 않고 취소만 한다. + #expect(notificationService.scheduled.count == 3) + #expect(notificationService.cancelledIdentifiers.count == 2) + #expect(notificationService.cancelledIdentifiers.last == [ + "secret-expiry-\(secretID.uuidString)-30d", + "secret-expiry-\(secretID.uuidString)-7d", + "secret-expiry-\(secretID.uuidString)-3d", + "secret-expiry-\(secretID.uuidString)-0d", + ]) } @Test("만료 알림 사용이 꺼져 있으면 기존 예약만 취소하고 새로 만들지 않는다") From b213ac1960d03d407db0adb80fe21c0db7226939 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:37:03 +0900 Subject: [PATCH 06/61] =?UTF-8?q?[#76]=20feat:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20SecretClient?= =?UTF-8?q?.updateSecret=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecretContentChange 신설 — 저장 시 무엇을 다시 쓸지 가리킨다. 바뀌지 않은 payload를 싣지 않으면 재암호화를 건너뛰어 keyTag·schemaVersion이 보존되고, metadata를 .unchanged로 두면 UI에 입력 경로가 없는 필드가 그대로 남는다 - metadataCleared / payloadAndMetadataCleared를 따로 둔 이유는 도메인의 metadata overload가 값을 요구해 nil을 표현할 수 없기 때문이다. 사용자가 마지막 metadata 필드를 비우면 SecretPatch.metadata = .set(nil)로 보내야 하고, 그러지 않으면 지운 값이 DB에 남아 다시 열 때 되살아난다 - 두 플래그가 아니라 enum으로 둔 것은 "쓰지 않는데 평문은 실려 있는" 조합을 만들지 않기 위해서다. none / metadataCleared에서는 평문이 Client 경계를 넘지 않는다 - projectIds도 PatchField로 받는다. 연결이 바뀌지 않았으면 .unchanged로 보내 링크 재조정 write를 건너뛴다 — 목록이 같아도 .set이면 매번 다시 조정한다 - PatchSecretUseCase의 overload 4개를 그대로 노출하지 않는다 — 제네릭이라 @DependencyClient의 저장 프로퍼티에 담기지 않는다. 생성 경로가 dispatchCreateSecret으로 같은 문제를 푼 것과 같은 형태다 - dispatchUpdateSecret은 중첩 제네릭 함수 두 개(metadata 있는 타입 / 없는 타입)로 11 case switch를 한 번만 돈다. overload별로 세 벌 반복하는 것을 피했다 - 저장 후 만료 알림을 다시 맞춘다. schedule이 예약 전에 이전 마크를 취소하므로 만료일을 지운 경우의 정리까지 같은 호출이 처리한다 --- .../Sources/Dependencies/SecretClient.swift | 33 ++++++ .../Model/SecretContentChange.swift | 89 +++++++++++++++ .../Dependencies/SecretClient+Live.swift | 107 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretContentChange.swift diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 0ee71a17..b7366312 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -25,6 +25,26 @@ public struct SecretClient: Sendable { /// 즐겨찾기 여부를 갱신하고 갱신된 Secret을 반환한다. /// payload 복호화가 없으므로 **생체인증을 타지 않는다**(`PatchSecretUseCase.updateSimple`). public var setLiked: @Sendable (_ id: Secret.ID, _ liked: Bool) async throws -> Secret + + /// 수정 화면의 저장. 공통 필드·프로젝트 연결과 함께 `change`가 가리키는 것만 다시 쓴다. + /// + /// `PatchSecretUseCase`의 overload 4개를 그대로 노출하지 않는 이유는 그것들이 제네릭이라 + /// `@DependencyClient`의 저장 프로퍼티에 담기지 않기 때문이다. 생성 경로가 `dispatchCreateSecret`으로 + /// 같은 문제를 푼 것과 같은 형태로, Live가 `change`를 보고 overload를 고른다. + /// + /// **인증을 타지 않는다.** payload를 다시 쓰더라도 암호화만 하고 복호화는 하지 않기 때문이다 — + /// 평문은 이미 수정 진입 시점의 `revealPayload`가 인증을 통과해 받아온 것이다. + /// + /// - Parameter patch: 공통 필드. 바뀐 것만 `.set`, 나머지는 `.unchanged`. 이름 trim과 + /// 만료일 23:59:59 고정은 도메인(`PatchSecretUseCase`)이 수행하므로 화면에서 맞출 필요가 없다. + /// - Parameter projectIds: 연결이 **바뀐 경우에만** `.set`으로 최종 목록 전체를 전달한다. + /// 바뀌지 않았으면 `.unchanged` — `.set`은 목록이 같아도 연결을 다시 조정하는 write를 일으킨다. + public var updateSecret: @Sendable ( + _ id: Secret.ID, + _ patch: SecretPatch, + _ change: SecretContentChange, + _ projectIds: PatchField<[Project.ID]> + ) async throws -> Secret /// 해당 Secret에 연결된 Project 목록. `Secret` 엔티티에는 프로젝트 정보가 없어 별도 조회가 필요하다. public var fetchLinkedProjects: @Sendable (_ secretID: Secret.ID) async throws -> [Project] @@ -187,6 +207,19 @@ private extension SecretClient { secret.liked = liked return secret }, + // 편집한 값이 프리뷰에서도 화면에 반영되도록 patch를 실제로 적용해 돌려준다. + // payload·metadata는 프리뷰에서 확인할 대상이 아니라 건드리지 않는다. + updateSecret: { id, patch, _, _ in + var secret = Secret.preview + secret.id = id + if case .set(let name) = patch.name { secret.name = name } + if case .set(let service) = patch.service { secret.service = service } + if case .set(let environment) = patch.environment { secret.environment = environment } + if case .set(let expiresAt) = patch.expiresAt { secret.expiresAt = expiresAt } + if case .set(let memo) = patch.memo { secret.memo = memo } + secret.updatedAt = .now + return secret + }, fetchLinkedProjects: { _ in Array([Project].preview.prefix(1)) }, // 프리뷰는 인증 시트를 띄울 수 없으므로 즉시 통과시킨다. authenticate: { _ in }, diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretContentChange.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretContentChange.swift new file mode 100644 index 00000000..3ffe6e77 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretContentChange.swift @@ -0,0 +1,89 @@ +// Copyright © 2026 Devault. All rights reserved + +// MARK: - SecretContentChange + +/// 수정 저장에서 **다시 쓸 대상**. Presentation의 dirty 판정 결과이고, Live 조립이 이 case를 보고 +/// `PatchSecretUseCase`의 overload 중 하나를 고른다. +/// +/// 바뀌지 않은 것을 싣지 않는 이유가 case를 나눈 이유다. +/// - **payload**: 다시 쓰지 않으면 재암호화를 건너뛰어 `keyTag`·`schemaVersion`이 보존되고, +/// 평문이 필요 없이 암호화 경로를 타지 않는다. +/// - **metadata**: `.unchanged`로 두면 저장된 값이 그대로 남는다. UI에 입력 경로가 없는 필드를 +/// 지키는 1차 방어선이고, 값이 실제로 바뀐 경우에만 +/// `SecretMetaFields.toCreateSecretPayload(preserving:)`의 병합이 필요해진다. +/// +/// `payload`·`metadata`를 독립된 두 플래그가 아니라 하나의 enum으로 둔 것은 **표현할 수 없는 상태를 +/// 만들지 않기 위해서**다 — "쓰지 않는데 평문은 실려 있는" 조합이 생기지 않는다. +public enum SecretContentChange: Equatable, Sendable { + + /// 공통 필드·프로젝트 연결만 바뀌었다. payload·metadata 모두 건드리지 않는다. + case none + + /// payload만 바뀌었다. + case payload(CreateSecretPayload) + + /// metadata만 바뀌었다. + case metadata(CreateSecretPayload) + + /// payload와 metadata가 함께 바뀌었다. + case payloadAndMetadata(CreateSecretPayload) + + /// metadata를 지운다 — 마지막으로 남아 있던 metadata 필드를 사용자가 비운 경우다. + /// + /// 별도 case가 필요한 이유는 도메인의 metadata overload가 **값을 요구해 `nil`을 표현할 수 없기** + /// 때문이다. 이 경우 metadata 없는 overload에 `SecretPatch.metadata = .set(nil)`을 실어 보낸다. + /// 그냥 ``none``으로 처리하면 지운 값이 DB에 남아 다시 열 때 되살아난다. + case metadataCleared + + /// payload가 바뀌면서 metadata는 비워졌다. (예: API Key 값을 고치고 Scope를 지운 경우) + case payloadAndMetadataCleared(CreateSecretPayload) +} + +// MARK: - Dispatch helpers + +/// Live 조립이 overload를 고를 때 쓴다. case를 늘리면 여기 네 프로퍼티가 컴파일 에러로 누락을 잡는다. +extension SecretContentChange { + + /// 다시 쓸 값의 출처. 쓸 것이 없으면 `nil`이고, 그때는 평문이 Client 경계를 넘지 않는다. + public var content: CreateSecretPayload? { + switch self { + case .none, .metadataCleared: + return nil + case .payload(let content), + .metadata(let content), + .payloadAndMetadata(let content), + .payloadAndMetadataCleared(let content): + return content + } + } + + /// payload를 다시 암호화해 저장할지. + public var writesPayload: Bool { + switch self { + case .payload, .payloadAndMetadata, .payloadAndMetadataCleared: + return true + case .none, .metadata, .metadataCleared: + return false + } + } + + /// metadata를 새 값으로 덮어쓸지. + public var writesMetadata: Bool { + switch self { + case .metadata, .payloadAndMetadata: + return true + case .none, .payload, .metadataCleared, .payloadAndMetadataCleared: + return false + } + } + + /// metadata 레코드를 지울지. ``writesMetadata``와 동시에 참이 되지 않는다. + public var clearsMetadata: Bool { + switch self { + case .metadataCleared, .payloadAndMetadataCleared: + return true + case .none, .payload, .metadata, .payloadAndMetadata: + return false + } + } +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index 5f0ddf02..71acca43 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -76,6 +76,19 @@ extension SecretClient: @retroactive DependencyKey { with: SecretPatch(liked: .set(liked)) ) }, + updateSecret: { id, patch, change, projectIds in + let updated = try await dispatchUpdateSecret( + useCase: patchSecretUseCase, + id: id, + patch: patch, + change: change, + projectIDs: projectIds + ) + // 만료일이 바뀌었을 수 있다. `schedule`은 예약 전에 이전 마크를 먼저 취소하므로 + // 만료일을 지운 경우의 정리까지 같은 호출이 처리한다. + await expiryUseCase.schedule(secret: updated) + return updated + }, fetchLinkedProjects: { secretID in try await fetchSecretUseCase.fetchProjects(secretID: secretID) }, @@ -103,6 +116,100 @@ extension SecretClient: @retroactive DependencyKey { }() } +// MARK: - Update Dispatch + +/// `SecretContentChange`가 가리키는 것만 다시 쓰도록 `PatchSecretUseCase`의 overload를 고른다. +/// +/// `dispatchCreateSecret`과 같은 이유로 존재한다 — 도메인 overload가 제네릭이라 Client 경계를 +/// 넘지 못하고, 구체 payload·metadata 타입은 `CreateSecretPayload`의 case에서만 나온다. +private func dispatchUpdateSecret( + useCase: any PatchSecretUseCase, + id: UUID, + patch: SecretPatch, + change: SecretContentChange, + projectIDs: PatchField<[UUID]> +) async throws -> Secret { + // metadata 삭제는 overload가 아니라 patch로 표현한다 — metadata overload는 값을 요구해 + // nil을 실을 수 없는 반면 `SecretPatch.metadata`는 `.set(nil)`을 지원한다. + let effectivePatch: SecretPatch = { + guard change.clearsMetadata else { return patch } + var next = patch + next.metadata = .set(nil) + return next + }() + + /// metadata 스키마가 있는 9개 타입용. + func write( + _ payload: Payload, + _ metadata: Metadata? + ) async throws -> Secret { + // 폼이 metadata의 모든 필드를 비우면 조립 결과가 nil이 된다. 그 삭제는 위 patch가 + // 이미 처리했으므로 여기서는 값이 있을 때만 metadata overload로 간다. + switch (change.writesPayload, change.writesMetadata ? metadata : nil) { + case (true, let metadata?): + return try await useCase.update( + id: id, patch: effectivePatch, payload: payload, metadata: metadata, projectIDs: projectIDs + ) + case (true, nil): + return try await useCase.update( + id: id, patch: effectivePatch, payload: payload, projectIDs: projectIDs + ) + case (false, let metadata?): + return try await useCase.update( + id: id, patch: effectivePatch, metadata: metadata, projectIDs: projectIDs + ) + case (false, nil): + return try await useCase.update(id: id, patch: effectivePatch, projectIDs: projectIDs) + } + } + + /// metadata 스키마 자체가 없는 타입용(envSet · custom). + func write(_ payload: Payload) async throws -> Secret { + guard change.writesPayload else { + return try await useCase.update(id: id, patch: effectivePatch, projectIDs: projectIDs) + } + return try await useCase.update( + id: id, patch: effectivePatch, payload: payload, projectIDs: projectIDs + ) + } + + // 다시 쓸 내용이 없으면 공통 필드·프로젝트 연결만 반영한다(metadata 삭제 포함). + guard let content = change.content else { + return try await useCase.update(id: id, patch: effectivePatch, projectIDs: projectIDs) + } + + switch content { + case .apiKey(let payload, let metadata), + .accessToken(let payload, let metadata), + .webhookSecret(let payload, let metadata): + return try await write(payload, metadata) + + case .oauthClient(let payload, let metadata): + return try await write(payload, metadata) + + case .serviceAccount(let payload, let metadata): + return try await write(payload, metadata) + + case .database(let payload, let metadata): + return try await write(payload, metadata) + + case .sshKey(let payload, let metadata): + return try await write(payload, metadata) + + case .sslTlsCertificate(let payload, let metadata): + return try await write(payload, metadata) + + case .licenseKey(let payload, let metadata): + return try await write(payload, metadata) + + case .environmentVariableSet(let payload): + return try await write(payload) + + case .custom(let payload): + return try await write(payload) + } +} + // MARK: - Payload Dispatch private enum PayloadAccess { From 18cdee0d52c8a63cf635f1724f205febf0bc2fa9 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:48:58 +0900 Subject: [PATCH 07/61] =?UTF-8?q?[#76]=20feat:=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=A4=91=20=ED=97=A4=EB=8D=94=20=EC=95=A1=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 편집 중에는 공유·수정·삭제를 렌더하지 않는다. 눌러도 반응하지 않는 컨트롤은 노출하지 않는다는 기준을 따른다 — #74에서 수정 버튼을 숨긴 것과 같은 이유이고, 비활성 상태로 남겨두면 그 기준과 어긋난다. 편집 중 유효한 동작은 footer의 Save / Cancel뿐이다 - 즐겨찾기만 남긴다. 별은 액션이면서 동시에 상태 표시라 숨기면 이 시크릿이 즐겨찾기인지가 화면에서 사라진다. 대신 vaultGreen에서 gray400으로 바꿔 지금은 바꿀 수 없다는 것을 알린다 — 즐겨찾기 변경은 조회 모드에서만 가능하다는 것이 정책이다 - 별에만 색을 바꾸는 이유는 나머지 셋과 성격이 달라서다. 그쪽은 원래 gray900이라 opacity로 충분하지만, 별은 유채색이라 opacity만 낮추면 연한 초록이 되어 꺼진 것으로 읽히지 않는다 - 편집 중 즐겨찾기가 성공하면 state.secret이 교체되어 저장 diff의 기준인 baseline과 어긋난다. 삭제는 편집 중인 대상을 없앤다 - 액션이 빠져도 타입명 행 높이가 흔들리지 않도록 고정을 유지한다. 서브타입 탭바는 그대로 둔다 — 이미 읽기 전용이라 같이 흐리면 편집 중에만 회색이 되는 이유 없는 차이가 생긴다 - 기본값은 조회 모드라 기존 호출부는 그대로다. 편집 모드 연결은 후속 커밋 --- .../Components/SecretDetailHeaderView.swift | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift index 119b6750..0fb3427e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift @@ -30,7 +30,19 @@ struct SecretDetailHeaderView: View { let subType: SecretSubType? let isLiked: Bool /// payload 복호화 전/실패 상태에서는 수정 진입을 막는다. + /// `isEditing`이 참이면 수정 버튼 자체가 렌더되지 않으므로 조회 모드에서만 의미가 있다. var isEditEnabled: Bool = true + /// 편집 폼 위에 얹힌 헤더인지. + /// + /// 편집 중에는 **공유·수정·삭제를 렌더하지 않는다.** 눌러도 반응하지 않는 컨트롤을 노출하지 + /// 않는다는 기준을 따르고(`#74`에서 수정 버튼을 숨긴 것과 같다), 편집 중 유효한 동작은 + /// footer의 Save / Cancel뿐이다. + /// + /// **즐겨찾기만 남긴다.** 별은 액션이면서 동시에 상태 표시라, 숨기면 이 시크릿이 즐겨찾기인지가 + /// 화면에서 사라진다. 대신 회색으로 바꿔 지금은 바꿀 수 없다는 것을 알린다 — 즐겨찾기 변경은 + /// 조회 모드에서만 가능하다는 것이 정책이다. 편집 중에 즐겨찾기가 성공하면 `state.secret`이 + /// 교체되어 저장 diff의 기준인 baseline과 어긋난다. + var isEditing: Bool = false let onToggleLike: () -> Void let onEdit: () -> Void let onDelete: () -> Void @@ -70,8 +82,11 @@ extension SecretDetailHeaderView { .lineLimit(1) } Spacer(minLength: 8) - actionsContainer + if !isEditing { + actionsContainer + } } + // 액션이 빠져도 행 높이가 흔들리지 않도록 고정한다 — 모드 전환 시 타입명이 위아래로 움직이면 안 된다. .frame(height: 30) } @@ -79,11 +94,14 @@ extension SecretDetailHeaderView { Button(action: onToggleLike) { Image(systemName: isLiked ? "star.fill" : "star") .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(Color.dv(.vaultGreen)) + // 색으로 비활성을 알린다. 별은 원래 유채색이라 opacity만 낮추면 + // "연한 초록"이 되어 꺼진 것으로 읽히지 않는다. + .foregroundStyle(Color.dv(isEditing ? .gray400 : .vaultGreen)) .frame(width: 24, height: 30) .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(isEditing) .accessibilityLabel( Text(isLiked ? .module("Remove from favorites") : .module("Add to favorites")) ) @@ -246,6 +264,23 @@ private let _headerWidth: CGFloat = 380 .previewWidth(_headerWidth + 40) } +/// 편집 중 모습. 공유·수정·삭제는 사라지고 즐겨찾기만 회색으로 남는다 — +/// 즐겨찾기 여부는 계속 보여야 하는 정보이고, 회색이 지금은 바꿀 수 없다는 표시다. +/// 액션이 빠져도 타입명 행의 높이는 조회 모드와 같아야 한다. +#Preview("편집 중 · 즐겨찾기만 회색으로 남음") { + SecretDetailHeaderView( + secretType: .apiKeyToken, + subType: .apiKey, + isLiked: true, + isEditing: true, + onToggleLike: {}, + onEdit: {}, + onDelete: {} + ) + .padding(20) + .previewWidth(_headerWidth + 40) +} + #Preview("수정 비활성 (payload 미복호화)") { SecretDetailHeaderView( secretType: .apiKeyToken, From 0d366b83f8aa04af841f41edfbfd90392b033dbb Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:55:53 +0900 Subject: [PATCH 08/61] =?UTF-8?q?[#76]=20refactor:=20=EB=B3=B5=ED=98=B8?= =?UTF-8?q?=ED=99=94=20=ED=9B=84=EC=86=8D=20=EB=8F=99=EC=9E=91=EC=9D=84=20?= =?UTF-8?q?RevealContinuation=20=ED=83=80=EC=9E=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payloadResponse의 revealing:/thenCopy: 두 파라미터를 RevealContinuation 하나로 합친다. "둘이 동시에 차는 경우는 없다"를 주석으로만 지키던 불변식을 타입이 보장하게 된다 - 후속 동작을 State가 아니라 액션에 싣는 이유는 그대로다 — 복호화는 CancelID.reveal을 공유해 나중 요청이 앞 요청을 취소하는데, State에 남겨두면 취소된 요청의 몫이 다음 응답에 얹혀 누르지도 않은 동작이 일어난다 - 동작 변경 없음. 수정 진입도 복호화를 타야 해서 곧 세 번째 후속 동작이 생기는데, 파라미터를 하나 더 늘리면 같은 불변식을 주석 하나로 더 지켜야 한다 --- .../SecretDetail/SecretDetailFeature.swift | 85 +++++++++++-------- .../SecretDetailFeatureTests.swift | 18 ++-- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 11cd99a5..8bb28281 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -19,6 +19,25 @@ public struct SecretDetailFeature { case editing } + // MARK: - RevealContinuation + + /// 복호화가 끝난 뒤 이어서 할 일. + /// + /// 이어서 할 일을 State가 아니라 **액션에 싣는 것**은 취소와 함께 사라지게 하려는 것이다. + /// 복호화는 `CancelID.reveal`을 공유해 나중 요청이 앞 요청을 취소하는데, State에 남겨두면 + /// 취소된 요청의 몫이 다음 응답에 얹혀 누르지도 않은 동작이 일어난다. + /// + /// 하나만 찰 수 있다는 것을 **타입이 보장한다** — 이전에는 `revealing:`·`thenCopy:` 두 파라미터를 + /// 두고 "둘이 동시에 차는 경우는 없다"를 주석으로만 지켰다. + public enum RevealContinuation: Equatable { + /// 값만 받아온다. 재시도 경로처럼 어떤 필드가 유발했는지 잃은 경우. + case none + /// 눈 버튼이 유발했다. 성공하면 그 필드가 열린다. + case reveal(SecretFieldID) + /// 복사 버튼이 유발했다. 성공하면 이어서 복사한다. + case copy(SecretFieldID) + } + // MARK: - State @ObservableState @@ -86,16 +105,10 @@ public struct SecretDetailFeature { // MARK: Internal case linkedProjectsResponse(Result<[Project], SecretUseCaseError>) - /// 복호화 응답. 복호화를 유발한 동작을 함께 싣는다 — 눈 버튼이면 `revealing`에 그 필드가, - /// 복사 버튼이면 `thenCopy`에 그 필드가 담긴다. 둘이 동시에 차는 경우는 없다. - /// - /// 이어서 할 일을 State가 아니라 액션에 싣는 것은 **취소와 함께 사라지게 하려는 것**이다. - /// 복호화는 `CancelID.reveal`을 공유해 나중 요청이 앞 요청을 취소하는데, State에 남겨두면 - /// 취소된 요청의 몫이 다음 응답에 얹혀 누르지도 않은 복사가 일어난다. + /// 복호화 응답. 복호화를 유발한 동작을 `then`에 함께 싣는다 (``RevealContinuation`` 참조). case payloadResponse( Result, - revealing: SecretFieldID?, - thenCopy: SecretFieldID? + then: RevealContinuation ) /// 인증만 수행한 결과. payload를 이미 들고 있는데 창만 만료된 경우에 쓴다. case reauthenticateResponse(Result, revealing: SecretFieldID) @@ -173,7 +186,7 @@ public struct SecretDetailFeature { // 복호화 실패 후 재시도. 어떤 필드가 유발했는지는 이미 잃었으므로 값만 다시 받아온다. case .didTapRetryReveal: state.payloadState = .loading - return revealEffect(secret: state.secret, revealing: nil, thenCopy: nil) + return revealEffect(secret: state.secret, then: .none) // 디자인에서 close(×) 버튼을 제거했으므로 현재 이 액션을 발생시키는 UI 경로가 없다. // detail은 사이드바 전환·리스트 선택 해제(`secretSelected(nil)`)로 닫힌다. @@ -191,20 +204,26 @@ public struct SecretDetailFeature { state.alert = .projectsLoadFailed return .none - // Reveal이 유발한 복호화만 인증 성공 시각을 갱신한다. - // Copy 전용 경로는 이어지는 Copy UseCase가 별도로 인증 정책을 적용한다. - case .payloadResponse(.success(let payload), let revealing, let thenCopy): + // 복호화는 인증을 통과해야만 성공하므로, 도착 자체가 인증 성공을 뜻한다. + // + // 다만 **복사가 유발한 복호화는 열람 인증 창을 열지 않는다.** 복사는 자체 정책을 + // 따로 갖고(`CopySensitiveValueUseCase`가 설정을 읽어 결정한다), 여기서 창을 열면 + // 복사 한 번에 누르지도 않은 열람 권한이 따라붙는다. + case .payloadResponse(.success(let payload), let continuation): state.payloadState = .loaded(payload) - if thenCopy == nil { + switch continuation { + case .none: state.revealAuthorizedAt = now + return .none + case .reveal(let field): + state.revealAuthorizedAt = now + state.revealedFields.insert(field) + return .none + case .copy(let field): + return copyEffect(value: payload.value(for: field)) } - if let revealing { - state.revealedFields.insert(revealing) - } - guard let thenCopy else { return .none } - return copyEffect(value: payload.value(for: thenCopy)) - case .payloadResponse(.failure(let error), _, _): + case .payloadResponse(.failure(let error), _): state.payloadState = .failed(error) state.alert = .payloadRevealFailed(SecretDetailError.map(error)) return .none @@ -240,7 +259,7 @@ public struct SecretDetailFeature { // 값이 아직 없으면 복호화가 필요하고, 복호화 경로가 인증까지 함께 수행한다. guard case .loaded = state.payloadState else { state.payloadState = .loading - return revealEffect(secret: state.secret, revealing: field, thenCopy: nil) + return revealEffect(secret: state.secret, then: .reveal(field)) } // 값은 있고 인증 창만 남았는지 확인한다. 열려 있으면 인증 없이 연다. guard !revealAuthPolicy.isAuthorized(since: state.revealAuthorizedAt, now: now) else { @@ -323,33 +342,28 @@ public struct SecretDetailFeature { // MARK: - Effects /// 인증 + 복호화. `revealPayload`가 둘을 함께 하므로 값이 아직 없을 때만 쓴다. - /// - Parameter revealing: 이 복호화를 유발한 필드. 성공하면 함께 열린다. 재시도 경로에서는 `nil`. - /// - Parameter thenCopy: 복호화가 끝나면 이어서 복사할 필드. 복사 버튼이 유발한 경우에만 채운다. + /// - Parameter then: 복호화가 끝나면 이어서 할 일 (``RevealContinuation``). private func revealEffect( secret: Secret, - revealing: SecretFieldID?, - thenCopy: SecretFieldID? + then continuation: RevealContinuation ) -> Effect { .run { send in do { let payload = try await secretClient.revealPayload(secret) - await send(.payloadResponse(.success(payload), revealing: revealing, thenCopy: thenCopy)) + await send(.payloadResponse(.success(payload), then: continuation)) } catch is CancellationError { } catch { - await send( - .payloadResponse( - .failure(SecretUseCaseError.map(error)), - revealing: revealing, - thenCopy: thenCopy - ) - ) + await send(.payloadResponse(.failure(SecretUseCaseError.map(error)), then: continuation)) } } .cancellable(id: CancelID.reveal, cancelInFlight: true) } /// Copy에 필요한 payload만 복호화한다. 인증은 이어지는 `copySensitiveValue`가 설정을 읽어 - /// 결정하며, 이 응답은 Reveal 인증 유효시간을 갱신하지 않는다. + /// 결정하며, 이 응답은 Reveal 인증 유효시간을 갱신하지 않는다(`.copy` continuation). + /// + /// `revealEffect`와 나뉘어 있는 것은 **Client 호출이 다르기 때문**이다 — + /// 이쪽은 `loadPayloadForCopy`, 저쪽은 `revealPayload`로 인증 정책 자체가 갈린다. private func loadPayloadForCopyEffect( secret: Secret, field: SecretFieldID @@ -357,14 +371,13 @@ public struct SecretDetailFeature { .run { send in do { let payload = try await secretClient.loadPayloadForCopy(secret) - await send(.payloadResponse(.success(payload), revealing: nil, thenCopy: field)) + await send(.payloadResponse(.success(payload), then: .copy(field))) } catch is CancellationError { } catch { await send( .payloadResponse( .failure(SecretUseCaseError.map(error)), - revealing: nil, - thenCopy: field + then: .copy(field) ) ) } diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index bbcb0158..a29bcba1 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -68,7 +68,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapToggleReveal(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.success(payload), revealing: .value, thenCopy: nil)) { + await store.receive(.payloadResponse(.success(payload), then: .reveal(.value))) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate $0.revealedFields = [.value] @@ -88,7 +88,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapToggleReveal(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)), revealing: .value, thenCopy: nil)) { + await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)), then: .reveal(.value))) { $0.payloadState = .failed(.cryptoFailure(.decryptionFailed)) $0.alert = .payloadRevealFailed(.decryptionFailed) } @@ -107,7 +107,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapToggleReveal(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), revealing: .value, thenCopy: nil)) { + await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), then: .reveal(.value))) { $0.payloadState = .failed(.authenticationFailure(.cancelled)) $0.alert = .payloadRevealFailed(.authRequired) } @@ -175,7 +175,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapRetryReveal) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.success(payload), revealing: nil, thenCopy: nil)) { + await store.receive(.payloadResponse(.success(payload), then: .none)) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate } @@ -194,7 +194,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapRetryReveal) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)), revealing: nil, thenCopy: nil)) { + await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)), then: .none)) { $0.payloadState = .failed(.cryptoFailure(.decryptionFailed)) $0.alert = .payloadRevealFailed(.decryptionFailed) } @@ -226,7 +226,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapToggleReveal(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), revealing: .value, thenCopy: nil)) { + await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), then: .reveal(.value))) { $0.payloadState = .failed(.authenticationFailure(.cancelled)) $0.alert = .payloadRevealFailed(.authRequired) } @@ -238,7 +238,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapRetryReveal) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.success(payload), revealing: nil, thenCopy: nil)) { + await store.receive(.payloadResponse(.success(payload), then: .none)) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate } @@ -421,7 +421,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapCopy(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.success(payload), revealing: nil, thenCopy: .value)) { + await store.receive(.payloadResponse(.success(payload), then: .copy(.value))) { $0.payloadState = .loaded(payload) } await store.receive(.copyResponse(.success(true))) @@ -489,7 +489,7 @@ struct SecretDetailFeatureTests { await store.send(.didTapToggleReveal(.clientId)) await clock.advance(by: .seconds(1)) - await store.receive(.payloadResponse(.success(payload), revealing: .clientId, thenCopy: nil)) { + await store.receive(.payloadResponse(.success(payload), then: .reveal(.clientId))) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate $0.revealedFields = [.clientId] From a3384a2c4f7767cca013541d91e746633faf294f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:03:50 +0900 Subject: [PATCH 09/61] =?UTF-8?q?[#76]=20feat:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EC=A7=84=EC=9E=85=EA=B3=BC=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - didTapEdit — 편집 폼의 type-specific 필드는 평문에서만 만들 수 있어(secret.payload는 암호문) 값이 없으면 복호화를, 따라서 인증을 먼저 탄다. 이미 .loaded면 인증 없이 들어간다. TTL은 보지 않는다 - 복호화에 실패하면 조회 모드에 남는다. 인증을 취소했는데 편집 화면이 열려 있으면 안 된다 - didTapCancelEdit — baseline과 같으면 확인 없이 나간다. 물어보는 것 자체가 성가신 확인이 된다. 다르면 confirmDiscard alert(프리셋과 Action case는 이미 있었고 수신 핸들러만 없었다) - baseline을 둘로 나눈다. editFieldsBaseline은 "사용자가 폼에서 무엇을 건드렸나"(취소 판정), editPayloadBaseline은 "저장할 때 무엇을 다시 써야 하나"(dirty 판정·metadata 병합). 전자만 있으면 UI 미노출 metadata를 병합할 원본이 없고, 후자만 있으면 projectIds·memo 같은 payload 밖 변경을 취소 확인에서 놓친다. payloadState와도 분리해 편집 중 다른 필드를 reveal해도 기준이 흔들리지 않게 한다 - 프로젝트 선택 옵션은 편집 진입 시에만 읽는다. 조회만 하는 사용자에게 전체 목록을 읽힐 이유가 없다. 조회 실패는 alert만 띄우고 편집은 계속하게 둔다 — 프로젝트 연결만 못 바꿀 뿐 나머지 필드는 영향이 없다 - SectionView 9종이 onCreateProject를 요구하므로 CreateProjectFeature 시트를 연결한다. 생성된 프로젝트는 목록에 얹기만 하고 재조회하지 않는다 — 편집 중인 폼이 그대로 있어야 한다 - CreateSecretPayload에 Sendable을 붙인다. 이미 @Sendable Client 클로저를 넘나들고 있었고, 실린 타입들은 SecretPayloadData / SecretMetadataContent가 Sendable을 요구해 조건 없이 성립한다 --- .../Model/CreateSecretPayload.swift | 5 +- .../SecretDetail/SecretDetailFeature.swift | 139 +++++++++- .../SecretDetailFeatureTests.swift | 260 +++++++++++++++++- 3 files changed, 397 insertions(+), 7 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreateSecretPayload.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreateSecretPayload.swift index 3897d955..aa578d4d 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreateSecretPayload.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreateSecretPayload.swift @@ -6,7 +6,10 @@ import Foundation /// `SecretMetaFields` → 도메인 매핑 결과 컨테이너. /// (secretType, subType) 조합별로 (Payload, Metadata?) 페어를 실은 enum. /// 소비 지점에서 case 분기해 `CreateSecretUseCase.execute`의 두 overload(metadata 유무) 중 하나로 dispatch한다. -public enum CreateSecretPayload: Equatable { +/// `Sendable`인 것은 이미 `@Sendable` Client 클로저(`revealPayload` · `createSecret` · `updateSecret`)를 +/// 넘나들기 때문이다. 실린 payload·metadata 타입은 `SecretPayloadData` / `SecretMetadataContent`가 +/// `Sendable`을 요구하므로 조건 없이 성립한다. +public enum CreateSecretPayload: Equatable, Sendable { case apiKey(APIKeyPayload, APIKeyMetadata?) case accessToken(APIKeyPayload, APIKeyMetadata?) case webhookSecret(APIKeyPayload, APIKeyMetadata?) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 8bb28281..a4cdf47e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -36,6 +36,12 @@ public struct SecretDetailFeature { case reveal(SecretFieldID) /// 복사 버튼이 유발했다. 성공하면 이어서 복사한다. case copy(SecretFieldID) + /// 수정 버튼이 유발했다. 성공하면 편집 모드로 들어간다. + /// + /// 편집 폼의 type-specific 필드는 평문에서만 만들 수 있어서(`secret.payload`는 암호문) + /// **수정 진입이 복호화를, 따라서 인증을 탄다.** 실패하면 조회 모드에 남는다 — + /// 인증을 취소했는데 편집 화면이 열려 있으면 안 된다. + case edit } // MARK: - State @@ -56,9 +62,25 @@ public struct SecretDetailFeature { public var mode: Mode = .viewing /// 수정 모드에서만 유효. viewing일 때는 반드시 nil. var editFields: SecretMetaFields? + /// 편집 진입 시점의 폼 스냅샷. **사용자가 폼에서 무엇을 건드렸는지**의 기준이다. + /// 취소 시 확인 alert를 띄울지 판정하고, 저장 시 공통 필드·프로젝트 연결의 변경분을 가린다. + var editFieldsBaseline: SecretMetaFields? + /// 편집 진입 시점의 복호화 payload. **저장할 때 무엇을 다시 써야 하는지**의 기준이다. + /// + /// `editFieldsBaseline`과 따로 두는 이유는 둘의 쓰임이 다르기 때문이다. 전자만 있으면 + /// UI에 노출되지 않는 metadata 필드를 병합할 원본이 없고, 후자만 있으면 `projectIds`·`memo`처럼 + /// payload 밖에 있는 변경을 취소 확인에서 놓친다. + /// `payloadState`와도 분리한다 — 편집 중 다른 필드를 reveal해도 병합 기준이 흔들리면 안 된다. + var editPayloadBaseline: CreateSecretPayload? /// 이 Secret에 연결된 Project. `Secret` 엔티티에 프로젝트 정보가 없어 별도 조회한다. /// 조회 화면의 Project 필드 표시와, 수정 진입 시 `projectIds` 초기값에 함께 쓰인다. public var linkedProjects: [Project] = [] + /// 프로젝트 선택 드롭다운의 옵션. **편집 진입 시에만 로드한다** — + /// 조회만 하는 사용자에게 전체 프로젝트 목록을 읽힐 이유가 없다. + public var availableProjects: [Project] = [] + public var isLoadingProjects = false + /// 저장 시도에서 누락된 필수 필드의 인라인 경고. 생성 화면과 같은 규칙으로 채운다. + var validationErrors: [SecretFieldID: String] = [:] public var isSaving = false /// 삭제 요청 진행 중. 진행 중에는 삭제 버튼을 비활성화한다. public var isDeleting = false @@ -73,6 +95,8 @@ public struct SecretDetailFeature { /// 새로 할당하므로 창이 자동으로 닫힌다("시크릿 변경 시 재인증"). var revealAuthorizedAt: Date? @Presents public var alert: AlertState? + /// 프로젝트 생성 시트. 편집 폼의 SectionView가 `onCreateProject`로 연다. + @Presents var createProject: CreateProjectFeature.State? public init(secret: Secret) { self.secret = secret @@ -102,9 +126,13 @@ public struct SecretDetailFeature { case didTapEdit case didTapCancelEdit case didTapSave + /// 편집 폼의 프로젝트 필드에서 "새 프로젝트" 를 누른 경우. + case didTapCreateProject // MARK: Internal case linkedProjectsResponse(Result<[Project], SecretUseCaseError>) + /// 편집 폼의 프로젝트 선택 옵션. 조회 중에는 조회하지 않는다. + case availableProjectsResponse(Result<[Project], ProjectUseCaseError>) /// 복호화 응답. 복호화를 유발한 동작을 `then`에 함께 싣는다 (``RevealContinuation`` 참조). case payloadResponse( Result, @@ -121,6 +149,7 @@ public struct SecretDetailFeature { // MARK: Child case alert(PresentationAction) + case createProject(PresentationAction) // MARK: Delegate case delegate(Delegate) @@ -221,6 +250,9 @@ public struct SecretDetailFeature { return .none case .copy(let field): return copyEffect(value: payload.value(for: field)) + case .edit: + beginEditing(&state, payload: payload) + return .none } case .payloadResponse(.failure(let error), _): @@ -326,21 +358,118 @@ public struct SecretDetailFeature { state.alert = .deleteFailed return .none - // 편집 모드는 후속 이슈 범위다. 상태 전이가 없으므로 헤더의 수정 버튼은 - // `isEditEnabled: false`로 비활성 렌더되고 Cancel / Save 컨트롤은 노출되지 않는다. - // 여기서는 액션 case만 예약해 둔다. - case .didTapEdit, .didTapCancelEdit, .didTapSave: + // 편집 폼은 평문이 있어야 만들 수 있다. 값이 없으면 복호화(= 인증)를 먼저 타고, + // 성공한 뒤에야 편집 모드로 들어간다 (`RevealContinuation.edit`). + case .didTapEdit: + // 헤더에서 수정 버튼이 사라지므로 편집 중에는 도달하지 않지만, + // 상태 전이를 한곳에서 막아 두는 편이 안전하다. + guard state.mode == .viewing else { return .none } + state.isLoadingProjects = true + let projects = availableProjectsEffect() + + // 값이 이미 있으면 인증 없이 들어간다. TTL은 보지 않는다 — 여기서 지키려는 것은 + // "값 없이 편집에 들어가지 않는다"이지 노출 통제가 아니고, 값은 이미 메모리에 있다. + if case .loaded(let payload) = state.payloadState { + beginEditing(&state, payload: payload) + return projects + } + state.payloadState = .loading + return .merge(revealEffect(secret: state.secret, then: .edit), projects) + + // 건드린 것이 없으면 확인 없이 나간다. 물어보는 것 자체가 성가신 확인이 된다. + case .didTapCancelEdit: + guard state.mode == .editing else { return .none } + guard state.editFields == state.editFieldsBaseline else { + state.alert = .confirmDiscard + return .none + } + endEditing(&state) + return .none + + case .alert(.presented(.confirmDiscard)): + endEditing(&state) return .none - case .binding, .alert, .delegate: + case .availableProjectsResponse(.success(let projects)): + state.isLoadingProjects = false + state.availableProjects = projects + return .none + + // 편집은 계속할 수 있게 둔다 — 프로젝트 연결만 못 바꿀 뿐 나머지 필드는 영향이 없다. + case .availableProjectsResponse(.failure): + state.isLoadingProjects = false + state.availableProjects = [] + state.alert = .projectsLoadFailed + return .none + + case .didTapCreateProject: + state.createProject = CreateProjectFeature.State() + return .none + + // 방금 만든 프로젝트를 목록에 얹는다. 전체 재조회는 하지 않는다 — + // 편집 중인 폼이 그대로 있어야 하고, 필요한 정보는 delegate가 다 실어 준다. + case .createProject(.presented(.delegate(.projectCreated(let project)))): + state.availableProjects.append( + Project(id: project.id, name: project.name, createdAt: now, updatedAt: now) + ) + return .none + + // 저장은 후속 커밋 범위다. + case .didTapSave: + return .none + + case .binding, .alert, .delegate, .createProject: return .none } } .ifLet(\.$alert, action: \.alert) + .ifLet(\.$createProject, action: \.createProject) { + CreateProjectFeature() + } + } + + // MARK: - Editing + + /// 편집 진입. 폼 초기값과 dirty 판정 기준 둘을 함께 세운다. + private func beginEditing(_ state: inout State, payload: CreateSecretPayload) { + let fields = SecretMetaFields( + secret: state.secret, + payload: payload, + // 조회 화면은 chip 라벨 때문에 엔티티를 들고 있다. 폼은 ID만 쓴다. + projectIds: state.linkedProjects.map(\.id) + ) + state.editFields = fields + state.editFieldsBaseline = fields + state.editPayloadBaseline = payload + state.validationErrors = [:] + state.mode = .editing + } + + /// 편집 종료. 편집 전용 상태를 한 번에 비운다 — + /// 하나라도 남으면 다음 진입의 dirty 판정 기준이 어긋난다. + private func endEditing(_ state: inout State) { + state.mode = .viewing + state.editFields = nil + state.editFieldsBaseline = nil + state.editPayloadBaseline = nil + state.validationErrors = [:] } // MARK: - Effects + /// 편집 폼의 프로젝트 선택 옵션을 읽어 온다. + private func availableProjectsEffect() -> Effect { + .run { send in + do { + let projects = try await secretClient.fetchProjects() + await send(.availableProjectsResponse(.success(projects))) + } catch is CancellationError { + } catch { + await send(.availableProjectsResponse(.failure(ProjectUseCaseError.map(error)))) + } + } + } + /// 인증 + 복호화. `revealPayload`가 둘을 함께 하므로 값이 아직 없을 때만 쓴다. /// - Parameter then: 복호화가 끝나면 이어서 할 일 (``RevealContinuation``). private func revealEffect( diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index a29bcba1..051ea8f6 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -17,7 +17,9 @@ struct SecretDetailFeatureTests { // MARK: - Helpers - private static func makeSecret( + /// 순수 팩토리라 격리가 필요 없다. `nonisolated`인 것은 기본 인자 위치에서도 부를 수 있어야 + /// 하기 때문이다 — 기본 인자는 함수의 격리와 무관하게 nonisolated 문맥에서 평가된다. + nonisolated private static func makeSecret( name: String = "Test Secret", secretType: SecretType = .apiKeyToken, liked: Bool = false @@ -674,4 +676,260 @@ struct SecretDetailFeatureTests { $0.alert = .deleteFailed } } + + // MARK: - 편집 진입 + + /// 편집 폼의 type-specific 필드는 평문에서만 만들 수 있다. 값이 이미 있으면 다시 받을 이유가 + /// 없으므로 인증도 복호화도 타지 않는다 — `revealPayload`를 스텁하지 않아, 불리면 실패한다. + @Test("수정 진입: 값이 이미 있으면 인증 없이 편집 모드로 들어간다") + func didTapEdit_withLoadedPayload_entersEditingWithoutAuthentication() async { + let secret = Self.makeSecret(name: "GitHub Token") + let payload = Self.editablePayload + let project = Project( + id: UUID(), name: "DrinkiG", + createdAt: Self.referenceDate, updatedAt: Self.referenceDate + ) + + let store = TestStore( + initialState: { + var state = SecretDetailFeature.State(secret: secret) + state.payloadState = .loaded(payload) + state.linkedProjects = [project] + return state + }() + ) { + SecretDetailFeature() + } withDependencies: { + // 프로젝트 목록은 이 테스트의 관심사가 아니다. 액션을 내지 않게 해 순서 의존을 없앤다. + $0.secretClient.fetchProjects = { throw CancellationError() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapEdit) { + $0.isLoadingProjects = true + $0.mode = .editing + $0.editFields = SecretMetaFields(secret: secret, payload: payload, projectIds: [project.id]) + $0.editFieldsBaseline = $0.editFields + $0.editPayloadBaseline = payload + } + } + + @Test("수정 진입: 값이 없으면 복호화한 뒤 편집 모드로 들어간다") + func didTapEdit_withoutPayload_decryptsThenEnters() async { + let secret = Self.makeSecret() + let payload = Self.editablePayload + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.revealPayload = { _ in payload } + $0.secretClient.fetchProjects = { throw CancellationError() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapEdit) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.payloadResponse(.success(payload), then: .edit)) { + $0.payloadState = .loaded(payload) + $0.revealAuthorizedAt = Self.referenceDate + $0.mode = .editing + $0.editFields = SecretMetaFields(secret: secret, payload: payload, projectIds: []) + $0.editFieldsBaseline = $0.editFields + $0.editPayloadBaseline = payload + } + } + + /// 인증을 취소했는데 편집 화면이 열려 있으면 안 된다. TestStore가 상태 변화를 전수 검사하므로 + /// mode가 `.editing`으로 넘어갔다면 이 테스트가 실패한다. + @Test("수정 진입: 복호화에 실패하면 조회 모드에 남는다") + func didTapEdit_decryptionFailure_staysViewing() async { + let secret = Self.makeSecret() + + let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.revealPayload = { _ in + throw SecretUseCaseError.authenticationFailure(.cancelled) + } + $0.secretClient.fetchProjects = { throw CancellationError() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapEdit) { + $0.isLoadingProjects = true + $0.payloadState = .loading + } + await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), then: .edit)) { + $0.payloadState = .failed(.authenticationFailure(.cancelled)) + $0.alert = .payloadRevealFailed(.authRequired) + } + } + + // MARK: - 편집 취소 + + @Test("취소: 건드린 것이 없으면 확인 없이 조회로 돌아간다") + func didTapCancelEdit_withoutChanges_returnsImmediately() async { + let store = TestStore(initialState: Self.editingState()) { + SecretDetailFeature() + } + + await store.send(.didTapCancelEdit) { + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + } + + @Test("취소: 변경이 있으면 확인 alert를 띄우고 편집 모드에 남는다") + func didTapCancelEdit_withChanges_presentsConfirmDiscard() async { + var initial = Self.editingState() + initial.editFields?.memo = "고친 메모" + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapCancelEdit) { + $0.alert = .confirmDiscard + } + } + + @Test("취소 확정: 편집 전용 상태가 모두 비워진다") + func confirmDiscard_clearsEditingState() async { + var initial = Self.editingState() + initial.editFields?.name = "고친 이름" + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapCancelEdit) { + $0.alert = .confirmDiscard + } + await store.send(.alert(.presented(.confirmDiscard))) { + $0.alert = nil + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + } + + // MARK: - 편집 폼의 프로젝트 목록 + + @Test("편집 진입 시 프로젝트 선택 옵션을 읽어 온다") + func didTapEdit_loadsAvailableProjects() async { + let secret = Self.makeSecret() + let payload = Self.editablePayload + let projects = [ + Project(id: UUID(), name: "DrinkiG", createdAt: Self.referenceDate, updatedAt: Self.referenceDate) + ] + + let store = TestStore( + initialState: { + var state = SecretDetailFeature.State(secret: secret) + state.payloadState = .loaded(payload) + return state + }() + ) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.fetchProjects = { projects } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapEdit) { + $0.isLoadingProjects = true + $0.mode = .editing + $0.editFields = SecretMetaFields(secret: secret, payload: payload, projectIds: []) + $0.editFieldsBaseline = $0.editFields + $0.editPayloadBaseline = payload + } + await store.receive(.availableProjectsResponse(.success(projects))) { + $0.isLoadingProjects = false + $0.availableProjects = projects + } + } + + /// 프로젝트 연결만 못 바꿀 뿐 나머지 필드는 영향이 없으므로 편집을 계속할 수 있게 둔다. + @Test("프로젝트 목록 조회 실패: alert를 띄우되 편집 모드는 유지한다") + func availableProjectsFailure_keepsEditing() async { + let secret = Self.makeSecret() + let payload = Self.editablePayload + + let store = TestStore( + initialState: { + var state = SecretDetailFeature.State(secret: secret) + state.payloadState = .loaded(payload) + return state + }() + ) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.fetchProjects = { throw ProjectUseCaseError.unexpected } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapEdit) { + $0.isLoadingProjects = true + $0.mode = .editing + $0.editFields = SecretMetaFields(secret: secret, payload: payload, projectIds: []) + $0.editFieldsBaseline = $0.editFields + $0.editPayloadBaseline = payload + } + await store.receive(.availableProjectsResponse(.failure(.unexpected))) { + $0.isLoadingProjects = false + $0.alert = .projectsLoadFailed + } + } + + /// 전체 재조회를 하지 않는 것이 요지다 — 편집 중인 폼이 그대로 있어야 한다. + @Test("프로젝트 생성 시트: 만들어진 프로젝트가 선택 옵션에 더해진다") + func createProject_appendsToAvailableProjects() async { + let newItem = ProjectItem(id: UUID(), name: "새 프로젝트") + + let store = TestStore(initialState: Self.editingState()) { + SecretDetailFeature() + } withDependencies: { + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapCreateProject) { + $0.createProject = CreateProjectFeature.State() + } + await store.send(.createProject(.presented(.delegate(.projectCreated(newItem))))) { + $0.availableProjects = [ + Project( + id: newItem.id, name: newItem.name, + createdAt: Self.referenceDate, updatedAt: Self.referenceDate + ) + ] + } + } +} + +// MARK: - 편집 Fixtures + +@MainActor +extension SecretDetailFeatureTests { + + /// metadata까지 있는 조합이라야 보존·dirty 판정 경로가 함께 걸린다. + fileprivate static var editablePayload: CreateSecretPayload { + .apiKey(APIKeyPayload(value: "ghp_x"), APIKeyMetadata(scope: "repo:read")) + } + + /// 편집 진입을 마친 상태. `beginEditing`이 세우는 것과 같은 조합이어야 한다. + fileprivate static func editingState( + secret: Secret = makeSecret(name: "GitHub Token"), + projectIds: [Project.ID] = [] + ) -> SecretDetailFeature.State { + let payload = editablePayload + var state = SecretDetailFeature.State(secret: secret) + state.payloadState = .loaded(payload) + let fields = SecretMetaFields(secret: secret, payload: payload, projectIds: projectIds) + state.editFields = fields + state.editFieldsBaseline = fields + state.editPayloadBaseline = payload + state.mode = .editing + return state + } } From a251c800435cde2f7e9d9913658de897a0680f99 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:20:01 +0900 Subject: [PATCH 10/61] =?UTF-8?q?[#76]=20feat:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=E2=80=94=20dirty=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20=EB=8B=A4=EC=8B=9C=20=EC=93=B8=20=EB=8C=80=EC=83=81?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - didTapSave — 변경 없음 판정 → 필수 필드 검증 → 다시 쓸 대상 결정 순이다. 아무것도 안 바뀌었으면 도메인을 부르지 않는다. 부르면 updatedAt만 갱신되어 목록의 "최근 추가" 정렬이 이유 없이 흔들리고, 재조회할 이유도 없으니 delegate도 보내지 않는다 - CreateSecretPayload.diff — 폼 필드가 아니라 매핑 결과를 비교한다. 어떤 폼 필드가 payload로 가고 어떤 것이 metadata로 가는지는 SecretMetaFields+Mapping만 알아야 하고, 폼 쪽에서 비교하면 그 규칙을 아는 코드가 한 벌 더 생겨 매핑이 바뀔 때 조용히 어긋난다 - contentChange — diff 결과와 metadata 유무로 SecretContentChange 6 case 중 하나를 고른다. metadata가 바뀌었는데 값이 nil이면 지우는 것이다 - 공통 필드는 toSecretDraft 결과끼리 비교해 바뀐 것만 .set으로 싣는다. 폼 값이 아니라 draft로 비교하는 것은 ""→nil 접힘 같은 매핑 규칙을 여기서 한 벌 더 알지 않기 위해서다 - 프로젝트 연결은 집합으로 비교한다. 순서만 다른 것은 변경이 아니다 — 드롭다운에서 고른 순서가 저장 여부를 바꾸면 안 된다 - 저장 성공 시 payloadState를 방금 저장한 값으로 바꾸고 revealedFields를 비운다. 전자를 빠뜨리면 조회로 돌아가 눈 버튼을 눌렀을 때 저장 전 값이 보이고, 후자를 빠뜨리면 조회 모드의 기본 상태(전부 마스킹)와 어긋난다. linkedProjects도 연결이 바뀐 경우에만 갱신한다 - 저장 실패는 편집 모드를 유지한다. 조회로 되돌리면 입력한 내용이 통째로 사라진다 - fix: Secret.subType이 nil이면 저장이 조용히 실패하던 문제. 도메인에서 optional이라 예전 데이터는 비어 있을 수 있는데, 그대로 넘기면 toCreateSecretPayload가 invalidTypeCombination으로 떨어져 아무 일도 일어나지 않았다. dispatchRevealPayload와 헤더 탭바가 이미 쓰던 "nil이면 첫 서브타입" 폴백을 resolvedSubType으로 모으고 세 곳이 같은 정의를 쓰게 한다 — 갈리면 조회는 되는데 저장만 실패한다 --- .../Components/SecretDetailHeaderView.swift | 6 +- .../Model/CreateSecretPayload+Diff.swift | 92 +++++++++ .../Model/SecretDetailError.swift | 14 ++ .../Model/SecretTypeReverseMapping.swift | 14 ++ .../SecretDetail/SecretDetailFeature.swift | 116 +++++++++++- .../CreateSecretPayloadDiffTests.swift | 178 ++++++++++++++++++ .../SecretDetailFeatureTests.swift | 169 +++++++++++++++++ 7 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift create mode 100644 Projects/DVPresentation/Tests/SecretDetail/CreateSecretPayloadDiffTests.swift diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift index 0fb3427e..472508d1 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift @@ -51,10 +51,10 @@ struct SecretDetailHeaderView: View { secretType.creatableType.availableSubTypes } - /// `subType`이 nil이면 첫 서브타입으로 간주한다 — - /// live `dispatchRevealPayload`가 `(.apiKeyToken, nil)`을 `.apiKey`로 처리하는 것과 동일한 규칙. + /// `subType`이 nil일 때의 폴백은 `resolvedSubType`이 정의한다 — 저장 경로와 같은 규칙을 써야 + /// 화면에 보이는 서브타입과 저장되는 서브타입이 갈리지 않는다. private var selectedSubType: CreatableSecretSubType? { - subType?.creatableSubType ?? subTypes.first + secretType.creatableType.resolvedSubType(subType) } var body: some View { diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift new file mode 100644 index 00000000..9c29e148 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift @@ -0,0 +1,92 @@ +// Copyright © 2026 Devault. All rights reserved + +// MARK: - payload / metadata dirty 판정 + +extension CreateSecretPayload { + + /// baseline 대비 payload 성분과 metadata 성분이 각각 바뀌었는지 판정한다. + /// + /// **폼 필드가 아니라 매핑 결과를 비교한다.** 어떤 폼 필드가 payload로 가고 어떤 것이 metadata로 + /// 가는지는 `SecretMetaFields+Mapping`만 알아야 한다. 폼 쪽에서 비교하면 그 규칙을 아는 코드가 + /// 한 벌 더 생기고, 매핑이 바뀔 때 조용히 어긋난다. + /// + /// 서브타입은 수정 화면에서 바뀌지 않으므로(payload 스키마 자체가 달라져 수정이 아니라 재생성이다) + /// 두 값은 항상 같은 case다. + static func diff( + baseline: CreateSecretPayload, + updated: CreateSecretPayload + ) -> (payload: Bool, metadata: Bool) { + switch (baseline, updated) { + case (.apiKey(let old, let oldMeta), .apiKey(let new, let newMeta)), + (.accessToken(let old, let oldMeta), .accessToken(let new, let newMeta)), + (.webhookSecret(let old, let oldMeta), .webhookSecret(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.oauthClient(let old, let oldMeta), .oauthClient(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.serviceAccount(let old, let oldMeta), .serviceAccount(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.database(let old, let oldMeta), .database(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.sshKey(let old, let oldMeta), .sshKey(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.sslTlsCertificate(let old, let oldMeta), .sslTlsCertificate(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + case (.licenseKey(let old, let oldMeta), .licenseKey(let new, let newMeta)): + return (old != new, oldMeta != newMeta) + + // metadata 스키마가 없는 타입이라 metadata는 언제나 변경 없음이다. + case (.environmentVariableSet(let old), .environmentVariableSet(let new)): + return (old != new, false) + + case (.custom(let old), .custom(let new)): + return (old != new, false) + + // 서브타입이 고정이므로 도달할 수 없다. 도달했다면 매핑 버그이고, 그때 변경 없음으로 + // 처리하면 사용자 입력이 조용히 사라진다 — 보수적으로 둘 다 다시 쓴다. + default: + assertionFailure("서브타입이 다른 payload 조합: \(baseline) → \(updated)") + return (true, true) + } + } + + /// `diff` 결과와 최종 payload로 저장 시 다시 쓸 대상을 정한다. + /// + /// metadata가 바뀌었는데 값이 `nil`이면 **지우는 것**이다. 마지막 남은 metadata 필드를 사용자가 + /// 비운 경우이고, 그대로 두면 지운 값이 저장소에 남아 다시 열 때 되살아난다. + func contentChange(comparedTo baseline: CreateSecretPayload) -> SecretContentChange { + let (payloadChanged, metadataChanged) = Self.diff(baseline: baseline, updated: self) + let clearsMetadata = metadataChanged && !hasMetadata + + switch (payloadChanged, metadataChanged, clearsMetadata) { + case (false, false, _): return .none + case (true, false, _): return .payload(self) + case (false, true, false): return .metadata(self) + case (false, true, true): return .metadataCleared + case (true, true, false): return .payloadAndMetadata(self) + case (true, true, true): return .payloadAndMetadataCleared(self) + } + } + + /// metadata 성분이 실제로 실려 있는지. 스키마가 없는 타입은 언제나 `false`다. + private var hasMetadata: Bool { + switch self { + case .apiKey(_, let metadata), + .accessToken(_, let metadata), + .webhookSecret(_, let metadata): + return metadata != nil + case .oauthClient(_, let metadata): return metadata != nil + case .serviceAccount(_, let metadata): return metadata != nil + case .database(_, let metadata): return metadata != nil + case .sshKey(_, let metadata): return metadata != nil + case .sslTlsCertificate(_, let metadata): return metadata != nil + case .licenseKey(_, let metadata): return metadata != nil + case .environmentVariableSet, .custom: return false + } + } +} diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift index c4ce9bce..c290ca0a 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift @@ -153,6 +153,20 @@ extension AlertState where Action == SecretDetailFeature.Action.Alert { } } + /// 수정 저장 실패. 편집 모드는 유지되므로 입력한 내용이 남아 있다는 것을 문구로 알린다. + static var updateFailed: Self { + Self { + TextState("Failed to save changes", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "An unexpected error occurred. Your changes were not saved and are still here.", + bundle: .module + ) + } + } + static var confirmDiscard: Self { Self { TextState("Discard changes?", bundle: .module) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretTypeReverseMapping.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretTypeReverseMapping.swift index c0eed37b..a93787ad 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretTypeReverseMapping.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretTypeReverseMapping.swift @@ -26,6 +26,20 @@ extension SecretType { } } +extension CreatableSecretType { + + /// 저장된 서브타입을 폼·화면이 쓰는 값으로 바꾼다. **`nil`이면 이 타입의 첫 서브타입으로 본다.** + /// + /// `Secret.subType`은 도메인에서 optional이라 예전 데이터에는 비어 있을 수 있다. live + /// `dispatchRevealPayload`가 `(.apiKeyToken, nil)`을 `.apiKey`로 처리하는 것과 같은 규칙이고, + /// 여기서 갈리면 조회는 되는데 저장만 조용히 실패하는(`invalidTypeCombination`) 상태가 된다. + /// + /// 서브탭이 없는 타입(database · envSet)은 그대로 `nil`이다. + func resolvedSubType(_ subType: SecretSubType?) -> CreatableSecretSubType? { + subType?.creatableSubType ?? availableSubTypes.first + } +} + extension SecretSubType { var creatableSubType: CreatableSecretSubType { diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index a4cdf47e..e0bcda01 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -143,6 +143,9 @@ public struct SecretDetailFeature { /// 클립보드 복사 결과. 실패도 사용자에게 알린다 — 값이 복사된 줄 알고 붙여넣으면 더 혼란스럽다. case copyResponse(Result) case likeResponse(Result) + /// 저장 응답. 방금 저장한 payload를 함께 싣는다 — 성공 시 `payloadState`를 그 값으로 바꿔야 + /// 조회로 돌아갔을 때 눈 버튼이 저장 전 값을 보여주지 않는다. + case saveResponse(Result, saved: CreateSecretPayload) /// 앱 수준 사건. 정책이 무효화 대상으로 보면 인증 창과 열린 필드를 모두 닫는다. case lifecycleEvent(AppLifecycleEvent) case deleteResponse(Result) @@ -414,8 +417,30 @@ public struct SecretDetailFeature { ) return .none - // 저장은 후속 커밋 범위다. case .didTapSave: + return handleSave(&state) + + case .saveResponse(.success(let updated), let saved): + state.isSaving = false + state.secret = updated + // 저장한 평문을 그대로 들고 있으므로 조회로 돌아가 눈 버튼을 눌러도 재복호화가 필요 없다. + state.payloadState = .loaded(saved) + // 조회 모드의 기본 상태는 전부 마스킹이다. 편집을 마치고 돌아왔는데 열려 있으면 어긋난다. + state.revealedFields.removeAll() + // 조회 화면의 Project chip은 `linkedProjects`를 읽는다. 갱신하지 않으면 저장 직후 + // 이전 프로젝트가 그대로 남는다. 방금 저장한 목록은 손에 있으므로 재조회하지 않는다. + if let fields = state.editFields, + Set(fields.projectIds) != Set(state.editFieldsBaseline?.projectIds ?? []) { + let selected = Set(fields.projectIds) + state.linkedProjects = state.availableProjects.filter { selected.contains($0.id) } + } + endEditing(&state) + return .send(.delegate(.secretUpdated(updated))) + + // 편집 모드를 유지한다 — 조회로 되돌리면 사용자가 입력한 내용이 통째로 사라진다. + case .saveResponse(.failure, _): + state.isSaving = false + state.alert = .updateFailed return .none case .binding, .alert, .delegate, .createProject: @@ -455,6 +480,95 @@ public struct SecretDetailFeature { state.validationErrors = [:] } + /// 저장. 변경 없음 판정 → 필수 필드 검증 → 다시 쓸 대상 결정 순이다. + private func handleSave(_ state: inout State) -> Effect { + guard state.mode == .editing, + let fields = state.editFields, + let baselineFields = state.editFieldsBaseline, + let baselinePayload = state.editPayloadBaseline + else { return .none } + + // 아무것도 바뀌지 않았으면 도메인을 부르지 않는다. 부르면 updatedAt만 갱신되어 + // 목록의 "최근 추가" 정렬이 이유 없이 흔들린다. 목록 재조회도 필요 없으니 delegate도 없다. + guard fields != baselineFields else { + endEditing(&state) + return .none + } + + // 서브타입은 수정 화면에서 바꿀 수 없으므로 저장된 값을 그대로 쓴다. + // `resolvedSubType`을 거치는 이유는 `Secret.subType`이 optional이라서다 — 그대로 넘기면 + // 예전 데이터에서 `invalidTypeCombination`으로 떨어져 저장이 조용히 실패한다. + let secretType = state.secret.secretType.creatableType + let subType = secretType.resolvedSubType(state.secret.subType) + + switch fields.toCreateSecretPayload( + secretType: secretType, + subType: subType, + preserving: baselinePayload + ) { + case .success(let payload): + state.validationErrors = [:] + state.isSaving = true + let change = payload.contentChange(comparedTo: baselinePayload) + let patch = Self.patch(from: fields, baseline: baselineFields, secretType: secretType, subType: subType) + let projectIds = Self.projectIDs(from: fields, baseline: baselineFields) + + return .run { [id = state.secret.id] send in + do { + let updated = try await secretClient.updateSecret(id, patch, change, projectIds) + await send(.saveResponse(.success(updated), saved: payload)) + } catch is CancellationError { + } catch { + await send(.saveResponse(.failure(SecretUseCaseError.map(error)), saved: payload)) + } + } + + case .failure(.missingRequired(let fieldIDs)): + // 이전 시도의 잔존 경고를 지우고 이번 결과만 세운다. 생성 화면과 같은 규칙이다. + state.validationErrors = [:] + for fieldID in fieldIDs { + state.validationErrors[fieldID] = .module("Required") + } + return .none + + // (secretType, subType)이 저장된 시크릿에서 오고 수정 화면이 바꾸지 않으므로 도달할 수 없다. + case .failure(.invalidTypeCombination): + return .none + } + } + + /// 바뀐 공통 필드만 `.set`으로 싣는다. 안 바뀐 것은 `.unchanged`로 두어 불필요한 write를 만들지 않는다. + /// + /// 비교를 폼 값이 아니라 `toSecretDraft` 결과로 하는 이유는 `""` → `nil` 접힘 같은 매핑 규칙을 + /// 여기서 한 벌 더 알지 않기 위해서다. 이름 trim과 만료일 23:59:59 고정은 도메인이 하므로 + /// 화면에서 맞추지 않는다. + private static func patch( + from fields: SecretMetaFields, + baseline: SecretMetaFields, + secretType: CreatableSecretType, + subType: CreatableSecretSubType? + ) -> SecretPatch { + let draft = fields.toSecretDraft(secretType: secretType, subType: subType) + let old = baseline.toSecretDraft(secretType: secretType, subType: subType) + + return SecretPatch( + name: draft.name == old.name ? .unchanged : .set(draft.name), + service: draft.service == old.service ? .unchanged : .set(draft.service), + environment: draft.environment == old.environment ? .unchanged : .set(draft.environment), + expiresAt: draft.expiresAt == old.expiresAt ? .unchanged : .set(draft.expiresAt), + memo: draft.memo == old.memo ? .unchanged : .set(draft.memo) + ) + } + + /// 연결이 바뀐 경우에만 `.set`으로 최종 목록을 싣는다. `.set`은 목록이 같아도 연결을 다시 조정한다. + /// 순서만 다른 것은 변경이 아니다 — 드롭다운에서 고른 순서가 저장 여부를 바꾸면 안 된다. + private static func projectIDs( + from fields: SecretMetaFields, + baseline: SecretMetaFields + ) -> PatchField<[Project.ID]> { + Set(fields.projectIds) == Set(baseline.projectIds) ? .unchanged : .set(fields.projectIds) + } + // MARK: - Effects /// 편집 폼의 프로젝트 선택 옵션을 읽어 온다. diff --git a/Projects/DVPresentation/Tests/SecretDetail/CreateSecretPayloadDiffTests.swift b/Projects/DVPresentation/Tests/SecretDetail/CreateSecretPayloadDiffTests.swift new file mode 100644 index 00000000..bbcfaa6e --- /dev/null +++ b/Projects/DVPresentation/Tests/SecretDetail/CreateSecretPayloadDiffTests.swift @@ -0,0 +1,178 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation +import Testing + +import DVDomain +@testable import DVPresentation + +/// 저장 시 **무엇을 다시 쓸지** 정하는 판정을 고정한다. +/// +/// 잘못 판정하면 두 방향으로 틀린다 — 과소 판정이면 사용자가 고친 값이 저장되지 않고, +/// 과대 판정이면 바뀌지도 않은 payload가 재암호화되어 `keyTag`·`schemaVersion`이 갱신된다. +@Suite("CreateSecretPayload 변경 판정") +struct CreateSecretPayloadDiffTests { + + private static let baseline = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_old"), + APIKeyMetadata(scope: "repo:read") + ) + + // MARK: - 4분기 + + @Test("변경 없음 → none") + func noChange() { + let change = Self.baseline.contentChange(comparedTo: Self.baseline) + #expect(change == .none) + #expect(change.content == nil, "쓸 것이 없으면 평문이 Client 경계를 넘지 않는다") + #expect(change.writesPayload == false) + #expect(change.writesMetadata == false) + #expect(change.clearsMetadata == false) + } + + @Test("payload만 변경 → payload") + func payloadOnly() { + let updated = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_new"), + APIKeyMetadata(scope: "repo:read") + ) + + let change = updated.contentChange(comparedTo: Self.baseline) + + #expect(change == .payload(updated)) + #expect(change.writesPayload) + #expect(change.writesMetadata == false) + } + + @Test("metadata만 변경 → metadata") + func metadataOnly() { + let updated = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_old"), + APIKeyMetadata(scope: "repo:write") + ) + + let change = updated.contentChange(comparedTo: Self.baseline) + + #expect(change == .metadata(updated)) + #expect(change.writesPayload == false) + #expect(change.writesMetadata) + } + + @Test("둘 다 변경 → payloadAndMetadata") + func bothChanged() { + let updated = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_new"), + APIKeyMetadata(scope: "repo:write") + ) + + let change = updated.contentChange(comparedTo: Self.baseline) + + #expect(change == .payloadAndMetadata(updated)) + #expect(change.writesPayload) + #expect(change.writesMetadata) + } + + // MARK: - metadata 삭제 + + /// 마지막 남은 metadata 필드를 비우면 조립 결과가 nil이 된다. `none`으로 처리하면 + /// 지운 값이 저장소에 남아 다시 열 때 되살아난다. + @Test("metadata가 비워짐 → metadataCleared") + func metadataCleared() { + let updated = CreateSecretPayload.apiKey(APIKeyPayload(value: "ghp_old"), nil) + + let change = updated.contentChange(comparedTo: Self.baseline) + + #expect(change == .metadataCleared) + #expect(change.clearsMetadata) + #expect(change.writesMetadata == false, "덮어쓰기와 삭제가 동시에 참이면 안 된다") + #expect(change.content == nil) + } + + @Test("payload 변경 + metadata 삭제 → payloadAndMetadataCleared") + func payloadChangedAndMetadataCleared() { + let updated = CreateSecretPayload.apiKey(APIKeyPayload(value: "ghp_new"), nil) + + let change = updated.contentChange(comparedTo: Self.baseline) + + #expect(change == .payloadAndMetadataCleared(updated)) + #expect(change.writesPayload) + #expect(change.clearsMetadata) + #expect(change.writesMetadata == false) + } + + /// baseline에도 metadata가 없었으면 지울 것이 없다. + @Test("원래 metadata가 없었으면 삭제로 보지 않는다") + func absentMetadataIsNotAClear() { + let baseline = CreateSecretPayload.apiKey(APIKeyPayload(value: "ghp_old"), nil) + let updated = CreateSecretPayload.apiKey(APIKeyPayload(value: "ghp_new"), nil) + + let change = updated.contentChange(comparedTo: baseline) + + #expect(change == .payload(updated)) + #expect(change.clearsMetadata == false) + } + + // MARK: - metadata 스키마가 없는 타입 + + @Test("envSet은 metadata 스키마가 없어 payload 변경만 잡힌다") + func envSetHasNoMetadata() { + let baseline = CreateSecretPayload.environmentVariableSet(EnvSetPayload(content: "A=1")) + let updated = CreateSecretPayload.environmentVariableSet(EnvSetPayload(content: "A=2")) + + let change = updated.contentChange(comparedTo: baseline) + + #expect(change == .payload(updated)) + #expect(change.writesMetadata == false) + #expect(change.clearsMetadata == false) + } + + @Test("custom도 값이 같으면 변경 없음이다") + func customUnchanged() { + let payload = CreateSecretPayload.custom(CustomPayload(value: "v")) + + #expect(payload.contentChange(comparedTo: payload) == .none) + } + + // MARK: - 타입별 diff + + /// case마다 비교 대상을 손으로 적어야 해서, 한 곳만 빠뜨려도 그 타입의 변경이 조용히 무시된다. + @Test("아홉 가지 content 조합 모두 payload 변경을 잡아낸다") + func diffCatchesPayloadChangeInEveryCase() { + let pairs: [(label: String, old: CreateSecretPayload, new: CreateSecretPayload)] = [ + ("apiKey", + .apiKey(APIKeyPayload(value: "a"), nil), .apiKey(APIKeyPayload(value: "b"), nil)), + ("accessToken", + .accessToken(APIKeyPayload(value: "a"), nil), .accessToken(APIKeyPayload(value: "b"), nil)), + ("webhookSecret", + .webhookSecret(APIKeyPayload(value: "a"), nil), .webhookSecret(APIKeyPayload(value: "b"), nil)), + ("oauthClient", + .oauthClient(OAuthClientPayload(clientId: "i", clientSecret: "a"), nil), + .oauthClient(OAuthClientPayload(clientId: "i", clientSecret: "b"), nil)), + ("serviceAccount", + .serviceAccount(ServiceAccountPayload(credentialJSON: "a"), nil), + .serviceAccount(ServiceAccountPayload(credentialJSON: "b"), nil)), + ("database", + .database(DatabasePayload(linkString: "a"), nil), .database(DatabasePayload(linkString: "b"), nil)), + ("sshKey", + .sshKey(SSHKeyPayload(privateKey: "a", passphrase: nil), nil), + .sshKey(SSHKeyPayload(privateKey: "b", passphrase: nil), nil)), + ("sslTlsCertificate", + .sslTlsCertificate(SSLCertPayload(certificate: "a", privateKey: "k", certificateChain: nil), nil), + .sslTlsCertificate(SSLCertPayload(certificate: "b", privateKey: "k", certificateChain: nil), nil)), + ("licenseKey", + .licenseKey(LicenseKeyPayload(licenseKey: "a"), nil), + .licenseKey(LicenseKeyPayload(licenseKey: "b"), nil)), + ("envSet", + .environmentVariableSet(EnvSetPayload(content: "a")), + .environmentVariableSet(EnvSetPayload(content: "b"))), + ("custom", + .custom(CustomPayload(value: "a")), .custom(CustomPayload(value: "b"))), + ] + + for pair in pairs { + let result = CreateSecretPayload.diff(baseline: pair.old, updated: pair.new) + #expect(result.payload, "\(pair.label): payload 변경을 잡아야 한다") + #expect(result.metadata == false, "\(pair.label): metadata는 그대로다") + } + } +} diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index 051ea8f6..3deb2fd9 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -905,8 +905,177 @@ struct SecretDetailFeatureTests { ] } } + + // MARK: - 저장 + + /// 아무것도 안 바꾸고 Save를 누르면 도메인을 부르지 않는다. 부르면 updatedAt만 갱신되어 + /// 목록의 "최근 추가" 정렬이 이유 없이 흔들린다. `updateSecret`을 스텁하지 않아, 불리면 실패한다. + @Test("저장: 변경이 없으면 write 없이 조회로 돌아간다") + func didTapSave_withoutChanges_skipsWrite() async { + let store = TestStore(initialState: Self.editingState()) { SecretDetailFeature() } + + await store.send(.didTapSave) { + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + } + + @Test("저장: 필수 필드가 비면 인라인 경고만 세우고 저장하지 않는다") + func didTapSave_missingRequired_setsValidationErrors() async { + var initial = Self.editingState() + initial.editFields?.content = .apiKeyToken(APIKeyTokenFields(value: "", authorityScope: "repo:read")) + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapSave) { + $0.validationErrors = [.value: .module("Required")] + } + } + + @Test("저장 성공: secret·payloadState 교체 + 열린 필드 정리 + delegate") + func didTapSave_success() async { + let secret = Self.makeSecret(name: "GitHub Token") + var initial = Self.editingState(secret: secret) + // payload 자체를 고쳐야 payloadState 교체 단언에 의미가 생긴다 — + // 공통 필드만 바꾸면 저장된 payload가 baseline과 같아 갱신 여부를 구분할 수 없다. + initial.editFields?.content = .apiKeyToken( + APIKeyTokenFields(value: "ghp_new", authorityScope: "repo:read") + ) + initial.revealedFields = [.value] + let saved = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_new"), + APIKeyMetadata(scope: "repo:read") + ) + let updated = Self.makeSecret(name: "GitHub Token") + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, _, _, _ in updated } + } + + await store.send(.didTapSave) { + $0.isSaving = true + } + await store.receive(.saveResponse(.success(updated), saved: saved)) { + $0.isSaving = false + $0.secret = updated + $0.payloadState = .loaded(saved) + $0.revealedFields = [] + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + await store.receive(.delegate(.secretUpdated(updated))) + } + + /// 조회로 되돌리면 사용자가 입력한 내용이 통째로 사라진다. + @Test("저장 실패: alert를 띄우되 편집 모드와 입력을 유지한다") + func didTapSave_failure_keepsEditing() async { + var initial = Self.editingState() + initial.editFields?.memo = "고친 메모" + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, _, _, _ in throw SecretUseCaseError.unexpected } + } + + await store.send(.didTapSave) { + $0.isSaving = true + } + await store.receive(.saveResponse(.failure(.unexpected), saved: Self.editablePayload)) { + $0.isSaving = false + $0.alert = .updateFailed + } + } + + /// 바뀐 필드만 `.set`으로 실어야 불필요한 write가 생기지 않는다. + @Test("저장: 바뀐 공통 필드만 patch에 실린다") + func didTapSave_patchesOnlyChangedFields() async { + var initial = Self.editingState() + initial.editFields?.memo = "고친 메모" + + let recorded = LockIsolated(nil) + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, patch, _, _ in + recorded.setValue(patch) + return Self.makeSecret() + } + } + + // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. + store.exhaustivity = .off + await store.send(.didTapSave) + await store.finish() + + let patch = try? #require(recorded.value) + #expect(patch?.memo == .set("고친 메모")) + #expect(patch?.name == .unchanged) + #expect(patch?.service == .unchanged) + #expect(patch?.expiresAt == .unchanged) + // 서브타입과 즐겨찾기는 수정 화면이 건드리지 않는다. + #expect(patch?.subType == .unchanged) + #expect(patch?.liked == .unchanged) + } + + /// 목록이 같으면 `.set`으로 보내지 않는다 — `.set`은 같은 목록이어도 연결을 다시 조정한다. + @Test("저장: 프로젝트 연결이 그대로면 unchanged로 보낸다") + func didTapSave_unchangedProjects_sendsUnchanged() async { + let projectID = UUID() + var initial = Self.editingState(projectIds: [projectID]) + initial.editFields?.memo = "고친 메모" + + let recorded = LockIsolated?>(nil) + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, _, _, projectIds in + recorded.setValue(projectIds) + return Self.makeSecret() + } + } + + // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. + store.exhaustivity = .off + await store.send(.didTapSave) + await store.finish() + + #expect(recorded.value == .unchanged) + } + + @Test("저장: 프로젝트 연결이 바뀌면 최종 목록을 set으로 보낸다") + func didTapSave_changedProjects_sendsSet() async { + let kept = UUID() + let added = UUID() + var initial = Self.editingState(projectIds: [kept]) + initial.editFields?.projectIds = [kept, added] + + let recorded = LockIsolated?>(nil) + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, _, _, projectIds in + recorded.setValue(projectIds) + return Self.makeSecret() + } + } + + // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. + store.exhaustivity = .off + await store.send(.didTapSave) + await store.finish() + + #expect(recorded.value == .set([kept, added])) + } } + // MARK: - 편집 Fixtures @MainActor From fe79abe3c55b1ebf627a401ec918459533c9ef6b Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:26:08 +0900 Subject: [PATCH 11/61] =?UTF-8?q?[#76]=20feat:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editingBody를 SecretFormSectionsView + $store.editFields 바인딩으로 채운다. 생성 화면과 같은 SectionView를 쓰므로 마스킹·여러 줄 입력 배선이 그대로 따라온다 - editFields가 nil인 채로 mode가 .editing인 조합은 reducer가 만들지 않지만, if let으로 렌더 자체를 불가능하게 한다 - 감지 힌트(serviceCandidates·detectedServices)는 넘기지 않는다. - 하드코딩 영문 인라인 footer(Button("Cancel") / Button("Save"))를 제거하고 FooterActionsView로 교체한다. 저장 라벨만 다르므로 saveTitle 파라미터를 추가했다. 기본값이 Create라 생성 화면 호출부는 그대로다 - Save 활성 규칙은 !isSaving 하나다. 필수 필드 검증은 didTapSave가 수행해 인라인 경고를 세우므로 여기서 미리 막으면 경고가 영영 뜨지 않는다 — 생성 화면과 같은 규칙 - 헤더에 isEditing을 연결하고 isEditEnabled를 활성화한다. 복호화가 진행 중일 때만 막는다 — .idle에서 누르면 그때 복호화가 시작되고 .failed에서는 재시도가 된다. 실패했다고 수정을 영영 막을 이유가 없다 - SectionView가 요구하는 프로젝트 생성 시트를 붙인다 --- .../Components/FooterActionsView.swift | 14 +- .../SecretDetail/SecretDetailView.swift | 142 +++++++++++++++--- 2 files changed, 132 insertions(+), 24 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/FooterActionsView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/FooterActionsView.swift index 2f28388f..4ca4525f 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/FooterActionsView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/FooterActionsView.swift @@ -3,10 +3,14 @@ import DVDesign import SwiftUI -/// CreateSecret 화면 하단 고정 액션 바. Cancel + Create, 10pt 간격 고정 + 우측 정렬. +/// 폼 화면 하단 고정 액션 바. Cancel + 저장, 10pt 간격 고정 + 우측 정렬. +/// 생성 화면과 수정 화면이 공유한다 — 배치·간격이 같고 다른 것은 저장 버튼 라벨뿐이다. /// TCA store에 결합하지 않고 콜백/불리언만 받아 독립 Preview 가능. struct FooterActionsView: View { + /// 저장 버튼 라벨. 생성은 기본값(`Create`), 수정은 `Save`를 넘긴다. + /// `DVButton`이 `String`을 받으므로 `String.module(_:)`로 룩업한 결과를 담는다. + var saveTitle: String = .module("Create") let isSaveEnabled: Bool let onCancel: () -> Void let onSave: () -> Void @@ -26,7 +30,7 @@ extension FooterActionsView { Spacer() HStack(spacing: 10) { DVButton(titleText: .module("Cancel"), style: .secondary, action: onCancel) - DVButton(titleText: .module("Create"), style: .secondaryProminent, action: onSave) + DVButton(titleText: saveTitle, style: .secondaryProminent, action: onSave) .disabled(!isSaveEnabled) } } @@ -57,4 +61,10 @@ extension FooterActionsView { .previewWidth(.wide) } +/// 수정 화면이 쓰는 라벨. 버튼 폭이 달라지므로 Cancel과의 간격이 그대로인지 확인한다. +#Preview("Save 라벨 · Medium") { + FooterActionsView(saveTitle: .module("Save"), isSaveEnabled: true, onCancel: {}, onSave: {}) + .previewWidth(.medium) +} + #endif diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index fa333394..146e951c 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -37,14 +37,7 @@ public struct SecretDetailView: View { } if store.mode == .editing { Divider() - HStack { - Spacer() - Button("Cancel") { store.send(.didTapCancelEdit) } - .buttonStyle(.plain) - Button("Save") { store.send(.didTapSave) } - .buttonStyle(.borderedProminent) - } - .padding() + footer } } // 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. @@ -63,6 +56,9 @@ public struct SecretDetailView: View { await store.send(.task).finish() } .alert($store.scope(state: \.alert, action: \.alert)) + .sheet(item: $store.scope(state: \.createProject, action: \.createProject)) { store in + CreateProjectView(store: store) + } } } @@ -125,8 +121,8 @@ extension SecretDetailView { secretType: store.secret.secretType, subType: store.secret.subType, isLiked: store.secret.liked, - // 편집 모드는 후속 이슈 범위 — 상태 전이가 없어 비활성으로 렌더한다. - isEditEnabled: false, + isEditEnabled: isEditEnabled, + isEditing: store.mode == .editing, onToggleLike: { store.send(.didTapToggleLike) }, onEdit: { store.send(.didTapEdit) }, onDelete: { store.send(.didTapDelete) } @@ -134,22 +130,68 @@ extension SecretDetailView { .disabled(store.isDeleting) } + /// 복호화가 진행 중일 때만 수정 진입을 막는다. `.idle`에서 누르면 그때 복호화가 시작되고, + /// `.failed`에서 누르면 재시도가 된다 — 실패했다고 수정을 영영 막을 이유가 없다. + private var isEditEnabled: Bool { + if case .loading = store.payloadState { return false } + return true + } + // MARK: Editing - /// 편집 모드 진입 경로가 아직 없어 도달하지 않는다. 후속 이슈에서 - /// `editFields` 바인딩과 SectionView 재사용으로 채운다. + /// 생성 화면과 **같은 SectionView**를 쓴다(`SecretFormSectionsView`). 마스킹·여러 줄 입력 같은 + /// 배선이 그쪽에 이미 있으므로 편집 모드용으로 다시 만들 것이 없다. + /// + /// `editFields`가 `nil`인 채로 `mode == .editing`인 조합은 reducer가 만들지 않지만, + /// `if let`이 그 조합을 아예 렌더 불가능하게 만든다. @ViewBuilder private var editingBody: some View { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text(store.secret.name) - .dvFont(.headingLG) - .foregroundStyle(Color.dv(.gray900)) - .padding(.horizontal, 20) - .padding(.top, 20) + if let fields = Binding($store.editFields) { + ScrollView { + bodyStack { + SecretFormSectionsView( + secretType: store.secret.secretType.creatableType, + subType: store.secret.secretType.creatableType + .resolvedSubType(store.secret.subType), + meta: fields, + availableProjects: store.availableProjects, + // 감지 힌트는 생성 화면 전용이다. 이미 저장된 값을 고치는 중에 + // "이 서비스로 보입니다"를 띄우면 사용자가 고른 service를 되묻는 꼴이 된다. + serviceCandidates: [], + validationErrors: store.validationErrors, + detectedServices: [:], + onCreateProject: { store.send(.didTapCreateProject) } + ) + } } + .scrollIndicators(.hidden) + .disabled(store.isSaving) + .overlay { + if store.isSaving { + ZStack { + Color.black.opacity(0.08) + ProgressView().controlSize(.regular) + } + .allowsHitTesting(true) + } + } + .environment(\.isProjectLoading, store.isLoadingProjects) } } + + /// 편집 모드에서만 보이는 하단 액션 바. 생성 화면과 같은 컴포넌트를 쓰고 라벨만 다르다. + private var footer: some View { + FooterActionsView( + saveTitle: .module("Save"), + // 필수 필드 검증은 `didTapSave`가 수행해 인라인 경고를 세운다 — 여기서 미리 막으면 + // 경고가 영영 뜨지 않는다. 생성 화면과 같은 규칙이다. + isSaveEnabled: !store.isSaving, + onCancel: { store.send(.didTapCancelEdit) }, + onSave: { store.send(.didTapSave) } + ) + .padding(.horizontal, FormLayoutMetrics.horizontalPadding) + .padding(.vertical, 16) + } } // MARK: - Preview @@ -214,12 +256,68 @@ private let _previewSecret = [Secret].previewSubTypeMatrix[0] .frame(width: 420, height: 700) } -#Preview("SecretDetail · Editing") { +// MARK: 편집 모드 + +/// `mode`만 바꾸면 `editFields`가 비어 폼이 그려지지 않는다. reducer의 `beginEditing`이 세우는 +/// 조합을 그대로 만들어 준다 — 프리뷰가 실제 진입과 다른 상태를 보여주면 확인의 의미가 없다. +private func _editingPreviewState( + isSaving: Bool = false, + validationErrors: [SecretFieldID: String] = [:] +) -> SecretDetailFeature.State { + let payload = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_preview_token"), + APIKeyMetadata(scope: "repo:read") + ) + let projects = [Project].preview + var state = SecretDetailFeature.State(secret: _previewSecret) + state.payloadState = .loaded(payload) + state.linkedProjects = Array(projects.prefix(1)) + state.availableProjects = projects + let fields = SecretMetaFields( + secret: _previewSecret, + payload: payload, + projectIds: state.linkedProjects.map(\.id) + ) + state.editFields = fields + state.editFieldsBaseline = fields + state.editPayloadBaseline = payload + state.isSaving = isSaving + state.validationErrors = validationErrors + state.mode = .editing + return state +} + +/// 확인 대상은 세 가지다 — 헤더에서 공유·수정·삭제가 사라지고 즐겨찾기만 회색으로 남는지, +/// 폼이 생성 화면과 같은 SectionView로 그려지는지, footer 라벨이 Save인지. +#Preview("SecretDetail · 편집") { + SecretDetailView( + store: Store(initialState: _editingPreviewState()) { SecretDetailFeature() } + ) + .frame(width: 420, height: 700) +} + +/// 컬럼이 2열 임계값을 넘으면 생성 화면과 같은 dual 배열이 되어야 한다. +#Preview("SecretDetail · 편집 (dual)") { + SecretDetailView( + store: Store(initialState: _editingPreviewState()) { SecretDetailFeature() } + ) + .frame(width: 900, height: 700) +} + +#Preview("SecretDetail · 편집 저장 중") { + SecretDetailView( + store: Store(initialState: _editingPreviewState(isSaving: true)) { SecretDetailFeature() } + ) + .frame(width: 420, height: 700) +} + +/// 필수 필드를 비우고 Save를 누른 뒤의 모습. +#Preview("SecretDetail · 편집 검증 실패") { SecretDetailView( store: Store( initialState: { - var state = SecretDetailFeature.State(secret: _previewSecret) - state.mode = .editing + var state = _editingPreviewState(validationErrors: [.value: .module("Required")]) + state.editFields?.content = .apiKeyToken(APIKeyTokenFields(value: "")) return state }() ) { From 7d6158363a905dd9fbcaa65e4aaf0801359c9697 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:20:32 +0900 Subject: [PATCH 12/61] =?UTF-8?q?[#76]=20chore:=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=B0=A8=EB=8B=A8=EC=9D=84=20Debug=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=EC=97=90=EC=84=9C=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개발 중에는 화면을 찍어 PR·디자인 리뷰에 붙이고 버그를 녹화해 공유해야 하는데, 캡처가 막혀 있으면 그 자리에서 검은 화면만 남았다 - #if DEBUG 컴파일 타임 분기라 Release 바이너리에는 해제 경로 자체가 들어가지 않는다. 런타임 플래그로 두면 배포본에서 뒤집힐 여지가 생기므로 그렇게 하지 않았다 - sharingType을 세팅하는 두 지점(window에 붙는 순간·갱신 시점)을 NSWindow.applyCapturePolicy()로 모은다. --- .../Dependencies/SecretClient+Live.swift | 2 +- .../Composition/WindowCaptureBlocker.swift | 29 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index 71acca43..4d72cf02 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -86,7 +86,7 @@ extension SecretClient: @retroactive DependencyKey { ) // 만료일이 바뀌었을 수 있다. `schedule`은 예약 전에 이전 마크를 먼저 취소하므로 // 만료일을 지운 경우의 정리까지 같은 호출이 처리한다. - await expiryUseCase.schedule(secret: updated) + await LiveUseCases.expirySchedule.schedule(secret: updated) return updated }, fetchLinkedProjects: { secretID in diff --git a/Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift b/Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift index 40a39fd7..55c130c5 100644 --- a/Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift +++ b/Projects/Devault/Sources/Composition/WindowCaptureBlocker.swift @@ -3,7 +3,12 @@ import SwiftUI /// 설정(화면 녹화 중 값 숨김)에 따라 앱 창을 스크린샷·화면 녹화·화면 공유 대상에서 제외한다. -/// `NSWindow.sharingType = .none`은 창 전체가 캡처 대상에서 빠짐 +/// `NSWindow.sharingType = .none`이면 창 전체가 캡처 대상에서 빠진다. +/// +/// **Debug 빌드에서는 설정과 무관하게 막지 않는다.** 개발 중에는 화면을 찍어 PR·디자인 리뷰에 +/// 붙이고 버그를 녹화해 공유해야 하는데, 캡처가 막혀 있으면 그 자리에서 검은 화면만 남는다. +/// 분기가 `#if DEBUG` 컴파일 타임이라 **Release 바이너리에는 해제 경로 자체가 들어가지 않는다** — +/// 런타임 플래그로 두면 배포본에서 뒤집힐 여지가 생기므로 그렇게 하지 않는다. struct WindowCaptureBlocker: NSViewRepresentable { private let isEnabled: Bool @@ -37,15 +42,29 @@ private final class CaptureBlockingView: NSView { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - applySharingType() + applyCapturePolicy() } func update(isEnabled: Bool) { self.isEnabled = isEnabled - applySharingType() + applyCapturePolicy() } - func applySharingType() { - window?.sharingType = isEnabled ? .none : .readOnly + private func applyCapturePolicy() { + window?.applyCapturePolicy(isEnabled: isEnabled) + } +} + +private extension NSWindow { + + /// 캡처 차단 여부를 한곳에서 정한다. 적용 지점이 둘(붙는 순간 · 설정 변경 시점)이라 + /// 각자 분기하면 한쪽만 고쳐져 설정과 화면이 어긋나는 상태가 생긴다. + func applyCapturePolicy(isEnabled: Bool) { + #if DEBUG + // 개발 빌드는 설정을 보지 않는다. 스크린샷·녹화·화면 공유에 정상적으로 잡힌다. + sharingType = .readOnly + #else + sharingType = isEnabled ? .none : .readOnly + #endif } } From 1e5fb5cef04b0255bd827fd7d2a3ee5a999a02cd Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:21:16 +0900 Subject: [PATCH 13/61] =?UTF-8?q?[#76]=20fix:=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=A4=84=20=EB=B3=B4=EC=95=88=20=ED=95=84=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=83=9D=EB=9E=B5=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EA=B0=80=20=EB=B6=99=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/DVMultilineTextField.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift index adb9c86a..6466c4f4 100644 --- a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -93,6 +93,9 @@ public struct DVMultilineTextField: View { } .padding(Metrics.contentPadding) .dvComponentWidth(size, height: height, alignment: .topLeading) + // 마스킹 표시가 높이를 넘겨도 잘라내기만 한다. 자르지 않으면 컨테이너 밖으로 흘러 + // 아래 필드를 덮는다 (`maskedText`가 높이 제약을 일부러 받지 않는다). + .clipped() .background { RoundedRectangle(cornerRadius: Metrics.cornerRadius) .stroke(Color.dv(.gray300), lineWidth: Metrics.borderWidth) @@ -148,7 +151,11 @@ extension DVMultilineTextField { .foregroundStyle(Color.dv(.gray900)) .lineSpacing(DVFont.bodyLG.lineSpacing) .padding(.leading, Metrics.textLeadingInset) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + // 높이 제약을 받으면 마지막 줄이 "…"로 잘린다. 아래 에디터는 스크롤로 넘치는데 + // 겹쳐 그리는 마스킹만 말줄임표가 붙어 실제로 없는 생략 표시가 생긴다. + // 전체를 그리게 두고 컨테이너(`body`의 `clipped()`)가 잘라낸다. + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .topLeading) .allowsHitTesting(false) } From b547cd1d9cff45e5bf2d3d0d8ead66395c1a5438 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:27:48 +0900 Subject: [PATCH 14/61] =?UTF-8?q?[#76]=20feat:=20=ED=8F=BC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EA=B0=80=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=8F=AD=EC=9D=84=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=96=B4?= =?UTF-8?q?=EB=93=A4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DVComponentWidthPolicy.fill이 토큰을 하한으로 쓰고 있었다. 그래서 컨테이너가 토큰보다 좁아지면 줄어드는 대신 넘쳤다 — 가변 폭 맥락에서 정확히 피하려던 상황이다. fill(minimum:)으로 하한을 분리하고 폭은 컨테이너를 따르게 한다. 토큰 사이를 뛰어넘지 않고 1pt 단위로 붙어서 줄어든다 - 조회·수정은 3컬럼의 한 칸이라 .xs, 생성은 단독 창이고 minWidth 520 아래로 안 내려가므로 .md로 하한 설정 --- .../RadioButton/DVRadioButtonGroup.swift | 42 ++++++++---------- .../Sizing/DVComponentWidthPolicy.swift | 21 +++++---- .../Fields/EnvironmentFieldView.swift | 3 +- .../Fields/LicenseTierFieldView.swift | 3 +- .../Sources/Form/FormLayout.swift | 43 +++++++++++++++++-- .../Sources/Form/FormLayoutContext.swift | 11 ++++- 6 files changed, 78 insertions(+), 45 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift index 0f1a22d7..4b79bccb 100644 --- a/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift +++ b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift @@ -6,7 +6,7 @@ import SwiftUI /// /// `DVRadioButtonGroup`은 하나의 선택 값을 `Binding`으로 받아 ``Item``마다 /// 한 개의 ``DVRadioButton``을 렌더링하고, 선택된 ``Size`` 변형에 따라 -/// 간격과 최소 너비를 적용합니다. 사용자가 클릭, 탭, 또는 키보드로 어떤 +/// 간격을 적용합니다. 사용자가 클릭, 탭, 또는 키보드로 어떤 /// 라디오를 활성화하면 그 값이 바인딩에 기록됩니다. /// /// ## 선택 바인딩 @@ -44,12 +44,13 @@ import SwiftUI /// /// 주변 레이아웃의 시각적 리듬에 맞춰 변형을 선택하세요. /// -/// - ``Size/xs`` — 좁은 밀도 (간격 8pt, 최소 너비 180pt) -/// - ``Size/sm`` — 기본 (간격 20pt, 최소 너비 330pt) -/// - ``Size/md`` — 넓은 밀도 (간격 56pt, 최소 너비 380pt) +/// - ``Size/xs`` — 좁은 밀도 (간격 8pt) +/// - ``Size/sm`` — 기본 (간격 20pt) +/// - ``Size/md`` — 넓은 밀도 (간격 56pt) /// -/// `minWidth`는 윈도우 크기가 변할 때 그룹이 디자인 footprint 아래로 -/// 줄어들지 않도록 보장하는 하한선입니다. +/// 그룹 폭은 고정하지 않습니다. 컨테이너가 좁아 한 줄에 다 들어가지 않으면 +/// 압축하는 대신 **줄을 바꿉니다** — 라벨은 고유 폭 아래로 줄어들 수 없어 +/// 폭을 고정하면 잘리기 때문입니다. /// /// ## 키보드 네비게이션 (macOS) /// @@ -77,7 +78,7 @@ public struct DVRadioButtonGroup: View { /// `Value`는 중복되어선 안 됩니다. /// - selection: 현재 선택된 값에 대한 양방향 바인딩. 사용자가 어떤 /// 라디오를 활성화하면 그룹이 이 바인딩에 쓰기를 수행합니다. - /// - size: 간격과 최소 너비 변형. 기본값은 ``Size/sm``. + /// - size: 라디오 사이 간격 변형. 기본값은 ``Size/sm``. public init( items: [Item], selection: Binding, @@ -89,7 +90,10 @@ public struct DVRadioButtonGroup: View { } public var body: some View { - HStack(spacing: size.spacing) { + // 한 줄에 다 들어가지 않으면 **줄을 바꾼다.** `HStack`은 라벨의 고유 폭 때문에 그 아래로 + // 압축되지 않아, 좁은 컨테이너에서는 줄어드는 대신 잘렸다 — 선택지가 몇 개인지조차 + // 보이지 않게 된다. 넓을 때의 모습은 한 줄 그대로다. + DVFlowLayout(hSpacing: size.spacing, vSpacing: size.spacing) { ForEach(items) { item in DVRadioButton(item.title, isSelected: selection == item.id) { selection = item.id @@ -98,7 +102,7 @@ public struct DVRadioButtonGroup: View { .focused($focusedValue, equals: item.id) } } - .frame(minWidth: size.minWidth, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .onKeyPress(.leftArrow) { moveSelection(by: -1) } .onKeyPress(.rightArrow) { moveSelection(by: +1) } } @@ -151,17 +155,15 @@ extension DVRadioButtonGroup { /// ``DVRadioButtonGroup``의 레이아웃 사이즈 변형. /// - /// 각 케이스는 라디오 사이의 가로 간격(``spacing``)과 그룹 HStack의 - /// 최소 너비 하한선(``minWidth``)을 Devault Figma 스펙에 맞춰 함께 - /// 고정합니다. + /// 각 케이스는 라디오 사이의 가로 간격(``spacing``)을 Devault Figma 스펙에 맞춰 고정합니다. public enum Size { - /// 좁은 밀도: 간격 8pt, 최소 너비 180pt. + /// 좁은 밀도: 간격 8pt. case xs - /// 기본 밀도: 간격 20pt, 최소 너비 330pt. + /// 기본 밀도: 간격 20pt. case sm - /// 넓은 밀도: 간격 56pt, 최소 너비 380pt. + /// 넓은 밀도: 간격 56pt. case md /// 인접한 라디오 버튼 사이에 삽입되는 가로 간격 (포인트 단위). @@ -172,16 +174,6 @@ extension DVRadioButtonGroup { case .md: return 56 } } - - /// 그룹 HStack에 적용되는 최소 너비 (포인트 단위). 윈도우 크기가 - /// 줄어들 때 그룹이 디자인 footprint 아래로 압축되지 않도록 합니다. - public var minWidth: CGFloat { - switch self { - case .xs: return 180 - case .sm: return 330 - case .md: return 380 - } - } } } diff --git a/Projects/DVDesign/Sources/Foundations/Sizing/DVComponentWidthPolicy.swift b/Projects/DVDesign/Sources/Foundations/Sizing/DVComponentWidthPolicy.swift index 0cb3bd39..dc5d5b74 100644 --- a/Projects/DVDesign/Sources/Foundations/Sizing/DVComponentWidthPolicy.swift +++ b/Projects/DVDesign/Sources/Foundations/Sizing/DVComponentWidthPolicy.swift @@ -14,19 +14,18 @@ import SwiftUI /// ```swift /// // 디테일 컬럼처럼 폭이 가변인 화면에서 /// formBody -/// .environment(\.dvComponentWidthPolicy, .fill) +/// .environment(\.dvComponentWidthPolicy, .fill(minimum: .xs)) /// ``` -/// -/// > Note: ``fill``에서 ``DVComponentSize``는 **최소 폭**으로 동작합니다. -/// > 따라서 컨테이너가 토큰 폭보다 좁아지면 컴포넌트가 컨테이너를 넘칩니다 — -/// > 호출부가 최소 폭을 보장해야 합니다. public enum DVComponentWidthPolicy: Equatable, Sendable { /// ``DVComponentSize/width`` 고정. 기본값. case fixed - /// 컨테이너를 채우고 ``DVComponentSize/width``를 최소 폭으로 보장. - case fill + /// 컨테이너를 채우되 `minimum`보다 좁아지지 않습니다. + /// + /// 컨테이너가 좁아지면 컴포넌트도 **1pt 단위로 따라 줄어듭니다** — 토큰 사이를 뛰어넘지 않습니다. + /// 하한에 닿은 뒤에야 넘치기 시작하므로, 화면은 그 하한만 보장하면 됩니다. + case fill(minimum: DVComponentSize) } extension EnvironmentValues { @@ -43,7 +42,8 @@ extension View { /// 사용하면, 호출부가 정책만 주입해 고정 폭 / 가변 폭을 전환할 수 있습니다. /// /// - Parameters: - /// - size: 폭 토큰. `.fixed`에서는 고정 폭, `.fill`에서는 최소 폭으로 쓰입니다. + /// - size: 폭 토큰. `.fixed`에서만 쓰입니다 — `.fill(minimum:)`에서는 컨테이너 폭을 따르므로 + /// 이 값이 결과에 영향을 주지 않습니다. /// - height: 고정 높이. `nil`이면 높이를 제약하지 않습니다. /// - minHeight: 최소 높이. 내용이 많으면 그만큼 **자랍니다**. `height`와 함께 쓰지 않습니다 — /// 한쪽은 높이를 묶고 다른 쪽은 풀어주므로 의도가 상충합니다. @@ -87,13 +87,12 @@ private struct DVComponentWidthModifier: ViewModifier { alignment: alignment ) .frame(minHeight: minHeight, alignment: alignment) - case .fill: - // minWidth == 토큰 폭으로 하한을 지키고, maxWidth로 컨테이너를 채운다. + case .fill(let minimum): // 고정 높이는 min/max를 같은 값으로 줘서 표현한다 — // `frame(minWidth:maxWidth:height:)` 오버로드는 존재하지 않는다. content .frame( - minWidth: size.width, + minWidth: minimum.width, maxWidth: .infinity, minHeight: minHeight ?? height, maxHeight: height, diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/EnvironmentFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/EnvironmentFieldView.swift index 3bcf33fd..bc5a685f 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/EnvironmentFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/EnvironmentFieldView.swift @@ -19,8 +19,7 @@ struct EnvironmentFieldView: View { items: SecretEnvironment.allCases.map { .init($0, title: String(localized: $0.displayName)) }, - selection: $environment, - size: .sm + selection: $environment ) } } diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LicenseTierFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LicenseTierFieldView.swift index 0e82d6e7..b8a3cc5e 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LicenseTierFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/LicenseTierFieldView.swift @@ -20,8 +20,7 @@ struct LicenseTierFieldView: View { items: LicenseTier.allCases.map { .init($0, title: String(localized: $0.displayName)) }, - selection: $tier, - size: .sm + selection: $tier ) } } diff --git a/Projects/DVPresentation/Sources/Form/FormLayout.swift b/Projects/DVPresentation/Sources/Form/FormLayout.swift index 4459cbcb..2082b77c 100644 --- a/Projects/DVPresentation/Sources/Form/FormLayout.swift +++ b/Projects/DVPresentation/Sources/Form/FormLayout.swift @@ -35,6 +35,25 @@ enum FormLayoutMetrics { /// Detail 가변 폼에서 페어 슬롯 사이 간격 (Figma `1768:30828` 실측 — `x=0`, `x=200`, 폭 180). static let detailPairSpacing: CGFloat = 20 + /// detail 컬럼(조회·수정) 슬롯의 하한. + /// + /// 3컬럼의 한 칸이라 단독 창보다 훨씬 좁아질 수 있어 하한을 낮게 잡는다. + /// 이보다 좁아지면 라벨·chevron이 겹치기 시작하므로 여기서 멈추고, 그 아래로는 넘친다. + static let slotFloor: DVComponentSize = .xs + + /// 단독 창(생성) 슬롯의 하한. + /// + /// 창 자체가 `minWidth 520` 아래로 내려가지 않으므로 detail만큼 좁아질 일이 없다. + /// 낮출 이유가 없어 디자인 기본 폭을 그대로 하한으로 쓴다. + static let standaloneSlotFloor: DVComponentSize = .md + + /// 페어 두 칸을 가로로 유지할 수 있는 컨테이너 폭 하한. + /// + /// 계산: `2 × xs(180) + detailPairSpacing(20) + horizontalPadding × 2` = **420**. + /// 이보다 좁으면 페어가 하한 아래로 밀리므로 세로로 접는다(`FormLayout/detailCompact`). + static let detailPairThreshold: CGFloat = + 2 * slotFloor.width + detailPairSpacing + horizontalPadding * 2 + /// 2열 폼으로 전환하는 컨테이너 폭 하한. /// /// 계산: `2 × md(380) + dualPairSpacing(16) + horizontalPadding × 2` = **816**. @@ -116,16 +135,19 @@ extension FormLayout { pairedSize: .md, pairAxis: .horizontal(spacing: FormLayoutMetrics.dualPairSpacing), rowSpacing: FormLayoutMetrics.rowSpacing, - widthPolicy: .fill + widthPolicy: .fill(minimum: FormLayoutMetrics.slotFloor) ) - /// 1열 폼 — full-width `.md`, 페어는 세로로 접힘. CreateSecret의 좁은 폭 배열. + /// 1열 폼 — 페어를 세로로 접고 컨테이너를 채운다. CreateSecret의 좁은 폭 배열. + /// + /// 생성 화면은 단독 창이라 detail 컬럼만큼 좁아지지 않는다. 하한을 `.md`로 두어 + /// 그 아래로는 줄지 않게 하고, 그보다 넓어지면 창을 따라 함께 늘어난다. static let single = FormLayout( fullWidthSize: .md, pairedSize: .md, pairAxis: .vertical(spacing: FormLayoutMetrics.rowSpacing), rowSpacing: FormLayoutMetrics.rowSpacing, - widthPolicy: .fixed + widthPolicy: .fill(minimum: FormLayoutMetrics.standaloneSlotFloor) ) /// 가변 폭 1열 폼 — 컨테이너를 채우고 페어는 절반씩 나눈다. Detail 컬럼의 기본 배열. @@ -137,7 +159,20 @@ extension FormLayout { pairedSize: .xs, pairAxis: .horizontal(spacing: FormLayoutMetrics.detailPairSpacing), rowSpacing: FormLayoutMetrics.rowSpacing, - widthPolicy: .fill + widthPolicy: .fill(minimum: FormLayoutMetrics.slotFloor) + ) + + /// 페어를 세로로 접은 가변 폭 1열 폼. detail 컬럼이 페어 두 칸을 나란히 놓을 수 없을 만큼 + /// 좁아졌을 때 쓴다. + /// + /// 접는 것은 불연속이지만(반쯤 접을 수 없다) **그 뒤로는 다시 연속이다** — 모든 슬롯이 + /// 컨테이너를 채우며 `slotFloor`까지 1pt 단위로 줄어든다. + static let detailCompact = FormLayout( + fullWidthSize: .md, + pairedSize: .md, + pairAxis: .vertical(spacing: FormLayoutMetrics.rowSpacing), + rowSpacing: FormLayoutMetrics.rowSpacing, + widthPolicy: .fill(minimum: FormLayoutMetrics.slotFloor) ) } diff --git a/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift b/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift index 6aadcbb0..66d982a6 100644 --- a/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift +++ b/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift @@ -42,7 +42,16 @@ enum StandaloneFormLayout: FormLayoutContext { /// 넓은 디스플레이에서는 2열 폼 임계값(816)을 넘는다 — 그때는 단일 화면과 같은 `.dual` 배열을 쓴다. enum DetailColumnFormLayout: FormLayoutContext { + /// 폭이 줄어드는 순서가 세 단계다. + /// + /// 1. 2열(`dual`) — 페어를 나란히, 프레임 상한까지 채운다 + /// 2. 가변 1열(`detailFluid`) — 페어를 절반씩 유지하며 컨테이너를 따라 **연속으로** 줄어든다 + /// 3. 접힌 1열(`detailCompact`) — 페어를 세로로 접고, 다시 컨테이너를 따라 연속으로 줄어든다 + /// + /// 단계 사이는 불연속이지만 **단계 안에서는 컨테이너를 따라 연속으로 줄어든다.** static func layout(for containerWidth: CGFloat) -> FormLayout { - containerWidth >= FormLayoutMetrics.dualThreshold ? .dual : .detailFluid + if containerWidth >= FormLayoutMetrics.dualThreshold { return .dual } + if containerWidth >= FormLayoutMetrics.detailPairThreshold { return .detailFluid } + return .detailCompact } } From cbc797bb3ee2fdda0e2c03eef4049c05a6dbf6fd Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:29:21 +0900 Subject: [PATCH 15/61] =?UTF-8?q?[#76]=20feat:=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EB=A5=BC=20=EC=B0=BD=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 영역 한정 오버레이를 없앤다. 3컬럼에서 컬럼 하나만, 또는 스크롤 영역만 어두워지면 "진행 중"이 아니라 레이아웃이 깨진 것처럼 보이고, 어두워지지 않은 영역은 여전히 눌리는 것처럼 보여 어디까지 잠겼는지도 알 수 없다 - 화면은 windowBusy(_:)로 진행 중이라는 사실만 PreferenceKey로 올려보내고, 창 루트(AppView)가 windowBusyOverlay()로 한 번만 그린다. 화면이 자기가 창의 어느 부분인지 알 필요가 없다 - 입력 잠금은 별개라 .disabled(_:)를 함께 쓴다 --- .../Sources/Features/AppView.swift | 2 + .../CreateSecret/CreateSecretView.swift | 11 +--- .../Sources/Support/WindowBusyOverlay.swift | 50 +++++++++++++++++++ 3 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift diff --git a/Projects/DVPresentation/Sources/Features/AppView.swift b/Projects/DVPresentation/Sources/Features/AppView.swift index ad0cb1d6..667be576 100644 --- a/Projects/DVPresentation/Sources/Features/AppView.swift +++ b/Projects/DVPresentation/Sources/Features/AppView.swift @@ -22,6 +22,8 @@ public struct AppView: View { public var body: some View { content + // 진행 오버레이를 그리는 유일한 지점 (`.omc/GUIDELINES.md`). + .windowBusyOverlay() .task { store.send(.task) } } } diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift index ab82939f..f36b853d 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretView.swift @@ -69,16 +69,7 @@ extension CreateSecretView { } .scrollIndicators(.hidden) .disabled(store.isSaving) - .overlay { - if store.isSaving { - ZStack { - Color.black.opacity(0.08) - ProgressView() - .controlSize(.regular) - } - .allowsHitTesting(true) - } - } + .windowBusy(store.isSaving) } /// FIXED BOTTOM. Cancel + Save 액션 바. diff --git a/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift b/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift new file mode 100644 index 00000000..3745f97c --- /dev/null +++ b/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift @@ -0,0 +1,50 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import DVDesign + +// MARK: - WindowBusyKey + +/// 진행 중인 화면이 하나라도 있는지. 여러 화면이 동시에 참이어도 하나로 합쳐진다. +private struct WindowBusyKey: PreferenceKey { + static let defaultValue = false + + static func reduce(value: inout Bool, nextValue: () -> Bool) { + value = value || nextValue() + } +} + +// MARK: - View + +extension View { + + /// 이 화면이 진행 중임을 **창 루트에 알린다.** 오버레이는 여기서 그리지 않는다. + /// + /// 진행 오버레이를 화면 일부에만 덮으면 그 경계가 그대로 드러난다. 3컬럼에서 컬럼 하나만, + /// 또는 스크롤 영역만 어두워지면 "무언가 진행 중"이 아니라 레이아웃이 깨진 것처럼 보이고, + /// 어두워지지 않은 영역은 여전히 눌리는 것처럼 보여 어디까지 잠겼는지도 알 수 없다. + /// + /// 그래서 화면은 사실만 올려보내고 덮는 일은 ``windowBusyOverlay()``를 붙인 창 루트가 한다. + /// 화면이 자기가 창의 어느 부분인지 알 필요가 없다. + /// + /// 입력을 잠그는 것은 별개다 — 필요하면 `.disabled(_:)`를 함께 쓴다. + func windowBusy(_ isBusy: Bool) -> some View { + preference(key: WindowBusyKey.self, value: isBusy) + } + + /// 창 루트에 **한 번만** 붙인다. 어느 화면이든 ``windowBusy(_:)``가 참이면 창 전체를 덮는다. + func windowBusyOverlay() -> some View { + overlayPreferenceValue(WindowBusyKey.self) { isBusy in + if isBusy { + ZStack { + Color.black.opacity(0.08) + ProgressView().controlSize(.regular) + } + // 아래로 클릭이 새지 않게 막는다. 진행 중에는 창 전체가 잠긴 것이 맞다. + .allowsHitTesting(true) + .ignoresSafeArea() + } + } + } +} From d3cc4e6c47a47662be59725fe1f1f0b281f2f300 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:30:18 +0900 Subject: [PATCH 16/61] =?UTF-8?q?[#76]=20feat:=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EC=84=A0=ED=83=9D=EB=90=9C=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/DVChipsContainer.swift | 49 +++++++++++++------ .../Components/DetailProjectFieldView.swift | 9 +++- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVChipsContainer.swift b/Projects/DVDesign/Sources/Components/DVChipsContainer.swift index dc6aec74..f070a574 100644 --- a/Projects/DVDesign/Sources/Components/DVChipsContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVChipsContainer.swift @@ -15,8 +15,9 @@ import SwiftUI /// } /// ``` /// -/// 값이 비면 chip 없이 박스만 남는다 — ``DVTextContainer``의 Empty 상태와 같은 취급이며, -/// 이때도 높이는 다른 읽기 전용 필드와 맞도록 32pt를 유지한다. +/// 값이 비면 `placeholder`를 표시한다. 박스만 남겨두면 "아직 안 불러온 것"과 "고른 것이 없는 것"이 +/// 구분되지 않는다. `placeholder`가 `nil`이면 빈 박스로 둔다. +/// 어느 쪽이든 높이는 다른 읽기 전용 필드와 맞도록 32pt를 유지한다. /// /// > Note: chip은 ``DVChip``(내부적으로 `Button`)이라 그대로 두면 포커스를 받는다. /// > 컨테이너 전체에 `allowsHitTesting(false)`를 걸어 인터랙션을 차단한다. @@ -40,37 +41,55 @@ public struct DVChipsContainer: View { // MARK: - Properties private let labels: [String] + private let placeholder: String? private let size: DVComponentSize // MARK: - Init /// - Parameters: - /// - labels: chip으로 표시할 텍스트 목록. 빈 배열이면 박스만 표시된다. + /// - labels: chip으로 표시할 텍스트 목록. + /// - placeholder: `labels`가 비었을 때 대신 표시할 문구. `nil`이면 빈 박스. /// - size: 너비 변형. 기본값 ``DVComponentSize/md``. public init( _ labels: [String], + placeholder: String? = nil, size: DVComponentSize = .md ) { self.labels = labels + self.placeholder = placeholder self.size = size } // MARK: - Body public var body: some View { - DVFlowLayout(hSpacing: Metrics.chipsSpacing, vSpacing: Metrics.chipsSpacing) { - // 라벨이 중복될 수 있으므로 값이 아니라 위치를 id로 쓴다. - ForEach(Array(labels.enumerated()), id: \.offset) { _, label in - DVChip(label) + content + .allowsHitTesting(false) + .padding(Metrics.chipsPadding) + .frame(minHeight: Metrics.minHeight) + .dvComponentWidth(size, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: Metrics.cornerRadius) + .fill(Color.dv(.gray300)) + } + } + + @ViewBuilder + private var content: some View { + if labels.isEmpty, let placeholder { + // 값 텍스트(gray900)가 아니라 안내 문구로 읽혀야 하므로 placeholder 색을 쓴다 — + // `DVTextField`·`DVMultilineTextField`의 placeholder와 같은 취급이다. + Text(placeholder) + .dvFont(.bodyLG) + .foregroundStyle(Color.dv(.gray400)) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + DVFlowLayout(hSpacing: Metrics.chipsSpacing, vSpacing: Metrics.chipsSpacing) { + // 라벨이 중복될 수 있으므로 값이 아니라 위치를 id로 쓴다. + ForEach(Array(labels.enumerated()), id: \.offset) { _, label in + DVChip(label) + } } - } - .allowsHitTesting(false) - .padding(Metrics.chipsPadding) - .frame(minHeight: Metrics.minHeight) - .dvComponentWidth(size, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: Metrics.cornerRadius) - .fill(Color.dv(.gray300)) } } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailProjectFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailProjectFieldView.swift index 2c32841f..cb1d1240 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailProjectFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailProjectFieldView.swift @@ -11,7 +11,8 @@ import DVDomain /// `DVChipsContainer`로 표시한다. 생성 화면이 `DVMultiSelectDropdown`의 chip 트리거로 /// 보여주는 것과 같은 모습이다. /// -/// 연결된 프로젝트가 없으면 chip 없이 박스만 남는다 — 다른 optional 필드의 Empty 상태와 동일. +/// 연결된 프로젝트가 없으면 안내 문구를 표시한다. 이 필드는 **조회에 실패해도 비어 있어서** +/// (`linkedProjectsResponse` 실패 경로) 빈 박스로 두면 특히 헷갈린다. struct DetailProjectFieldView: View { let projects: [Project] @@ -25,7 +26,11 @@ struct DetailProjectFieldView: View { var body: some View { DVLabeledField(.module("Project"), size: size) { - DVChipsContainer(projects.map(\.name), size: size) + DVChipsContainer( + projects.map(\.name), + placeholder: .module("No project selected"), + size: size + ) } } } From 721e26878696cb7b2382f8e119013df3d6380698 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:32:18 +0900 Subject: [PATCH 17/61] =?UTF-8?q?[#76]=20feat:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=84=ED=99=98=20=EB=A7=88=EA=B0=90?= =?UTF-8?q?=EA=B3=BC=20=EC=88=98=EC=A0=95=20=EC=A7=84=EC=9E=85=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=AC=B8=EA=B5=AC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조회 ↔ 수정 전환에 애니메이션을 넣는다. 필드 영역은 교차 페이드 — 읽기 전용 필드와 입력 필드가 같은 자리에 있어 자연스럽다. footer는 아래에서 올라온다 - 헤더는 교체 대상에서 뺀다. 두 모드가 사실상 같은데 함께 교차 페이드하면 양쪽이 동시에 반투명해지는 구간에서 타입명과 탭바가 한 번 어두워진다. 모드별로 나뉘어 있던 ScrollView를 하나로 합쳐 바뀌는 자리만 감쌌고, 덕분에 모드를 오가도 스크롤 위치가 유지된다 - fix: 취소·저장 시 크래시. Binding($store.editFields)는 생성 시점에만 nil을 걸러내고 만들어진 바인딩은 읽을 때마다 강제 언래핑한다. 전환 애니메이션 때문에 사라지는 쪽 뷰가 0.25초 살아남아 nil을 한 번 더 읽으면서 터졌다 — 마지막 값으로 대신 읽게 한다 - footer 위 구분선을 없앤다. 생성 화면에도 없다 - 저장 중 스크림을 걷어내고 진행 표시를 창 루트에 맡긴다 - 수정 진입 인증 문구를 열람과 분리한다. 고치려고 눌렀는데 "확인하려면"이라고 물으면 잘못 눌렀나 싶어진다. 문구는 호출자 의도이므로 revealPayload(id:as:reason:)까지 전달한다 - 편집 폼에서 만든 프로젝트를 알리는 delegate(projectsChanged)를 함께 넣는다. 수신은 다음 커밋(MainFeature)이라 이 시점에는 발신만 있다 --- .../RevealSecretPayloadUseCaseImpl.swift | 7 +- .../Authentication/AuthenticationReason.swift | 4 + .../Secret/RevealSecretPayloadUseCase.swift | 4 +- .../RevealSecretPayloadUseCaseImplTests.swift | 8 +- .../Resources/Localizable.xcstrings | 32 +++++ .../Sources/Dependencies/SecretClient.swift | 9 +- .../SecretDetail/SecretDetailFeature.swift | 39 +++++- .../SecretDetail/SecretDetailView.swift | 122 ++++++++---------- .../Tests/Main/MainFeatureTests.swift | 2 +- .../SecretDetailFeatureTests.swift | 20 +-- .../Dependencies/SecretClient+Live.swift | 13 +- 11 files changed, 165 insertions(+), 95 deletions(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/RevealSecretPayloadUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/RevealSecretPayloadUseCaseImpl.swift index 0c454aec..dd7c3fec 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/RevealSecretPayloadUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/RevealSecretPayloadUseCaseImpl.swift @@ -16,10 +16,13 @@ public struct RevealSecretPayloadUseCaseImpl: RevealSecretPayloadUseCase { public func revealPayload( id: UUID, - as type: Payload.Type + as type: Payload.Type, + reason: String ) async throws -> Payload { do { - try await authenticateUseCase.authenticate(reason: AuthenticationReason.revealSecret) + // 문구는 호출자가 정한다 — 열람과 수정 진입이 같은 복호화를 타지만 사용자가 누른 + // 버튼이 달라서다. 기본값을 두지 않아 호출부가 고르는 것을 잊을 수 없다. + try await authenticateUseCase.authenticate(reason: reason) return try await decryptPayloadUseCase.decryptPayload(id: id, as: type) } catch { throw SecretUseCaseError.map(error) diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Authentication/AuthenticationReason.swift b/Projects/DVDomain/Sources/UseCase/Interface/Authentication/AuthenticationReason.swift index 57cf39cf..ea8b974c 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Authentication/AuthenticationReason.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Authentication/AuthenticationReason.swift @@ -16,4 +16,8 @@ public enum AuthenticationReason { /// 시크릿 값 복사. 복사 시 인증 설정이 켜진 경우에만 사용한다. public static let copySecret = "시크릿 값을 복사하려면 인증이 필요합니다" + + /// 수정 화면 진입. 열람과 같은 복호화를 타지만 사용자가 누른 것은 수정 버튼이므로 문구를 나눈다 — + /// 고치려고 눌렀는데 "확인하려면"이라고 물으면 잘못 눌렀나 싶어진다. + public static let editSecret = "시크릿을 수정하려면 인증이 필요합니다" } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/RevealSecretPayloadUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/RevealSecretPayloadUseCase.swift index d317ba92..4b349965 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/RevealSecretPayloadUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/RevealSecretPayloadUseCase.swift @@ -10,9 +10,11 @@ public protocol RevealSecretPayloadUseCase: Sendable { /// - Parameters: /// - id: 복호화할 Secret의 ID /// - type: 복호화 결과로 변환할 payload 타입 + /// - reason: 시스템 인증 시트에 표시할 문구. ``AuthenticationReason``의 상수를 쓴다. /// - Returns: 복호화된 payload func revealPayload( id: UUID, - as type: Payload.Type + as type: Payload.Type, + reason: String ) async throws -> Payload } diff --git a/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift index f3517b94..7e7532ce 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift @@ -23,7 +23,7 @@ struct RevealSecretPayloadUseCaseImplTests { repo.seed(secret) let sut = makeSUT(repository: repo, cryptoService: crypto, authenticationService: auth) - let revealed: APIKeyPayload = try await sut.revealPayload(id: secret.id, as: APIKeyPayload.self) + let revealed: APIKeyPayload = try await sut.revealPayload(id: secret.id, as: APIKeyPayload.self, reason: AuthenticationReason.revealSecret) #expect(revealed == payload) #expect(auth.authenticateCount == 1) @@ -40,7 +40,7 @@ struct RevealSecretPayloadUseCaseImplTests { let sut = makeSUT(repository: repo, cryptoService: crypto, authenticationService: auth) await #expect(throws: SecretUseCaseError.authenticationFailure(.cancelled)) { - _ = try await sut.revealPayload(id: UUID(), as: APIKeyPayload.self) + _ = try await sut.revealPayload(id: UUID(), as: APIKeyPayload.self, reason: AuthenticationReason.revealSecret) } #expect(repo.fetchByIDCount == 0) #expect(crypto.decryptCount == 0) @@ -54,7 +54,7 @@ struct RevealSecretPayloadUseCaseImplTests { let sut = makeSUT(repository: repo, cryptoService: crypto, authenticationService: StubUserAuthenticationService()) await #expect(throws: SecretUseCaseError.secretNotFound(id: missingID)) { - _ = try await sut.revealPayload(id: missingID, as: APIKeyPayload.self) + _ = try await sut.revealPayload(id: missingID, as: APIKeyPayload.self, reason: AuthenticationReason.revealSecret) } #expect(crypto.decryptCount == 0) } @@ -69,7 +69,7 @@ struct RevealSecretPayloadUseCaseImplTests { let sut = makeSUT(repository: repo, cryptoService: crypto, authenticationService: StubUserAuthenticationService()) await #expect(throws: SecretUseCaseError.cryptoFailure(.decryptionFailed)) { - _ = try await sut.revealPayload(id: secret.id, as: APIKeyPayload.self) + _ = try await sut.revealPayload(id: secret.id, as: APIKeyPayload.self, reason: AuthenticationReason.revealSecret) } } diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 267744bd..ef04305e 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -108,6 +108,10 @@ "comment" : "Description of a setting that alerts the user when they copy from the clipboard more than once.", "isCommentAutoGenerated" : true }, + "An unexpected error occurred. Your changes were not saved and are still here." : { + "comment": "Text displayed in a notification when an unexpected error occurred while saving changes.", + "isCommentAutoGenerated": true + }, "Approval required" : { }, @@ -344,6 +348,10 @@ "comment" : "Placeholder text for the private key field in the SSL/TLS certificate form section.", "isCommentAutoGenerated" : true }, + "e.g deploy.example.com" : { + "comment": "Placeholder text for a label.", + "isCommentAutoGenerated": true + }, "e.g {\"type\": \"service_account\", ...}" : { }, @@ -469,6 +477,10 @@ "comment" : "Title of the sidebar filter option for expired items.", "isCommentAutoGenerated" : true }, + "Failed to copy" : { + "comment": "Text displayed in a notification when a value could not be copied to the clipboard.", + "isCommentAutoGenerated": true + }, "Failed to delete secret" : { "comment" : "Text displayed in an alert when a secret deletion fails.", "isCommentAutoGenerated" : true @@ -486,6 +498,10 @@ }, "Failed to reveal secret" : { + }, + "Failed to save changes" : { + "comment": "Text displayed in a confirmation alert when an update operation fails.", + "isCommentAutoGenerated": true }, "Failed to update favorite" : { "comment" : "Text displayed in an alert when updating a favorite status of a secret fails.", @@ -584,6 +600,10 @@ }, "No iCloud Account" : { + }, + "No project selected" : { + "comment": "Text displayed when a project is not selected.", + "isCommentAutoGenerated": true }, "No projects yet" : { @@ -670,6 +690,10 @@ }, "Project" : { + }, + "Project information could not be loaded. Other details are unaffected." : { + "comment": "Alert message when project details cannot be loaded.", + "isCommentAutoGenerated": true }, "Project Name" : { @@ -719,6 +743,10 @@ "comment" : "Button title that triggers the retry action for revealing a secret.", "isCommentAutoGenerated" : true }, + "Sample" : { + "comment": "Label for a text field.", + "isCommentAutoGenerated": true + }, "Save" : { "comment" : "The label of a button that saves the current input.", "isCommentAutoGenerated" : true @@ -829,6 +857,10 @@ }, "The setting was saved, but existing notifications couldn't be updated. Please try again." : { + }, + "The value could not be copied to the clipboard. Please try again." : { + "comment": "Alert message when a clipboard copy operation fails.", + "isCommentAutoGenerated": true }, "This action cannot be undone." : { "comment" : "Warning text displayed above the \"Delete All Data\" button in the Data Settings view.", diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index b7366312..9444ac39 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -18,7 +18,9 @@ public struct SecretClient: Sendable { public var permanentlyDelete: @Sendable (_ id: Secret.ID) async throws -> Void /// 생체인증 후 Secret의 암호화된 payload를 복호화해 `CreateSecretPayload`로 반환한다. /// secretType/subType에 따라 적절한 도메인 payload 타입으로 dispatch하고 metadata JSON을 병합한다. - public var revealPayload: @Sendable (_ secret: Secret) async throws -> CreateSecretPayload + /// - Parameter reason: 시스템 인증 시트 문구. `AuthenticationReason`의 상수를 쓴다 — + /// 같은 복호화라도 열람과 수정 진입은 사용자가 누른 버튼이 달라 문장이 달라야 한다. + public var revealPayload: @Sendable (_ secret: Secret, _ reason: String) async throws -> CreateSecretPayload /// Copy가 자체 인증 정책을 적용하도록 Reveal 인증 없이 payload를 복호화한다. /// 반환값은 화면에 공개하지 않고 민감 값 Copy 흐름에서만 사용한다. public var loadPayloadForCopy: @Sendable (_ secret: Secret) async throws -> CreateSecretPayload @@ -116,7 +118,7 @@ private extension SecretClient { // live `dispatchRevealPayload`와 case 구성이 1:1로 일치해야 한다 — // 한쪽만 바뀌면 프리뷰가 실제와 다른 payload 타입을 조용히 반환한다. // metadata는 CreateSecret 폼이 실제로 입력받는 필드만 채운다(나머지는 live에서도 항상 nil). - revealPayload: { secret in + revealPayload: { secret, _ in switch (secret.secretType, secret.subType) { case (.apiKeyToken, .apiKey), (.apiKeyToken, nil): return .apiKey( @@ -200,7 +202,8 @@ private extension SecretClient { } }, loadPayloadForCopy: { secret in - try await dummyClient().revealPayload(secret) + // 프리뷰는 인증을 타지 않으므로 문구가 무엇이든 결과가 같다. + try await dummyClient().revealPayload(secret, AuthenticationReason.revealSecret) }, setLiked: { _, liked in var secret = Secret.preview diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index e0bcda01..01116ad5 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -42,6 +42,29 @@ public struct SecretDetailFeature { /// **수정 진입이 복호화를, 따라서 인증을 탄다.** 실패하면 조회 모드에 남는다 — /// 인증을 취소했는데 편집 화면이 열려 있으면 안 된다. case edit + + /// 이 복호화가 시스템 인증 시트에 표시할 문구. + /// + /// 같은 복호화라도 사용자가 누른 버튼이 다르면 기대하는 문장이 다르다. 눈·복사는 값을 + /// 보려는 것이고 수정은 고치려는 것이라, 후자에 "확인하려면"이라고 물으면 잘못 눌렀나 싶어진다. + var authenticationReason: String { + switch self { + case .none, .reveal, .copy: return AuthenticationReason.revealSecret + case .edit: return AuthenticationReason.editSecret + } + } + + /// 이 복호화가 성공했을 때 **열람 인증 창을 열어도 되는지**. + /// + /// 복사만 예외다. 복사는 자체 인증 정책을 따로 갖고(`CopySensitiveValueUseCase`가 설정을 + /// 읽어 결정한다), 여기서 창을 열면 복사 한 번에 누르지도 않은 열람 권한이 따라붙는다. + /// 나머지는 전부 `revealPayload`로 인증을 통과한 것이므로 창을 여는 것이 맞다. + var opensRevealWindow: Bool { + switch self { + case .copy: return false + case .none, .reveal, .edit: return true + } + } } // MARK: - State @@ -166,6 +189,9 @@ public struct SecretDetailFeature { case closed case secretUpdated(Secret) case deleted(Secret.ID) + /// 편집 폼 안에서 프로젝트를 새로 만들었다. **저장 여부와 무관하게** 프로젝트는 이미 + /// 만들어졌으므로 사이드바가 곧바로 반영해야 한다 — 취소하고 나가도 프로젝트는 남는다. + case projectsChanged } } @@ -243,12 +269,13 @@ public struct SecretDetailFeature { // 복사 한 번에 누르지도 않은 열람 권한이 따라붙는다. case .payloadResponse(.success(let payload), let continuation): state.payloadState = .loaded(payload) + if continuation.opensRevealWindow { + state.revealAuthorizedAt = now + } switch continuation { case .none: - state.revealAuthorizedAt = now return .none case .reveal(let field): - state.revealAuthorizedAt = now state.revealedFields.insert(field) return .none case .copy(let field): @@ -415,7 +442,8 @@ public struct SecretDetailFeature { state.availableProjects.append( Project(id: project.id, name: project.name, createdAt: now, updatedAt: now) ) - return .none + // 이 폼의 드롭다운만 갱신하고 끝내면 사이드바는 앱을 다시 열 때까지 모른다. + return .send(.delegate(.projectsChanged)) case .didTapSave: return handleSave(&state) @@ -590,9 +618,10 @@ public struct SecretDetailFeature { secret: Secret, then continuation: RevealContinuation ) -> Effect { - .run { send in + let reason = continuation.authenticationReason + return .run { send in do { - let payload = try await secretClient.revealPayload(secret) + let payload = try await secretClient.revealPayload(secret, reason) await send(.payloadResponse(.success(payload), then: continuation)) } catch is CancellationError { } catch { diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index 146e951c..8e86d7c8 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -23,35 +23,40 @@ public struct SecretDetailView: View { // MARK: - Body public var body: some View { - // 필드 폭은 컨테이너 폭에서 파생된다. detail 컬럼은 기본적으로 가변 폭 1열(.detailFluid)이고, - // 컬럼이 2열 임계값(816)을 넘으면 CreateSecret과 같은 .dual 배열로 전환된다. + /// 필드 폭은 컨테이너 폭에서 파생된다. detail 컬럼은 기본적으로 가변 폭 1열(.detailFluid)이고, + /// 컬럼이 2열 임계값(816)을 넘으면 CreateSecret과 같은 .dual 배열로 전환된다. GeometryReader { proxy in let layout = DetailColumnFormLayout.layout(for: proxy.size.width) VStack(spacing: 0) { - Group { - if store.mode == .viewing { - viewingBody - } else { - editingBody + ScrollView { + bodyStack { + // 헤더는 두 모드가 사실상 같으므로 교체 대상에서 빼둔다 + Group { + if store.mode == .viewing { + viewingSection.transition(.opacity) + } else { + editingSection.transition(.opacity) + } + } } } + .scrollIndicators(.hidden) + if store.mode == .editing { - Divider() + /// 생성 화면과 마찬가지로 구분선을 두지 않는다 — footer 자체가 여백으로 분리된다. footer + .transition(.move(edge: .bottom).combined(with: .opacity)) } } - // 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. + /// 모드 전환만 애니메이션한다. `value`를 좁히지 않으면 편집 중 타이핑 한 글자마다 폼 전체가 다시 애니메이션된다. + .animation(.smooth(duration: 0.25), value: store.mode) + /// 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. .formMaxWidth() .formLayout(layout) } - // `id:`가 필수다. 다른 리스트 셀을 선택하면 MainFeature가 `secretDetail`에 **새 State**를 - // 할당하지만, 뷰의 타입·위치가 그대로라 SwiftUI는 뷰를 재생성하지 않는다. - // 그러면 평범한 `.task`는 다시 실행되지 않아 `linkedProjects` / `payloadState`가 빈 채로 남는다 - // (`store.secret`을 직접 읽는 헤더·Name만 갱신되어 더 헷갈린다). - // - // `finish()`를 await해야 secret이 바뀔 때 이전 secret의 lifecycle 구독과 프로젝트 조회가 - // 함께 끝난다. 눈·복사는 이 task의 자식이 아니므로 여기서 취소되지 않는다 — - // 그쪽은 `State.id`를 보고 `ifLet`이 취소한다. + /// `id:`가 필수다. 다른 리스트 셀을 선택하면 MainFeature가 `secretDetail`에 새 State를 할당하지만, 뷰의 타입·위치가 그대로라 SwiftUI는 뷰를 재생성하지 않는다. 그러면 평범한 `.task`는 다시 실행되지 않아 `linkedProjects` / `payloadState`가 빈 채로 남는다 (`store.secret`을 직접 읽는 헤더·Name만 갱신되어 더 헷갈린다). + /// + /// `finish()`를 await해야 secret이 바뀔 때 이전 secret의 lifecycle 구독과 프로젝트 조회가 함께 끝난다. 눈·복사는 이 task의 자식이 아니므로 여기서 취소되지 않는다 — 그쪽은 `State.id`를 보고 `ifLet`이 취소한다. .task(id: store.secret.id) { await store.send(.task).finish() } @@ -68,21 +73,15 @@ extension SecretDetailView { // MARK: Viewing - /// 복호화 여부와 무관하게 항상 같은 섹션을 렌더한다. payload 필드는 값이 있든 없든 마스킹되므로 - /// 복호화 전후로 화면 구성이 달라지지 않고, 로딩·실패 전용 화면도 필요 없다(실패는 alert로 알린다). + /// 복호화 여부와 무관하게 항상 같은 섹션을 렌더한다. payload 필드는 값이 있든 없든 마스킹되므로 복호화 전후로 화면 구성이 달라지지 않고, 로딩·실패 전용 화면도 필요 없다(실패는 alert로 알린다). /// - /// 눈·복사 동작은 Environment로 내려보낸다 — 필드가 9개 섹션 안쪽에 흩어져 있어 - /// 파라미터로 넘기면 중간 섹션들이 쓰지도 않는 값을 계속 실어 나른다. - private var viewingBody: some View { - ScrollView { - bodyStack { - DetailPayloadSectionView( - secret: store.secret, - linkedProjects: store.linkedProjects, - payload: displayedPayload - ) - } - } + /// secure·copy 동작은 Environment로 내려보낸다 — 필드가 9개 섹션 안쪽에 흩어져 있어 파라미터로 넘기면 중간 섹션들이 쓰지도 않는 값을 계속 실어 나른다. + private var viewingSection: some View { + DetailPayloadSectionView( + secret: store.secret, + linkedProjects: store.linkedProjects, + payload: displayedPayload + ) .environment( \.detailFieldActions, DetailFieldActions( @@ -94,8 +93,7 @@ extension SecretDetailView { ) } - /// reveal 전에는 payload만 빈 껍데기를 넘긴다. 민감 필드는 어차피 마스킹되어 화면이 같고, - /// metadata는 평문이라 `Secret`에서 그대로 채워지므로 평문 필드가 처음부터 보인다. + /// reveal 전에는 payload만 빈 껍데기를 넘긴다. 민감 필드는 어차피 마스킹되어 화면이 같고, metadata는 평문이라 `Secret`에서 그대로 채워지므로 평문 필드가 처음부터 보인다. private var displayedPayload: CreateSecretPayload { if case .loaded(let payload) = store.payloadState { return payload @@ -145,36 +143,31 @@ extension SecretDetailView { /// `editFields`가 `nil`인 채로 `mode == .editing`인 조합은 reducer가 만들지 않지만, /// `if let`이 그 조합을 아예 렌더 불가능하게 만든다. @ViewBuilder - private var editingBody: some View { - if let fields = Binding($store.editFields) { - ScrollView { - bodyStack { - SecretFormSectionsView( - secretType: store.secret.secretType.creatableType, - subType: store.secret.secretType.creatableType - .resolvedSubType(store.secret.subType), - meta: fields, - availableProjects: store.availableProjects, - // 감지 힌트는 생성 화면 전용이다. 이미 저장된 값을 고치는 중에 - // "이 서비스로 보입니다"를 띄우면 사용자가 고른 service를 되묻는 꼴이 된다. - serviceCandidates: [], - validationErrors: store.validationErrors, - detectedServices: [:], - onCreateProject: { store.send(.didTapCreateProject) } - ) - } - } - .scrollIndicators(.hidden) + private var editingSection: some View { + // `Binding($store.editFields)`를 쓰면 안 된다. 그 이니셜라이저는 **생성 시점에만** nil을 + // 걸러내고, 만들어진 바인딩은 읽을 때마다 강제 언래핑한다. 취소·저장이 `editFields`를 + // 비우는 순간 전환 애니메이션 때문에 사라지는 쪽 뷰가 아직 살아 있어 한 번 더 읽히고, + // 그때 크래시한다. 마지막 값으로 대신 읽게 해 둔다 — 그 프레임은 어차피 페이드 아웃 중이다. + if let snapshot = store.editFields { + let fields = Binding( + get: { $store.editFields.wrappedValue ?? snapshot }, + set: { $store.editFields.wrappedValue = $0 } + ) + SecretFormSectionsView( + secretType: store.secret.secretType.creatableType, + subType: store.secret.secretType.creatableType + .resolvedSubType(store.secret.subType), + meta: fields, + availableProjects: store.availableProjects, + /// 감지 힌트는 생성 화면 전용이다. 이미 저장된 값을 고치는 중에 서비스 제안 필요 없음. + serviceCandidates: [], + validationErrors: store.validationErrors, + detectedServices: [:], + onCreateProject: { store.send(.didTapCreateProject) } + ) + /// 진행 표시는 창 루트가 그린다 (`.omc/GUIDELINES.md`). .disabled(store.isSaving) - .overlay { - if store.isSaving { - ZStack { - Color.black.opacity(0.08) - ProgressView().controlSize(.regular) - } - .allowsHitTesting(true) - } - } + .windowBusy(store.isSaving) .environment(\.isProjectLoading, store.isLoadingProjects) } } @@ -183,8 +176,7 @@ extension SecretDetailView { private var footer: some View { FooterActionsView( saveTitle: .module("Save"), - // 필수 필드 검증은 `didTapSave`가 수행해 인라인 경고를 세운다 — 여기서 미리 막으면 - // 경고가 영영 뜨지 않는다. 생성 화면과 같은 규칙이다. + /// 필수 필드 검증은 `didTapSave`가 수행해 인라인 경고를 세운다 — 여기서 미리 막으면 경고가 영영 뜨지 않는다. 생성 화면과 같은 규칙. isSaveEnabled: !store.isSaving, onCancel: { store.send(.didTapCancelEdit) }, onSave: { store.send(.didTapSave) } @@ -248,7 +240,7 @@ private let _previewSecret = [Secret].previewSubTypeMatrix[0] ) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in + $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } } diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 45f0645b..d7a860cc 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -368,7 +368,7 @@ struct MainFeatureTests { let store = TestStore(initialState: initial) { MainFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in + $0.secretClient.revealPayload = { _, _ in try await clock.sleep(for: .seconds(1)) return .apiKey(APIKeyPayload(value: "A의 평문"), nil) } diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index 3deb2fd9..adb7ebf7 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -63,7 +63,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in payload } + $0.secretClient.revealPayload = { _, _ in payload } $0.date = .constant(Self.referenceDate) } @@ -84,7 +84,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } + $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } } await store.send(.didTapToggleReveal(.value)) { @@ -103,7 +103,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } + $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } } await store.send(.didTapToggleReveal(.value)) { @@ -170,7 +170,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in payload } + $0.secretClient.revealPayload = { _, _ in payload } $0.date = .constant(Self.referenceDate) } @@ -190,7 +190,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } + $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } } await store.send(.didTapRetryReveal) { @@ -212,7 +212,7 @@ struct SecretDetailFeatureTests { SecretDetailFeature() } withDependencies: { $0.date = .constant(Self.referenceDate) - $0.secretClient.revealPayload = { _ in + $0.secretClient.revealPayload = { _, _ in let attempt = attemptCount.withValue { count -> Int in count += 1 return count @@ -472,7 +472,7 @@ struct SecretDetailFeatureTests { try await clock.sleep(for: .seconds(1)) return payload } - $0.secretClient.revealPayload = { _ in + $0.secretClient.revealPayload = { _, _ in try await clock.sleep(for: .seconds(1)) return payload } @@ -722,7 +722,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in payload } + $0.secretClient.revealPayload = { _, _ in payload } $0.secretClient.fetchProjects = { throw CancellationError() } $0.date = .constant(Self.referenceDate) } @@ -750,7 +750,7 @@ struct SecretDetailFeatureTests { let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { SecretDetailFeature() } withDependencies: { - $0.secretClient.revealPayload = { _ in + $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.authenticationFailure(.cancelled) } $0.secretClient.fetchProjects = { throw CancellationError() } @@ -904,6 +904,8 @@ struct SecretDetailFeatureTests { ) ] } + // 저장 여부와 무관하게 프로젝트는 이미 만들어졌다 — 사이드바가 곧바로 알아야 한다. + await store.receive(.delegate(.projectsChanged)) } // MARK: - 저장 diff --git a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift index 4d72cf02..98cc5f63 100644 --- a/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift +++ b/Projects/Devault/Sources/Composition/Dependencies/SecretClient+Live.swift @@ -58,10 +58,10 @@ extension SecretClient: @retroactive DependencyKey { try await deleteSecretUseCase.permanentlyDelete(id: id) await LiveUseCases.expirySchedule.cancel(secretID: id) }, - revealPayload: { secret in + revealPayload: { secret, reason in try await dispatchPayload( secret: secret, - access: .reveal(revealSecretPayloadUseCase) + access: .reveal(revealSecretPayloadUseCase, reason: reason) ) }, loadPayloadForCopy: { secret in @@ -213,7 +213,10 @@ private func dispatchUpdateSecret( // MARK: - Payload Dispatch private enum PayloadAccess { - case reveal(any RevealSecretPayloadUseCase) + /// 열람·수정 진입. 인증을 타므로 시스템 시트에 띄울 문구를 함께 받는다 — + /// 같은 복호화라도 사용자가 누른 버튼이 다르면 기대하는 문장이 다르다. + case reveal(any RevealSecretPayloadUseCase, reason: String) + /// 복사. 인증 여부는 이어지는 `copySensitiveValue`가 설정을 읽어 결정하므로 여기서는 복호화만 한다. case copy(any DecryptSecretPayloadUseCase) func load( @@ -221,8 +224,8 @@ private enum PayloadAccess { as type: Payload.Type ) async throws -> Payload { switch self { - case .reveal(let useCase): - return try await useCase.revealPayload(id: id, as: type) + case .reveal(let useCase, let reason): + return try await useCase.revealPayload(id: id, as: type, reason: reason) case .copy(let useCase): return try await useCase.decryptPayload(id: id, as: type) } From fdd2224aa49448f52c392cc09037d6cc634fbd4f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:11:36 +0900 Subject: [PATCH 18/61] =?UTF-8?q?[#76]=20feat:=20=ED=8F=BC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A7=8C=EB=93=A0=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94?= =?UTF-8?q?=EC=97=90=20=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 생성·수정 폼의 프로젝트 생성을 delegate로 올려 공통 부모가 사이드바 재조회를 지시 - 시크릿 저장 여부와 무관하게 프로젝트는 이미 만들어졌으므로 폼을 취소해도 목록에 남는다 - 목록을 비우지 않는 refresh 액션을 추가해 재조회 때 사이드바가 사라졌다 나타나지 않게 함 - 개수 갱신도 .loading으로 되돌리지 않아 시크릿을 만들 때마다 숫자가 깜빡이던 문제 해소 - 프로젝트 추가·삭제·이름 변경 시 행이 부드럽게 움직이도록 애니메이션 추가 --- .../CreateSecret/CreateSecretFeature.swift | 5 +- .../Sources/Features/Main/MainFeature.swift | 11 ++- .../Features/Sidebar/SidebarFeature.swift | 46 ++++++--- .../Features/Sidebar/SidebarView.swift | 3 + .../CreateSecretFeatureTests.swift | 1 + .../Tests/Main/MainFeatureTests.swift | 93 ++++++++++++++----- .../Tests/Sidebar/SidebarFeatureTests.swift | 18 +--- 7 files changed, 122 insertions(+), 55 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift index 23f0d006..e0dd626f 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift @@ -95,6 +95,9 @@ public struct CreateSecretFeature { public enum Delegate: Equatable { case secretCreated(Secret.ID) case cancelled + /// 폼 안에서 프로젝트를 새로 만들었다. **시크릿 저장 여부와 무관하게** 프로젝트는 이미 + /// 만들어졌으므로 사이드바가 곧바로 반영해야 한다 — 취소하고 나가도 프로젝트는 남는다. + case projectsChanged } } @@ -203,7 +206,7 @@ public struct CreateSecretFeature { state.availableProjects.append( Project(id: projectItem.id, name: projectItem.name, createdAt: now, updatedAt: now) ) - return .none + return .send(.delegate(.projectsChanged)) case .createProject: return .none diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 90ff563f..3ab94262 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -156,6 +156,11 @@ public struct MainFeature { .send(.sidebar(.countsRefreshRequested)) ) + // 자식끼리 직접 연결하지 않고 공통 부모가 사이드바 재조회를 지시한다 (TCA_GUIDELINES 7.4). + // 아래 `.secretDetail` 캐치올보다 **앞에** 둬야 한다 — 뒤에 두면 그쪽이 먼저 잡아 무시된다. + case .secretDetail(.delegate(.projectsChanged)): + return .send(.sidebar(.refresh)) + case .secretDetail: return .none @@ -175,6 +180,10 @@ public struct MainFeature { .send(.sidebar(.countsRefreshRequested)) ) + // 자식끼리 직접 연결하지 않고 공통 부모가 사이드바 재조회를 지시한다 (TCA_GUIDELINES 7.4). + case .createSecret(.delegate(.projectsChanged)): + return .send(.sidebar(.refresh)) + case .createSecret(.delegate(.cancelled)): state.createSecret = nil state.selectSecretType = .init() @@ -189,7 +198,7 @@ public struct MainFeature { state.sidebar.selection = .project(id: item.id) state.secretList = .init(collection: .project(id: item.id), projectName: item.name) } - return .send(.sidebar(.task)) + return .send(.sidebar(.refresh)) case .createProject: return .none diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index c06e756c..0bffeff3 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -83,6 +83,10 @@ public struct SidebarFeature { // MARK: - View case task + /// 이미 불러온 목록을 다시 읽는다. `task`와 달리 `.loading`으로 되돌리지 않아 + /// 화면에서 목록이 사라졌다 나타나지 않는다 — 프로젝트 추가·이름 변경처럼 이미 보고 있는 + /// 목록이 조금 달라지는 경우에 쓴다. + case refresh case didSelect(SidebarSelection) case didTapAddButton case didTapAddProject @@ -148,19 +152,13 @@ public struct SidebarFeature { Reduce { state, action in switch action { case .task: + // 최초 진입에만 스피너를 보인다 — 보여줄 이전 값이 정말로 없는 유일한 경우다. state.projectsState = .loading state.countsState = .loading - return .run { send in - do { - let projects = try await sidebarClient.fetchProjects() - await send(.projectsResponse(.success(projects))) - } catch let error as SidebarError { - await send(.projectsResponse(.failure(error))) - } catch { - await send(.projectsResponse(.failure(.fetchFailed))) - } - } - .cancellable(id: CancelID.fetch, cancelInFlight: true) + return fetchProjectsEffect() + + case .refresh: + return fetchProjectsEffect() case .projectsResponse(.success(let projects)): let sorted = projects.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } @@ -173,8 +171,10 @@ public struct SidebarFeature { // 프로젝트 목록이 실패해도 필터 카드 개수는 독립적으로 유효하므로 집계는 계속 시도한다. return countsEffect(projectIDs: []) + // `.loading`으로 되돌리지 않는다. 되돌리면 `counts`가 nil이 되어 모든 행의 숫자가 + // 사라졌다 다시 나타난다 — 시크릿을 하나 만들 때마다 사이드바 전체가 깜빡였다. + // 이전 값을 그대로 두고 새 값이 오면 숫자만 바뀐다. case .countsRefreshRequested: - state.countsState = .loading return countsEffect(projectIDs: state.projects.map(\.id)) case .countsResponse(.success(let counts)): @@ -250,7 +250,7 @@ public struct SidebarFeature { case .renameResponse(.success(let updated)): return .concatenate( .send(.delegate(.projectRenamed(updated))), - .send(.task) + .send(.refresh) ) case .renameResponse(.failure(.nameTaken)): @@ -289,10 +289,10 @@ public struct SidebarFeature { state.selection = .filter(.all) return .concatenate( .send(.delegate(.selectionChanged(.filter(.all)))), - .send(.task) + .send(.refresh) ) } - return .send(.task) + return .send(.refresh) case .deleteResponse(.failure): state.alert = makeDeleteFailedAlert() @@ -310,6 +310,22 @@ public struct SidebarFeature { private extension SidebarFeature { + /// 프로젝트 목록 조회. `task`(최초)와 `refresh`(재조회)가 공유하며, 다른 것은 호출 전에 + /// `.loading`으로 되돌리는지 여부뿐이다. + func fetchProjectsEffect() -> Effect { + .run { send in + do { + let projects = try await sidebarClient.fetchProjects() + await send(.projectsResponse(.success(projects))) + } catch let error as SidebarError { + await send(.projectsResponse(.failure(error))) + } catch { + await send(.projectsResponse(.failure(.fetchFailed))) + } + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + } + /// 필터·프로젝트 개수 집계. 생성/삭제가 연달아 일어나면 직전 집계는 취소한다 (E3). func countsEffect(projectIDs: [ProjectItem.ID]) -> Effect { .run { [now] send in diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index b902ff0c..2ddd4147 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -169,6 +169,9 @@ extension SidebarView { } } .listStyle(.sidebar) + // 프로젝트가 추가·삭제·이름 변경될 때 행이 뚝 나타나지 않게 한다. `refresh` 경로가 목록을 + // 비우지 않으므로 실제로 바뀐 행만 움직인다. + .animation(.smooth(duration: 0.25), value: store.projects) .scrollContentBackground(.hidden) .padding(.horizontal, -12) .onKeyPress(.return) { diff --git a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift index 26b7c542..a3948693 100644 --- a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift @@ -381,6 +381,7 @@ struct CreateSecretFeatureTests { Project(id: newItem.id, name: newItem.name, createdAt: fixedDate, updatedAt: fixedDate) ] } + await store.receive(.delegate(.projectsChanged)) } // MARK: - isSaveEnabled diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index d7a860cc..00027029 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -35,9 +35,7 @@ struct MainFeatureTests { } await store.send(.secretList(.delegate(.secretsChanged))) - await store.receive(.sidebar(.countsRefreshRequested)) { - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.sidebar(.countsResponse(.success(counts)))) { $0.sidebar.countsState = .loaded(counts) } @@ -176,9 +174,7 @@ struct MainFeatureTests { await store.receive(.sidebar(.setCreatingSecret(false))) { $0.sidebar.isCreatingSecret = false } - await store.receive(.sidebar(.countsRefreshRequested)) { - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.sidebar(.countsResponse(.success(counts)))) { $0.sidebar.countsState = .loaded(counts) } @@ -238,10 +234,7 @@ struct MainFeatureTests { projectName: "Backend" ) } - await store.receive(.sidebar(.task)) { - $0.sidebar.projectsState = .loading - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.refresh)) await store.receive(.sidebar(.projectsResponse(.success([item])))) { $0.sidebar.projectsState = .loaded([item]) } @@ -270,10 +263,7 @@ struct MainFeatureTests { $0.createProject = nil // selection 및 secretList 변경 없음 } - await store.receive(.sidebar(.task)) { - $0.sidebar.projectsState = .loading - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.refresh)) await store.receive(.sidebar(.projectsResponse(.success([item])))) { $0.sidebar.projectsState = .loaded([item]) } @@ -427,6 +417,68 @@ struct MainFeatureTests { } } + @Test("수정 화면에서 만든 프로젝트는 사이드바에 즉시 반영된다") + func projectsChangedFromDetailRefreshesSidebar() async { + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + let created = [ProjectItem(id: UUID(), name: "새 프로젝트")] + + var initial = MainFeature.State() + initial.secretDetail = SecretDetailFeature.State(secret: secret) + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { created } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.secretDetail(.delegate(.projectsChanged))) + await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.projectsResponse(.success(created)))) { + $0.sidebar.projectsState = .loaded(IdentifiedArray(uniqueElements: created)) + } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } + // 편집 중인 화면은 유지된다 — 프로젝트를 만들었다고 폼이 닫히면 안 된다. + #expect(store.state.secretDetail != nil) + } + + @Test("생성 화면에서 만든 프로젝트도 사이드바에 즉시 반영된다") + func projectsChangedFromCreateRefreshesSidebar() async { + let created = [ProjectItem(id: UUID(), name: "새 프로젝트")] + + var initial = MainFeature.State() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { created } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.createSecret(.delegate(.projectsChanged))) + await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.projectsResponse(.success(created)))) { + $0.sidebar.projectsState = .loaded(IdentifiedArray(uniqueElements: created)) + } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } + // 생성 폼은 유지된다 — 프로젝트만 만들고 시크릿 작성은 이어가야 한다. + #expect(store.state.createSecret != nil) + } + @Test("secretUpdated delegate는 목록을 재조회한다") func secretUpdatedRefreshesList() async { let secret = Secret( @@ -455,9 +507,7 @@ struct MainFeatureTests { // refresh는 .loading으로 바꾸지 않는다 — 목록이 깜빡이지 않아야 한다. await store.receive(.secretList(.refresh)) // 즐겨찾기는 Starred 개수를 바꾸므로 사이드바 개수도 갱신되어야 한다. - await store.receive(.sidebar(.countsRefreshRequested)) { - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.secretList(.secretsResponse(.success([secret])))) { $0.secretList.secretsState = .loaded(IdentifiedArray(uniqueElements: [secret])) } @@ -497,9 +547,7 @@ struct MainFeatureTests { } await store.receive(.secretList(.refresh)) // 삭제는 All·Deleted 개수를 바꾸므로 사이드바 개수도 갱신되어야 한다. - await store.receive(.sidebar(.countsRefreshRequested)) { - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.secretList(.secretsResponse(.success([])))) { $0.secretList.secretsState = .loaded([]) } @@ -540,10 +588,7 @@ struct MainFeatureTests { ) } // 이후 refetch로 목록도 동기화 - await store.receive(.sidebar(.task)) { - $0.sidebar.projectsState = .loading - $0.sidebar.countsState = .loading - } + await store.receive(.sidebar(.refresh)) await store.receive(.sidebar(.projectsResponse(.success([renamed])))) { $0.sidebar.projectsState = .loaded([renamed]) } diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index 2ae78c6d..af6f1b56 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -97,9 +97,7 @@ struct SidebarFeatureTests { $0.date = .constant(Self.referenceDate) } - await store.send(.countsRefreshRequested) { - $0.countsState = .loading - } + await store.send(.countsRefreshRequested) await store.receive(.countsResponse(.success(counts))) { $0.countsState = .loaded(counts) #expect($0.counts?.count(for: .all) == 7) @@ -116,9 +114,7 @@ struct SidebarFeatureTests { $0.date = .constant(Self.referenceDate) } - await store.send(.countsRefreshRequested) { - $0.countsState = .loading - } + await store.send(.countsRefreshRequested) await store.receive(.countsResponse(.failure(.fetchFailed))) { $0.countsState = .failed(.fetchFailed) #expect($0.counts == nil) @@ -212,10 +208,7 @@ struct SidebarFeatureTests { } await store.receive(.renameResponse(.success(renamed))) await store.receive(.delegate(.projectRenamed(renamed))) - await store.receive(.task) { - $0.projectsState = .loading - $0.countsState = .loading - } + await store.receive(.refresh) await store.receive(.projectsResponse(.success([renamed]))) { $0.projectsState = .loaded([renamed]) } @@ -328,10 +321,7 @@ struct SidebarFeatureTests { $0.selection = .filter(.all) } await store.receive(.delegate(.selectionChanged(.filter(.all)))) - await store.receive(.task) { - $0.projectsState = .loading - $0.countsState = .loading - } + await store.receive(.refresh) await store.receive(.projectsResponse(.success([]))) { $0.projectsState = .loaded([]) } From 28bb118225940c2df88ae74072492767ba8603de Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:37:40 +0900 Subject: [PATCH 19/61] =?UTF-8?q?[#76]=20fix:=20=ED=8C=9D=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EC=97=90=EC=84=9C=20=EC=97=B0=EC=86=8D=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=EC=9D=98=20=EC=B2=B4=ED=81=AC=EA=B0=80=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 팝오버 본문이 선택 상태를 직접 소유하고 바깥에는 콜백으로 알리도록 뒤집음 - 별도 윈도우로 호스팅된 서브트리에 부모의 상태 변경이 도달하지 않아 방금 누른 행만 갱신되고 이전에 누른 행들은 옛 체크 상태로 남았다 - @Binding·값 전달 모두 같은 증상이라 전달 방식이 아닌 소유권 문제로 확인 - 섹션 스냅샷도 팝오버가 열릴 때 직접 잡으므로 부모의 openSessionSnapshot 제거 - 항목이 늘거나 줄면 .id로 팝오버를 다시 시드해 "+ Add new" 직후에도 반영 - 공개 API 변화 없음 --- .../Components/DVMultiSelectDropdown.swift | 117 +++++++++++------- 1 file changed, 73 insertions(+), 44 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift index fa062b1a..6b017bb1 100644 --- a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift +++ b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift @@ -93,10 +93,6 @@ public struct DVMultiSelectDropdown: View { @State private var isOpen: Bool = false - /// 팝오버가 열린 순간의 selection 스냅샷. 세션 동안 섹션 배치의 기준으로만 - /// 사용되며, 세션 중 새 선택/해제로는 재정렬되지 않는다. 닫혔다 다시 열면 재-스냅샷. - @State private var openSessionSnapshot: Set = [] - // MARK: - Init /// 다중 선택 드롭다운을 생성합니다. @@ -157,14 +153,11 @@ public struct DVMultiSelectDropdown: View { triggerContent } .buttonStyle(.plain) - .onChange(of: isOpen) { _, open in - if open { openSessionSnapshot = selection } - } .popover(isPresented: $isOpen, arrowEdge: .bottom) { PopoverContentView( items: items, - selection: $selection, - groupingSnapshot: openSessionSnapshot, + initialSelection: selection, + onSelectionChange: { selection = $0 }, label: label, width: size.width, searchText: searchText, @@ -173,8 +166,11 @@ public struct DVMultiSelectDropdown: View { createLabel: createLabel, emptyMessage: emptyMessage, noResultsMessage: noResultsMessage, - useSectioning: useSectioning + groupsSelectedAtTop: groupsSelectedAtTop ) + // 항목이 추가·삭제되면(예: "+ Add new"로 만든 프로젝트) 팝오버를 새 정체성으로 다시 만들어 + // 아래 `@State`가 현재 selection으로 다시 시드되게 한다. + .id(items.map(\.id)) } } } @@ -237,23 +233,19 @@ private extension DVMultiSelectDropdown { var selectedItems: [Item] { items.filter { selection.contains($0.id) } } - - /// 섹션 분리 조건: 명시 활성 or (open 시점 스냅샷) 선택 2개+ && 전체 4개+. - /// 세션 도중 selection이 바뀌어도 재-분리되지 않도록 스냅샷 기준으로 판정. - var useSectioning: Bool { - if groupsSelectedAtTop { return true } - return openSessionSnapshot.count >= 2 && items.count >= 4 - } } // MARK: - PopoverContent +/// 팝오버 본문. **선택 상태를 직접 소유하고** 바깥에는 `onSelectionChange`로 알린다. +/// +/// 부모가 소유하면 항목을 연속으로 누를 때 **방금 누른 행만 갱신되고 이전에 누른 행들은 옛 체크 +/// 상태로 남는다.** 팝오버가 별도 윈도우에 호스팅되어 부모의 상태 변경이 이 서브트리까지 내려오지 +/// 않기 때문이며, `@Binding`으로 넘기든 값 + 콜백으로 넘기든 똑같이 재현된다. 전달 방식이 아니라 +/// 갱신 도달 여부의 문제라, 소유권을 안으로 옮겨 갱신이 이 서브트리 안에서 끝나게 해야 한다. private struct PopoverContentView: View { let items: [Item] - @Binding var selection: Set - /// open 시점의 selection 스냅샷 — 섹션 배치 기준 (checkbox 상태는 여전히 live `selection`). - let groupingSnapshot: Set let label: (Item) -> String let width: CGFloat @@ -266,7 +258,42 @@ private struct PopoverContentView: View { let emptyMessage: String let noResultsMessage: ((String) -> String)? - let useSectioning: Bool + let groupsSelectedAtTop: Bool + let onSelectionChange: (Set) -> Void + + @State private var selection: Set + + /// 섹션 배치 기준 — 열린 순간의 선택으로 고정되어 세션 중 재정렬되지 않는다. + @State private var groupingSnapshot: Set + + init( + items: [Item], + initialSelection: Set, + onSelectionChange: @escaping (Set) -> Void, + label: @escaping (Item) -> String, + width: CGFloat, + searchText: Binding?, + searchPlaceholder: String, + onCreate: (() -> Void)?, + createLabel: String, + emptyMessage: String, + noResultsMessage: ((String) -> String)?, + groupsSelectedAtTop: Bool + ) { + self.items = items + self.onSelectionChange = onSelectionChange + self.label = label + self.width = width + self.searchText = searchText + self.searchPlaceholder = searchPlaceholder + self.onCreate = onCreate + self.createLabel = createLabel + self.emptyMessage = emptyMessage + self.noResultsMessage = noResultsMessage + self.groupsSelectedAtTop = groupsSelectedAtTop + _selection = State(initialValue: initialSelection) + _groupingSnapshot = State(initialValue: initialSelection) + } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -280,7 +307,8 @@ private struct PopoverContentView: View { } else if useSectioning { SectionedListView( items: items, - selection: $selection, + selection: selection, + onToggle: toggle(_:), groupingSnapshot: groupingSnapshot, label: label, query: searchText?.wrappedValue ?? "" @@ -288,7 +316,8 @@ private struct PopoverContentView: View { } else { FlatListView( items: items, - selection: $selection, + selection: selection, + onToggle: toggle(_:), label: label, query: searchText?.wrappedValue ?? "" ) @@ -300,6 +329,20 @@ private struct PopoverContentView: View { } } .frame(width: width) + .onChange(of: selection) { _, new in onSelectionChange(new) } + } + + private func toggle(_ id: Item.ID) { + if selection.contains(id) { + selection.remove(id) + } else { + selection.insert(id) + } + } + + private var useSectioning: Bool { + if groupsSelectedAtTop { return true } + return groupingSnapshot.count >= 2 && items.count >= 4 } /// 빈 상태 문구: 검색 중이면 no-results, 아니면 empty. @@ -338,7 +381,8 @@ private struct SearchHeaderView: View { private struct FlatListView: View { let items: [Item] - @Binding var selection: Set + let selection: Set + let onToggle: (Item.ID) -> Void let label: (Item) -> String let query: String @@ -351,7 +395,7 @@ private struct FlatListView: View { isSelected: selection.contains(item.id), query: query ) { - toggle(item.id) + onToggle(item.id) } } } @@ -359,19 +403,12 @@ private struct FlatListView: View { } .frame(maxHeight: DropdownMetrics.listMaxHeight) } - - private func toggle(_ id: Item.ID) { - if selection.contains(id) { - selection.remove(id) - } else { - selection.insert(id) - } - } } private struct SectionedListView: View { let items: [Item] - @Binding var selection: Set + let selection: Set + let onToggle: (Item.ID) -> Void /// 섹션 배치는 이 스냅샷 기준으로 고정 — 세션 중 selection 변경으로 재정렬되지 않음. let groupingSnapshot: Set let label: (Item) -> String @@ -388,7 +425,7 @@ private struct SectionedListView: View { isSelected: selection.contains(item.id), query: query ) { - toggle(item.id) + onToggle(item.id) } } } @@ -400,7 +437,7 @@ private struct SectionedListView: View { isSelected: selection.contains(item.id), query: query ) { - toggle(item.id) + onToggle(item.id) } } } @@ -416,14 +453,6 @@ private struct SectionedListView: View { private var snapshotUnselected: [Item] { items.filter { !groupingSnapshot.contains($0.id) } } - - private func toggle(_ id: Item.ID) { - if selection.contains(id) { - selection.remove(id) - } else { - selection.insert(id) - } - } } // MARK: - Row / Section header From 7db35383e789bbc7de2365bba209dfcc6a77498f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:38:06 +0900 Subject: [PATCH 20/61] =?UTF-8?q?[#76]=20test:=20=EB=8B=A4=EC=A4=91=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20?= =?UTF-8?q?=EC=97=B0=EC=86=8D=20=EC=84=A0=ED=83=9D=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 샘플앱에 DVMultiSelectDropdown 항목 추가 - TCA·Array↔Set 어댑터 없이 순수 @State만 써서 컴포넌트 자체를 격리 - 팝오버 밖에 선택 상태를 함께 표시해 렌더 어긋남과 쓰기 유실을 구분 --- .../SampleApp/Sources/ContentView.swift | 3 + .../DVMultiSelectDropdownPreviewView.swift | 96 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 Projects/DVDesign/SampleApp/Sources/DVMultiSelectDropdownPreviewView.swift diff --git a/Projects/DVDesign/SampleApp/Sources/ContentView.swift b/Projects/DVDesign/SampleApp/Sources/ContentView.swift index 9371f620..1f8928c2 100644 --- a/Projects/DVDesign/SampleApp/Sources/ContentView.swift +++ b/Projects/DVDesign/SampleApp/Sources/ContentView.swift @@ -70,6 +70,8 @@ struct ContentView: View { DVLabeledFieldPreviewView() case "DVChipsField": DVChipsFieldPreviewView() + case "DVMultiSelectDropdown": + DVMultiSelectDropdownPreviewView() default: ComponentPlaceholderView(name: component.name) } @@ -107,6 +109,7 @@ private struct Component: Hashable { Component(name: "DVCategory"), Component(name: "DVButton"), Component(name: "DVCheckBox"), + Component(name: "DVMultiSelectDropdown"), Component(name: "DVTitleBar"), Component(name: "DVSecretType"), Component(name: "DVRadioButton"), diff --git a/Projects/DVDesign/SampleApp/Sources/DVMultiSelectDropdownPreviewView.swift b/Projects/DVDesign/SampleApp/Sources/DVMultiSelectDropdownPreviewView.swift new file mode 100644 index 00000000..7ac8f298 --- /dev/null +++ b/Projects/DVDesign/SampleApp/Sources/DVMultiSelectDropdownPreviewView.swift @@ -0,0 +1,96 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +import DVDesign + +/// `DVMultiSelectDropdown`의 **연속 선택** 회귀 화면. +/// +/// 여러 항목을 잇따라 누를 때 팝오버의 체크 상태가 매번 따라오는지 본다. 한때 방금 누른 행만 +/// 갱신되고 이전에 누른 행들은 옛 상태로 남았고, 몇 번은 멀쩡해서 눈으로는 판정이 어려웠다. +/// +/// TCA도 Array↔Set 어댑터도 없이 순수 `@State`만 쓰므로, 여기서 어긋나면 원인은 컴포넌트 안에 있다. +/// 나머지 설정은 `ProjectFieldView`와 맞춘다 — `groupsSelectedAtTop: true`, 검색 없음, add-new 있음. +struct DVMultiSelectDropdownPreviewView: View { + + private struct Item: Identifiable, Hashable { + let id = UUID() + let name: String + } + + private let items: [Item] = [ + .init(name: "DeVault"), + .init(name: "DrinkiG"), + .init(name: "CheerLot"), + .init(name: "SipStream"), + .init(name: "Example"), + ] + + @State private var selection: Set = [] + @State private var log: [String] = [] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + header + + DVMultiSelectDropdown( + "Select Project", + items: items, + selection: $selection, + label: \.name, + size: .md, + onCreate: { log.insert("+ Add new 눌림", at: 0) }, + createLabel: "Add new project", + emptyMessage: "No projects yet", + groupsSelectedAtTop: true + ) + + stateReadout + Spacer(minLength: 0) + } + .padding(40) + .frame(maxWidth: .infinity, alignment: .leading) + } + .onAppear { + // 열기 전에 두 개를 선택해 둬야 Selected / All 두 섹션이 모두 생긴다. + if selection.isEmpty { + selection = Set(items.prefix(2).map(\.id)) + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + Text("연속 선택 재현") + .font(.title2).fontWeight(.semibold) + Text("팝오버를 열고 여러 항목을 **연속으로** 눌러보세요. 아래 상태가 누를 때마다 즉시 " + + "따라오면 컴포넌트는 정상입니다.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + /// 팝오버 밖에서 본 실제 상태. 팝오버 안 체크와 여기가 어긋나면 **렌더 문제**, + /// 둘 다 틀리면 **쓰기 유실**이다. + private var stateReadout: some View { + VStack(alignment: .leading, spacing: 8) { + Text("selection (\(selection.count)개)") + .font(.headline) + ForEach(items) { item in + HStack(spacing: 8) { + Image(systemName: selection.contains(item.id) ? "checkmark.square.fill" : "square") + .foregroundStyle(selection.contains(item.id) ? .green : .secondary) + Text(item.name) + } + .font(.system(.body, design: .monospaced)) + } + if !log.isEmpty { + Divider() + ForEach(log, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) } + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08))) + } +} From 8c64979153f4ce538ec9b14462f038f9c912b8df Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:57:25 +0900 Subject: [PATCH 21/61] =?UTF-8?q?[#76]=20fix:=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=97=AC=EB=9F=AC=20=EC=A4=84=20=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=20=EA=B8=80=EC=9E=90=20=EB=8B=A8=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=91=ED=9E=88=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증서·키·JSON처럼 단어 경계가 없는 값이 단어 단위로 접혀 오른쪽에 큰 여백이 남고 상자가 실제보다 좁아 보이던 문제 - SwiftUI Text에는 wrap 모드 API가 없어 본문만 AppKit 텍스트로 그림 - 높이는 실제로 그리는 레이아웃 매니저에게 물어 측정과 렌더가 어긋나지 않게 함 - DVColor.nsColor / DVFont.nsFont 추가. 색 이름은 assetName으로 뽑아 SwiftUI 쪽과 공유하고, 굵기는 기존 weight에서 옮겨와 두 벌로 갈리지 않게 함 --- .../Sources/Components/DVTextContainer.swift | 99 +++++++++++++++++-- .../Sources/Foundations/Color/DVColor.swift | 49 +++++---- .../Sources/Foundations/Fonts/DVFont.swift | 20 ++++ 3 files changed, 142 insertions(+), 26 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVTextContainer.swift b/Projects/DVDesign/Sources/Components/DVTextContainer.swift index 8cbf7c64..fe418536 100644 --- a/Projects/DVDesign/Sources/Components/DVTextContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVTextContainer.swift @@ -24,6 +24,10 @@ import SwiftUI /// 본문이 폭을 넘으면 잘리거나 가로 스크롤되지 않고 **다음 줄로 접힙니다**. 개행이 있는 값 /// (PEM, JSON, `KEY=value` 목록)도 줄 구조 그대로 전부 보입니다. /// +/// 접히는 단위는 단어가 아니라 **글자**입니다. 이 컨테이너가 다루는 값은 인증서·키·JSON·명령어처럼 +/// 단어 경계가 의미 없는 기계값이라, 단어 단위로 접으면 오른쪽에 큰 여백이 남아 상자가 실제보다 +/// 좁아 보입니다. 공백이 의미를 갖는 값도 단어 중간에서 접히는 것이 이 선택의 대가입니다. +/// /// 읽기 전용 표시라 값을 확인할 다른 수단이 없으므로, 스크롤 조작을 요구하는 대신 다 펼쳐 보여줍니다. /// 그래서 ``DVTextField``(입력)의 가로 시프트 동작과는 다릅니다 — 입력은 커서를 따라가야 하지만 /// 표시는 전체를 한눈에 보여주는 쪽이 맞습니다. @@ -33,7 +37,7 @@ import SwiftUI /// /// ## 텍스트 선택·복사 /// -/// 본문에 `.textSelection(.enabled)`이 적용되어 있어, 사용자가 마우스 드래그 또는 ⌘+A로 텍스트를 선택하고 ⌘+C로 클립보드에 복사할 수 있습니다. +/// 본문을 마우스로 드래그해 선택하고 ⌘+C로 클립보드에 복사할 수 있습니다. /// ``init(copyable:size:onCopy:)`` 편의 init과 별도로 동작하므로 둘 다 함께 활용 가능합니다. /// /// ## 액세서리 @@ -153,12 +157,7 @@ public struct DVTextContainer: View { // leading 아이콘은 본문과 한 덩어리로 읽히도록 액세서리보다 좁은 6pt로 붙인다. HStack(alignment: .top, spacing: 6) { leadingIconView - Text(text) - .font(DVFont.bodyLG.font) - .foregroundStyle(Color.dv(textColor)) - .lineSpacing(DVFont.bodyLG.lineSpacing) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) + CharacterWrappingText(text, textColor: textColor) .frame(maxWidth: .infinity, alignment: .leading) } accessories() @@ -341,6 +340,76 @@ extension DVTextContainer where Accessories == AnyView { } } +// MARK: - Character wrapping + +/// 본문을 **글자 단위로** 접어 그리는 텍스트. 선택·복사는 그대로 된다. +/// +/// AppKit으로 내려오는 이유는 SwiftUI `Text`가 단어 단위로만 접히고 wrap 모드를 여는 API가 없기 +/// 때문이다. 글자 단위로 접는 이유 자체는 ``DVTextContainer``의 "높이와 줄바꿈"에 있다. +/// +/// `NSTextField`가 아니라 `NSTextView`를 쓰는 이유는 **높이를 정확히 재기 위해서**다. 컨테이너 +/// 인셋과 line fragment padding을 0으로 두고 실제로 그리는 레이아웃 매니저에게 높이를 물으므로, +/// 측정값과 그리는 폭이 어긋나 마지막 글자가 한 줄 더 밀리는 일이 없다. +private struct CharacterWrappingText: NSViewRepresentable { + + private let text: String + private let textColor: DVColor + + init(_ text: String, textColor: DVColor) { + self.text = text + self.textColor = textColor + } + + private static let font = DVFont.bodyLG + + private var attributed: NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.lineBreakMode = .byCharWrapping + paragraph.lineSpacing = Self.font.lineSpacing + return NSAttributedString( + string: text, + attributes: [ + .font: Self.font.nsFont, + .foregroundColor: textColor.nsColor, + .paragraphStyle: paragraph, + ] + ) + } + + func makeNSView(context: Context) -> NSTextView { + let view = NSTextView() + view.isEditable = false + view.isSelectable = true + view.drawsBackground = false + view.isRichText = false + // 인셋을 0으로 둬야 측정한 높이와 그리는 높이가 같다. + view.textContainerInset = .zero + view.textContainer?.lineFragmentPadding = 0 + view.textContainer?.widthTracksTextView = true + view.isHorizontallyResizable = false + view.isVerticallyResizable = true + return view + } + + func updateNSView(_ view: NSTextView, context: Context) { + view.textStorage?.setAttributedString(attributed) + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? { + guard let width = proposal.width, width.isFinite, width > 0 else { return nil } + // 측정 전에 내용을 맞춰둔다 — `updateNSView`보다 먼저 불릴 수 있다. + nsView.textStorage?.setAttributedString(attributed) + guard let container = nsView.textContainer, let layout = nsView.layoutManager else { + return nil + } + container.containerSize = CGSize(width: width, height: .greatestFiniteMagnitude) + layout.ensureLayout(for: container) + // 빈 값이어도 한 줄 높이는 차지해야 상자 높이가 값 유무에 따라 튀지 않는다. + let height = max(layout.usedRect(for: container).height, Self.font.lineHeight) + return CGSize(width: width, height: ceil(height)) + } +} + // MARK: - Previews #if DEBUG @@ -388,6 +457,22 @@ extension DVTextContainer where Accessories == AnyView { .padding() } +#Preview("Character wrapping") { + VStack(alignment: .leading, spacing: 12) { + DVTextContainer( + "Server=db.internal.example.com; Port=5432; Database=vault_prod; " + + "User Id=svc_reader; Timeout=30", + size: .md + ) + DVTextContainer( + "-----BEGIN PRIVATE KEY-----MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcw" + + "ggSjAgEAAoIBAQC7VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dV", + size: .md + ) + } + .padding() +} + #Preview("Sizes") { VStack(alignment: .leading, spacing: 12) { DVTextContainer("XS", size: .xs) diff --git a/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift b/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift index 2ec0f65a..ad65bc1c 100644 --- a/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift +++ b/Projects/DVDesign/Sources/Foundations/Color/DVColor.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import AppKit import SwiftUI public enum DVColor: CaseIterable { @@ -22,26 +23,36 @@ public enum DVColor: CaseIterable { case warning case required - public var color: Color { + /// Asset catalog에 등록된 이름. `color`와 `nsColor`가 공유해 두 벌로 갈리지 않게 한다. + private var assetName: String { switch self { - case .vaultGreen: return Color("vault green", bundle: .module) - case .vaultGreenDark: return Color("vault green dark", bundle: .module) - case .vaultGreenTint: return Color("vault green tint", bundle: .module) - case .vaultDark: return Color("vault dark", bundle: .module) - case .gray900: return Color("gray900", bundle: .module) - case .gray800: return Color("gray800", bundle: .module) - case .gray700: return Color("gray700", bundle: .module) - case .gray600: return Color("gray600", bundle: .module) - case .gray500: return Color("gray500", bundle: .module) - case .gray400: return Color("gray400", bundle: .module) - case .gray300: return Color("gray300", bundle: .module) - case .gray200: return Color("gray200", bundle: .module) - case .gray100: return Color("gray100", bundle: .module) - case .white: return Color("white", bundle: .module) - case .black: return Color("black", bundle: .module) - case .danger: return Color("danger", bundle: .module) - case .warning: return Color("warning", bundle: .module) - case .required: return Color("required", bundle: .module) + case .vaultGreen: return "vault green" + case .vaultGreenDark: return "vault green dark" + case .vaultGreenTint: return "vault green tint" + case .vaultDark: return "vault dark" + case .gray900: return "gray900" + case .gray800: return "gray800" + case .gray700: return "gray700" + case .gray600: return "gray600" + case .gray500: return "gray500" + case .gray400: return "gray400" + case .gray300: return "gray300" + case .gray200: return "gray200" + case .gray100: return "gray100" + case .white: return "white" + case .black: return "black" + case .danger: return "danger" + case .warning: return "warning" + case .required: return "required" } } + + public var color: Color { + Color(assetName, bundle: .module) + } + + /// `NSView`를 직접 쓰는 자리에 넘길 색. `color`와 같은 asset을 가리킨다. + public var nsColor: NSColor { + NSColor(named: assetName, bundle: .module) ?? .labelColor + } } diff --git a/Projects/DVDesign/Sources/Foundations/Fonts/DVFont.swift b/Projects/DVDesign/Sources/Foundations/Fonts/DVFont.swift index 2b8b5cdf..6307b6d2 100644 --- a/Projects/DVDesign/Sources/Foundations/Fonts/DVFont.swift +++ b/Projects/DVDesign/Sources/Foundations/Fonts/DVFont.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import AppKit import SwiftUI public enum DVFont: CaseIterable { @@ -71,4 +72,23 @@ public enum DVFont: CaseIterable { public var font: Font { .system(size: size, weight: weight) } + + /// `NSView`를 직접 쓰는 자리에 넘길 폰트. 굵기는 ``weight``에서 옮겨오므로 갈리지 않는다. + public var nsFont: NSFont { + .systemFont(ofSize: size, weight: Self.nsWeight(weight)) + } + + private static func nsWeight(_ weight: Font.Weight) -> NSFont.Weight { + switch weight { + case .ultraLight: return .ultraLight + case .thin: return .thin + case .light: return .light + case .medium: return .medium + case .semibold: return .semibold + case .bold: return .bold + case .heavy: return .heavy + case .black: return .black + default: return .regular + } + } } From 23f035e36c88f6e3b69dcdc2494690693a6b25fd Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:00:30 +0900 Subject: [PATCH 22/61] =?UTF-8?q?[#76]=20fix:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=B2=BD=EB=A1=9C=EC=9D=98=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=86=90=EC=8B=A4=C2=B7=EC=9E=A0=EA=B8=88?= =?UTF-8?q?=20=EA=B5=AC=EB=A9=8D=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연결된 프로젝트를 못 읽은 채 수정에 들어가면 저장할 때 실제 연결이 조용히 끊겼다. linkedProjects를 LoadingState로 바꿔 "연결 없음"과 "아직 모름"을 구분하고, 읽기 전에는 수정 진입을 막는다. 그냥 넘어갈 수 없는 실패이므로 alert에 재시도를 붙였다. - 편집 화면은 잠금 정책 밖에 있어 인증 창이 닫힌 뒤에도 저장이 나갔다. 입력을 지우지 않는 대신 창이 닫혀 있으면 저장 직전에 다시 인증받고, 인증 취소는 쓰기 실패와 다른 문구로 알린다 — 사용자가 할 일이 다르다. - 눈·복사가 진행 중인 수정 진입 복호화를 취소해서, 인증 시트가 뜬 사이의 클릭 한 번에 누른 적 있는 수정이 사라졌다. 취소 그룹을 눈·복사와 수정 진입으로 나눠 각자 안에서만 대체되게 했다. 눈·복사끼리 대체하는 기존 규칙은 그대로다. - 라디오 그룹이 가로 간격을 세로에도 써서 접힌 줄이 .md에서 56pt 벌어졌다. 줄바꿈은 밀도가 아니라 줄 리듬이므로 고정 8pt로 분리한다. --- .../Components/DVMultiSelectDropdown.swift | 2 + .../Sources/Components/DVTextContainer.swift | 11 +- .../RadioButton/DVRadioButtonGroup.swift | 8 +- .../Resources/Localizable.xcstrings | 9 + .../Sources/Dependencies/SecretClient.swift | 5 +- .../Components/SecretDetailHeaderView.swift | 6 +- .../Model/SecretDetailError.swift | 36 +++- .../SecretDetail/SecretDetailFeature.swift | 163 +++++++++++---- .../SecretDetail/SecretDetailView.swift | 37 ++-- .../Sources/Form/FormLayout.swift | 17 +- .../SecretDetailFeatureTests.swift | 187 ++++++++++++++++-- 11 files changed, 402 insertions(+), 79 deletions(-) diff --git a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift index 6b017bb1..0b434116 100644 --- a/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift +++ b/Projects/DVDesign/Sources/Components/DVMultiSelectDropdown.swift @@ -159,6 +159,8 @@ public struct DVMultiSelectDropdown: View { initialSelection: selection, onSelectionChange: { selection = $0 }, label: label, + // 트리거는 `.fill`에서 컨테이너를 따라 늘어나지만 팝오버는 토큰 폭 고정이다. + // 넓혀 봐야 라벨 오른쪽 여백만 늘고, 컬럼 폭에 따라 같은 목록이 달라 보인다. width: size.width, searchText: searchText, searchPlaceholder: searchPlaceholder, diff --git a/Projects/DVDesign/Sources/Components/DVTextContainer.swift b/Projects/DVDesign/Sources/Components/DVTextContainer.swift index fe418536..4e849cc4 100644 --- a/Projects/DVDesign/Sources/Components/DVTextContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVTextContainer.swift @@ -392,13 +392,20 @@ private struct CharacterWrappingText: NSViewRepresentable { } func updateNSView(_ view: NSTextView, context: Context) { - view.textStorage?.setAttributedString(attributed) + Self.syncIfNeeded(view, to: attributed) + } + + /// 내용이 그대로면 손대지 않는다. `setAttributedString`은 text storage를 통째로 갈아치워 + /// **드래그해 둔 선택을 지우므로**, 무관한 갱신 하나에 ⌘C 직전의 선택이 풀린다. + private static func syncIfNeeded(_ view: NSTextView, to next: NSAttributedString) { + guard let storage = view.textStorage, !storage.isEqual(to: next) else { return } + storage.setAttributedString(next) } func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSTextView, context: Context) -> CGSize? { guard let width = proposal.width, width.isFinite, width > 0 else { return nil } // 측정 전에 내용을 맞춰둔다 — `updateNSView`보다 먼저 불릴 수 있다. - nsView.textStorage?.setAttributedString(attributed) + Self.syncIfNeeded(nsView, to: attributed) guard let container = nsView.textContainer, let layout = nsView.layoutManager else { return nil } diff --git a/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift index 4b79bccb..799535fc 100644 --- a/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift +++ b/Projects/DVDesign/Sources/Components/RadioButton/DVRadioButtonGroup.swift @@ -93,7 +93,7 @@ public struct DVRadioButtonGroup: View { // 한 줄에 다 들어가지 않으면 **줄을 바꾼다.** `HStack`은 라벨의 고유 폭 때문에 그 아래로 // 압축되지 않아, 좁은 컨테이너에서는 줄어드는 대신 잘렸다 — 선택지가 몇 개인지조차 // 보이지 않게 된다. 넓을 때의 모습은 한 줄 그대로다. - DVFlowLayout(hSpacing: size.spacing, vSpacing: size.spacing) { + DVFlowLayout(hSpacing: size.spacing, vSpacing: Size.wrappedLineSpacing) { ForEach(items) { item in DVRadioButton(item.title, isSelected: selection == item.id) { selection = item.id @@ -174,6 +174,12 @@ extension DVRadioButtonGroup { case .md: return 56 } } + + /// 줄이 바뀔 때 두 줄 사이 간격 (포인트 단위). 사이즈와 무관하게 고정이다. + /// + /// ``spacing``은 **한 줄 안에서 선택지를 갈라놓는** 간격이라 `.md`가 56pt다. 그대로 세로에 쓰면 + /// 접힌 줄이 폼의 행 간격(16pt)보다 멀어져 한 필드가 아니라 깨진 레이아웃으로 읽힌다. + static var wrappedLineSpacing: CGFloat { 8 } } } diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index ef04305e..1900c236 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -427,6 +427,9 @@ "Edit" : { "comment" : "Accessibility label for the \"Edit\" button in the secret detail header.", "isCommentAutoGenerated" : true + }, + "Editing is unavailable until the linked projects load. Other details are unaffected." : { + }, "Enable expiration alerts" : { "comment" : "Title of a toggle row that allows the user to enable or disable expiration alerts.", @@ -488,6 +491,9 @@ "Failed to load" : { "comment" : "Text displayed in the sidebar when loading the list of projects fails.", "isCommentAutoGenerated" : true + }, + "Failed to load linked projects" : { + }, "Failed to load projects" : { @@ -670,6 +676,9 @@ }, "Please authenticate to save the secret." : { + }, + "Please authenticate to save your changes. Your changes are still here." : { + }, "Please authenticate to view the secret." : { "comment" : "Alert message when authentication is required to view a secret.", diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index 9444ac39..b4fb66b1 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -34,8 +34,9 @@ public struct SecretClient: Sendable { /// `@DependencyClient`의 저장 프로퍼티에 담기지 않기 때문이다. 생성 경로가 `dispatchCreateSecret`으로 /// 같은 문제를 푼 것과 같은 형태로, Live가 `change`를 보고 overload를 고른다. /// - /// **인증을 타지 않는다.** payload를 다시 쓰더라도 암호화만 하고 복호화는 하지 않기 때문이다 — - /// 평문은 이미 수정 진입 시점의 `revealPayload`가 인증을 통과해 받아온 것이다. + /// **이 호출 자체는 인증을 타지 않는다** — 암호화만 하고 복호화는 하지 않기 때문이다. + /// 다만 진입 시점의 인증이 저장 시점까지 열려 있다는 보장은 없으므로, 창이 닫혀 있으면 + /// 화면이 직전에 `authenticate`를 먼저 부른다 (`SecretDetailFeature.handleSave`). /// /// - Parameter patch: 공통 필드. 바뀐 것만 `.set`, 나머지는 `.unchanged`. 이름 trim과 /// 만료일 23:59:59 고정은 도메인(`PatchSecretUseCase`)이 수행하므로 화면에서 맞출 필요가 없다. diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift index 472508d1..130cdb1b 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/SecretDetailHeaderView.swift @@ -29,7 +29,9 @@ struct SecretDetailHeaderView: View { let secretType: SecretType let subType: SecretSubType? let isLiked: Bool - /// payload 복호화 전/실패 상태에서는 수정 진입을 막는다. + /// 수정 진입을 받을 수 있는지. 판정은 호출부(`SecretDetailView.isEditEnabled`)가 갖는다 — + /// 복호화 진행 중이거나 연결 프로젝트를 아직 못 읽은 동안 거짓이다. + /// /// `isEditing`이 참이면 수정 버튼 자체가 렌더되지 않으므로 조회 모드에서만 의미가 있다. var isEditEnabled: Bool = true /// 편집 폼 위에 얹힌 헤더인지. @@ -281,7 +283,7 @@ private let _headerWidth: CGFloat = 380 .previewWidth(_headerWidth + 40) } -#Preview("수정 비활성 (payload 미복호화)") { +#Preview("수정 비활성 (복호화 진행 중)") { SecretDetailHeaderView( secretType: .apiKeyToken, subType: .apiKey, diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift index c290ca0a..e368ea8e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/SecretDetailError.swift @@ -80,7 +80,8 @@ extension AlertState where Action == SecretDetailFeature.Action.Alert { } } - /// 프로젝트 목록 조회 실패. 연결 프로젝트와 선택 가능한 프로젝트 모두 이 alert를 쓴다. + /// 편집 폼의 **선택 가능한** 프로젝트 조회 실패. 프로젝트 연결만 못 바꿀 뿐 + /// 나머지 필드는 계속 고칠 수 있으므로 편집을 이어가게 둔다. static var projectsLoadFailed: Self { Self { TextState("Failed to load projects", bundle: .module) @@ -94,6 +95,39 @@ extension AlertState where Action == SecretDetailFeature.Action.Alert { } } + /// 이 시크릿에 **연결된** 프로젝트 조회 실패. 재시도를 제공하는 유일한 alert다. + /// + /// 연결을 모르는 채로 수정에 들어가면 편집 baseline이 빈 목록이 되어 저장할 때 + /// **실제 연결이 조용히 끊긴다.** 읽을 때까지 수정을 막으므로 복구 경로가 필요하다. + static var linkedProjectsLoadFailed: Self { + Self { + TextState("Failed to load linked projects", bundle: .module) + } actions: { + ButtonState(action: .retryLinkedProjects) { TextState("Retry", bundle: .module) } + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "Editing is unavailable until the linked projects load. Other details are unaffected.", + bundle: .module + ) + } + } + + /// 저장 직전 재인증 실패·취소. ``updateFailed``와 나누는 이유는 사용자가 할 일이 달라서다 — + /// 이쪽은 다시 눌러 인증하면 되고, 저쪽은 다시 눌러도 같은 오류가 날 수 있다. + static var updateAuthRequired: Self { + Self { + TextState("Authentication required", bundle: .module) + } actions: { + ButtonState(role: .cancel) { TextState("OK", bundle: .module) } + } message: { + TextState( + "Please authenticate to save your changes. Your changes are still here.", + bundle: .module + ) + } + } + /// 클립보드 복사 실패. 자동 정리·반복 감지까지 포함한 경로가 실패한 경우다. static var copyFailed: Self { Self { diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 01116ad5..38d5cdbf 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -65,6 +65,15 @@ public struct SecretDetailFeature { case .none, .reveal, .edit: return true } } + + /// 이 복호화가 속한 취소 그룹. **취소는 같은 그룹 안에서만 일어난다** — 눈·복사는 서로를 + /// 대체하는 것이 맞지만, 수정 진입까지 그 규칙에 밀려 사라지면 안 되기 때문이다. + var cancelID: CancelID { + switch self { + case .none, .reveal, .copy: return .reveal + case .edit: return .editReveal + } + } } // MARK: - State @@ -95,9 +104,19 @@ public struct SecretDetailFeature { /// payload 밖에 있는 변경을 취소 확인에서 놓친다. /// `payloadState`와도 분리한다 — 편집 중 다른 필드를 reveal해도 병합 기준이 흔들리면 안 된다. var editPayloadBaseline: CreateSecretPayload? - /// 이 Secret에 연결된 Project. `Secret` 엔티티에 프로젝트 정보가 없어 별도 조회한다. + /// 이 Secret에 연결된 Project의 조회 상태. `Secret` 엔티티에 프로젝트 정보가 없어 별도 조회한다. /// 조회 화면의 Project 필드 표시와, 수정 진입 시 `projectIds` 초기값에 함께 쓰인다. - public var linkedProjects: [Project] = [] + /// + /// 배열이 아니라 `LoadingState`인 이유는 **"연결 없음"과 "아직 모름"을 구분해야 하기 때문**이다. + /// 모르는 채로 수정에 들어가면 편집 baseline이 빈 목록이 되어 저장할 때 실제 연결이 끊긴다. + public var linkedProjectsState: LoadingState<[Project], SecretUseCaseError> = .idle + + /// 화면에 그릴 연결 목록. 아직 못 읽었으면 빈 배열이고, + /// 구분이 필요한 곳은 ``linkedProjectsState``를 직접 본다. + public var linkedProjects: [Project] { + guard case .loaded(let projects) = linkedProjectsState else { return [] } + return projects + } /// 프로젝트 선택 드롭다운의 옵션. **편집 진입 시에만 로드한다** — /// 조회만 하는 사용자에게 전체 프로젝트 목록을 읽힐 이유가 없다. public var availableProjects: [Project] = [] @@ -183,6 +202,8 @@ public struct SecretDetailFeature { public enum Alert: Equatable { case confirmDiscard case confirmDelete + /// 연결 프로젝트 조회 재시도. 읽지 못하면 수정에 들어갈 수 없으므로 재시도 경로가 필요하다. + case retryLinkedProjects } public enum Delegate: Equatable { @@ -197,11 +218,21 @@ public struct SecretDetailFeature { // MARK: - Cancellation - private enum CancelID { + // `RevealContinuation.cancelID`가 노출하므로 `private`으로 둘 수 없다. + enum CancelID { /// 즐겨찾기 연타 시 이전 요청을 취소해 응답 순서가 뒤바뀌는 것을 막는다. case like - /// 재시도 연타 시 생체인증 프롬프트 요청이 겹쳐 쌓이는 것을 막는다. + /// 눈·복사가 유발한 복호화. 재시도 연타 시 생체인증 프롬프트가 겹쳐 쌓이는 것을 막는다. + /// 나중 요청이 앞 요청을 취소하므로 **대기 중이던 복사도 함께 사라진다** — 눈 버튼을 눌렀는데 + /// 다른 필드가 클립보드에 들어가면 사용자가 알 방법이 없기 때문이다. case reveal + /// 수정 진입이 유발한 복호화. ``reveal``과 나누지 않으면 인증 시트가 뜬 사이 눈 버튼 한 번에 + /// 수정 진입이 사라져, 인증만 두 번 뜨고 편집 모드는 열리지 않는다. + case editReveal + /// 연결 프로젝트 조회. 재시도가 겹쳐 쌓이지 않게 한다. + case linkedProjects + /// 편집 폼의 프로젝트 선택 옵션 조회. 겹치면 늦게 도착한 실패가 먼저 도착한 성공을 덮어쓴다. + case projects } // MARK: - Dependencies @@ -224,16 +255,9 @@ public struct SecretDetailFeature { // 진입 시에는 복호화하지 않는다. payload 필드는 값과 무관하게 항상 마스킹되므로 // 미리 풀어둘 이유가 없고, 화면을 여는 것만으로 인증을 요구하지 않기 위해서다. case .task: + state.linkedProjectsState = .loading return .merge( - .run { [id = state.secret.id] send in - do { - let projects = try await secretClient.fetchLinkedProjects(id) - await send(.linkedProjectsResponse(.success(projects))) - } catch is CancellationError { - } catch { - await send(.linkedProjectsResponse(.failure(SecretUseCaseError.map(error)))) - } - }, + linkedProjectsEffect(id: state.secret.id), .run { send in for await event in appLifecycleClient.events() { await send(.lifecycleEvent(event)) @@ -253,15 +277,20 @@ public struct SecretDetailFeature { return .send(.delegate(.closed)) case .linkedProjectsResponse(.success(let projects)): - state.linkedProjects = projects + state.linkedProjectsState = .loaded(projects) return .none - // 진입 시 복호화를 하지 않게 되면서 복호화 실패 alert와 겹칠 일이 없어졌다. - // 필드는 빈 값으로 남고, 나머지 정보는 영향받지 않는다는 것을 문구로 알린다. - case .linkedProjectsResponse(.failure): - state.alert = .projectsLoadFailed + // 필드는 빈 값으로 남고 나머지 정보는 영향받지 않는다. 다만 연결을 모르는 채로 + // 수정에 들어가면 저장할 때 실제 연결이 끊기므로, 수정 버튼은 잠기고 Retry가 복구 경로다. + case .linkedProjectsResponse(.failure(let error)): + state.linkedProjectsState = .failed(error) + state.alert = .linkedProjectsLoadFailed return .none + case .alert(.presented(.retryLinkedProjects)): + state.linkedProjectsState = .loading + return linkedProjectsEffect(id: state.secret.id) + // 복호화는 인증을 통과해야만 성공하므로, 도착 자체가 인증 성공을 뜻한다. // // 다만 **복사가 유발한 복호화는 열람 인증 창을 열지 않는다.** 복사는 자체 정책을 @@ -394,6 +423,9 @@ public struct SecretDetailFeature { // 헤더에서 수정 버튼이 사라지므로 편집 중에는 도달하지 않지만, // 상태 전이를 한곳에서 막아 두는 편이 안전하다. guard state.mode == .viewing else { return .none } + // 연결을 모르면 편집 baseline을 세울 수 없다. 빈 목록을 기준으로 삼으면 저장할 때 + // 실제 연결이 끊긴다. 헤더의 수정 버튼도 같은 조건으로 잠겨 있다. + guard case .loaded = state.linkedProjectsState else { return .none } state.isLoadingProjects = true let projects = availableProjectsEffect() @@ -409,16 +441,15 @@ public struct SecretDetailFeature { // 건드린 것이 없으면 확인 없이 나간다. 물어보는 것 자체가 성가신 확인이 된다. case .didTapCancelEdit: guard state.mode == .editing else { return .none } - guard state.editFields == state.editFieldsBaseline else { - state.alert = .confirmDiscard - return .none - } - endEditing(&state) + guard let fields = state.editFields, + let baseline = state.editFieldsBaseline, + Self.isDirty(fields, from: baseline) + else { return endEditing(&state) } + state.alert = .confirmDiscard return .none case .alert(.presented(.confirmDiscard)): - endEditing(&state) - return .none + return endEditing(&state) case .availableProjectsResponse(.success(let projects)): state.isLoadingProjects = false @@ -460,15 +491,22 @@ public struct SecretDetailFeature { if let fields = state.editFields, Set(fields.projectIds) != Set(state.editFieldsBaseline?.projectIds ?? []) { let selected = Set(fields.projectIds) - state.linkedProjects = state.availableProjects.filter { selected.contains($0.id) } + state.linkedProjectsState = .loaded( + state.availableProjects.filter { selected.contains($0.id) } + ) } - endEditing(&state) - return .send(.delegate(.secretUpdated(updated))) + return .merge( + endEditing(&state), + .send(.delegate(.secretUpdated(updated))) + ) // 편집 모드를 유지한다 — 조회로 되돌리면 사용자가 입력한 내용이 통째로 사라진다. - case .saveResponse(.failure, _): + case .saveResponse(.failure(let error), _): state.isSaving = false - state.alert = .updateFailed + // 인증 취소는 다시 눌러 인증하면 되고, 쓰기 실패는 다시 눌러도 같은 오류가 날 수 있다. + state.alert = SecretDetailError.map(error) == .authRequired + ? .updateAuthRequired + : .updateFailed return .none case .binding, .alert, .delegate, .createProject: @@ -500,12 +538,18 @@ public struct SecretDetailFeature { /// 편집 종료. 편집 전용 상태를 한 번에 비운다 — /// 하나라도 남으면 다음 진입의 dirty 판정 기준이 어긋난다. - private func endEditing(_ state: inout State) { + /// + /// 진행 중인 프로젝트 조회도 함께 끊는다. 남겨두면 늦게 도착한 응답이 방금 비운 목록을 + /// 조회 모드에 되살린다. + private func endEditing(_ state: inout State) -> Effect { state.mode = .viewing state.editFields = nil state.editFieldsBaseline = nil state.editPayloadBaseline = nil state.validationErrors = [:] + state.availableProjects = [] + state.isLoadingProjects = false + return .cancel(id: CancelID.projects) } /// 저장. 변경 없음 판정 → 필수 필드 검증 → 다시 쓸 대상 결정 순이다. @@ -518,9 +562,8 @@ public struct SecretDetailFeature { // 아무것도 바뀌지 않았으면 도메인을 부르지 않는다. 부르면 updatedAt만 갱신되어 // 목록의 "최근 추가" 정렬이 이유 없이 흔들린다. 목록 재조회도 필요 없으니 delegate도 없다. - guard fields != baselineFields else { - endEditing(&state) - return .none + guard Self.isDirty(fields, from: baselineFields) else { + return endEditing(&state) } // 서브타입은 수정 화면에서 바꿀 수 없으므로 저장된 값을 그대로 쓴다. @@ -541,8 +584,18 @@ public struct SecretDetailFeature { let patch = Self.patch(from: fields, baseline: baselineFields, secretType: secretType, subType: subType) let projectIds = Self.projectIDs(from: fields, baseline: baselineFields) + // `lifecycleEvent`는 인증 창을 닫으면서도 편집 중인 입력은 일부러 남겨 둔다. 그래서 + // 창이 닫힌 채로 저장이 나갈 수 있어, 닫혀 있으면 직전에 다시 인증받는다. + let needsAuthentication = !revealAuthPolicy.isAuthorized( + since: state.revealAuthorizedAt, + now: now + ) + return .run { [id = state.secret.id] send in do { + if needsAuthentication { + try await secretClient.authenticate(AuthenticationReason.editSecret) + } let updated = try await secretClient.updateSecret(id, patch, change, projectIds) await send(.saveResponse(.success(updated), saved: payload)) } catch is CancellationError { @@ -560,11 +613,34 @@ public struct SecretDetailFeature { return .none // (secretType, subType)이 저장된 시크릿에서 오고 수정 화면이 바꾸지 않으므로 도달할 수 없다. + // 그래도 조용히 삼키지 않는다 — 여기로 떨어지면 Save가 아무 반응 없는 버튼이 된다. case .failure(.invalidTypeCombination): + assertionFailure( + "수정 저장에서 타입 조합이 어긋났다: \(secretType) / \(String(describing: subType))" + ) + state.alert = .updateFailed return .none } } + /// 폼이 편집 진입 시점과 **의미상** 달라졌는지. + /// + /// 그대로 비교하지 않는 이유는 `projectIds`의 순서가 임의이기 때문이다 — 드롭다운이 선택을 + /// Set으로 다루고 `Array(Set)`으로 되돌리므로(`ProjectFieldView.setBinding`), 같은 프로젝트를 + /// 껐다 켜기만 해도 순서가 달라진다. 그것을 변경으로 세면 물어볼 이유가 없는 확인 alert가 뜨고, + /// 저장은 `updatedAt`만 바꾸는 write를 내보내 목록 정렬을 흔든다. + private static func isDirty(_ fields: SecretMetaFields, from baseline: SecretMetaFields) -> Bool { + sortedProjectIds(fields) != sortedProjectIds(baseline) + } + + /// 순서만 다른 것을 같게 보이도록 맞춘 사본. `Project.ID`(`UUID`)가 `Comparable`이 아니라 + /// `uuidString`으로 정렬한다 — 비교에만 쓰이므로 기준의 의미는 상관없다. + private static func sortedProjectIds(_ fields: SecretMetaFields) -> SecretMetaFields { + var normalized = fields + normalized.projectIds.sort { $0.uuidString < $1.uuidString } + return normalized + } + /// 바뀐 공통 필드만 `.set`으로 싣는다. 안 바뀐 것은 `.unchanged`로 두어 불필요한 write를 만들지 않는다. /// /// 비교를 폼 값이 아니라 `toSecretDraft` 결과로 하는 이유는 `""` → `nil` 접힘 같은 매핑 규칙을 @@ -599,6 +675,20 @@ public struct SecretDetailFeature { // MARK: - Effects + /// 이 시크릿에 연결된 프로젝트를 읽어 온다. 진입(`task`)과 실패 후 재시도가 함께 쓴다. + private func linkedProjectsEffect(id: Secret.ID) -> Effect { + .run { send in + do { + let projects = try await secretClient.fetchLinkedProjects(id) + await send(.linkedProjectsResponse(.success(projects))) + } catch is CancellationError { + } catch { + await send(.linkedProjectsResponse(.failure(SecretUseCaseError.map(error)))) + } + } + .cancellable(id: CancelID.linkedProjects, cancelInFlight: true) + } + /// 편집 폼의 프로젝트 선택 옵션을 읽어 온다. private func availableProjectsEffect() -> Effect { .run { send in @@ -610,9 +700,12 @@ public struct SecretDetailFeature { await send(.availableProjectsResponse(.failure(ProjectUseCaseError.map(error)))) } } + .cancellable(id: CancelID.projects, cancelInFlight: true) } /// 인증 + 복호화. `revealPayload`가 둘을 함께 하므로 값이 아직 없을 때만 쓴다. + /// 취소 ID는 이어서 할 일의 종류를 따른다 (``RevealContinuation/cancelID``). + /// /// - Parameter then: 복호화가 끝나면 이어서 할 일 (``RevealContinuation``). private func revealEffect( secret: Secret, @@ -628,7 +721,7 @@ public struct SecretDetailFeature { await send(.payloadResponse(.failure(SecretUseCaseError.map(error)), then: continuation)) } } - .cancellable(id: CancelID.reveal, cancelInFlight: true) + .cancellable(id: continuation.cancelID, cancelInFlight: true) } /// Copy에 필요한 payload만 복호화한다. 인증은 이어지는 `copySensitiveValue`가 설정을 읽어 diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index 8e86d7c8..b78f7c2a 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -23,8 +23,8 @@ public struct SecretDetailView: View { // MARK: - Body public var body: some View { - /// 필드 폭은 컨테이너 폭에서 파생된다. detail 컬럼은 기본적으로 가변 폭 1열(.detailFluid)이고, - /// 컬럼이 2열 임계값(816)을 넘으면 CreateSecret과 같은 .dual 배열로 전환된다. + // 필드 폭은 컨테이너 폭에서 파생된다. detail 컬럼은 기본적으로 가변 폭 1열(.detailFluid)이고, + // 컬럼이 2열 임계값(816)을 넘으면 CreateSecret과 같은 .dual 배열로 전환된다. GeometryReader { proxy in let layout = DetailColumnFormLayout.layout(for: proxy.size.width) VStack(spacing: 0) { @@ -43,20 +43,26 @@ public struct SecretDetailView: View { .scrollIndicators(.hidden) if store.mode == .editing { - /// 생성 화면과 마찬가지로 구분선을 두지 않는다 — footer 자체가 여백으로 분리된다. + // 생성 화면과 마찬가지로 구분선을 두지 않는다 — footer 자체가 여백으로 분리된다. footer .transition(.move(edge: .bottom).combined(with: .opacity)) } } - /// 모드 전환만 애니메이션한다. `value`를 좁히지 않으면 편집 중 타이핑 한 글자마다 폼 전체가 다시 애니메이션된다. + // 모드 전환만 애니메이션한다. `value`를 좁히지 않으면 편집 중 타이핑 한 글자마다 + // 폼 전체가 다시 애니메이션된다. .animation(.smooth(duration: 0.25), value: store.mode) - /// 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. + // 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. .formMaxWidth() .formLayout(layout) } - /// `id:`가 필수다. 다른 리스트 셀을 선택하면 MainFeature가 `secretDetail`에 새 State를 할당하지만, 뷰의 타입·위치가 그대로라 SwiftUI는 뷰를 재생성하지 않는다. 그러면 평범한 `.task`는 다시 실행되지 않아 `linkedProjects` / `payloadState`가 빈 채로 남는다 (`store.secret`을 직접 읽는 헤더·Name만 갱신되어 더 헷갈린다). - /// - /// `finish()`를 await해야 secret이 바뀔 때 이전 secret의 lifecycle 구독과 프로젝트 조회가 함께 끝난다. 눈·복사는 이 task의 자식이 아니므로 여기서 취소되지 않는다 — 그쪽은 `State.id`를 보고 `ifLet`이 취소한다. + // `id:`가 필수다. 다른 리스트 셀을 선택하면 MainFeature가 `secretDetail`에 새 State를 + // 할당하지만, 뷰의 타입·위치가 그대로라 SwiftUI는 뷰를 재생성하지 않는다. 그러면 평범한 + // `.task`는 다시 실행되지 않아 `linkedProjects` / `payloadState`가 빈 채로 남는다 + // (`store.secret`을 직접 읽는 헤더·Name만 갱신되어 더 헷갈린다). + // + // `finish()`를 await해야 secret이 바뀔 때 이전 secret의 lifecycle 구독과 프로젝트 조회가 + // 함께 끝난다. 눈·복사는 이 task의 자식이 아니므로 여기서 취소되지 않는다 — + // 그쪽은 `State.id`를 보고 `ifLet`이 취소한다. .task(id: store.secret.id) { await store.send(.task).finish() } @@ -128,10 +134,14 @@ extension SecretDetailView { .disabled(store.isDeleting) } - /// 복호화가 진행 중일 때만 수정 진입을 막는다. `.idle`에서 누르면 그때 복호화가 시작되고, + /// 복호화는 **진행 중일 때만** 막는다. `.idle`에서 누르면 그때 복호화가 시작되고, /// `.failed`에서 누르면 재시도가 된다 — 실패했다고 수정을 영영 막을 이유가 없다. + /// + /// 연결된 프로젝트는 다르다. 빈 목록을 baseline으로 삼으면 저장할 때 실제 연결이 끊기므로 + /// `.loaded`가 아니면 잠근다 — 복구는 `linkedProjectsLoadFailed` alert의 Retry가 맡는다. private var isEditEnabled: Bool { if case .loading = store.payloadState { return false } + guard case .loaded = store.linkedProjectsState else { return false } return true } @@ -159,13 +169,13 @@ extension SecretDetailView { .resolvedSubType(store.secret.subType), meta: fields, availableProjects: store.availableProjects, - /// 감지 힌트는 생성 화면 전용이다. 이미 저장된 값을 고치는 중에 서비스 제안 필요 없음. + // 감지 힌트는 생성 화면 전용이다. 이미 저장된 값을 고치는 중에 서비스 제안 필요 없음. serviceCandidates: [], validationErrors: store.validationErrors, detectedServices: [:], onCreateProject: { store.send(.didTapCreateProject) } ) - /// 진행 표시는 창 루트가 그린다 (`.omc/GUIDELINES.md`). + // 진행 표시는 창 루트가 그린다 (`.omc/GUIDELINES.md`). .disabled(store.isSaving) .windowBusy(store.isSaving) .environment(\.isProjectLoading, store.isLoadingProjects) @@ -176,7 +186,8 @@ extension SecretDetailView { private var footer: some View { FooterActionsView( saveTitle: .module("Save"), - /// 필수 필드 검증은 `didTapSave`가 수행해 인라인 경고를 세운다 — 여기서 미리 막으면 경고가 영영 뜨지 않는다. 생성 화면과 같은 규칙. + // 필수 필드 검증은 `didTapSave`가 수행해 인라인 경고를 세운다 — 여기서 미리 막으면 + // 경고가 영영 뜨지 않는다. 생성 화면과 같은 규칙. isSaveEnabled: !store.isSaving, onCancel: { store.send(.didTapCancelEdit) }, onSave: { store.send(.didTapSave) } @@ -263,7 +274,7 @@ private func _editingPreviewState( let projects = [Project].preview var state = SecretDetailFeature.State(secret: _previewSecret) state.payloadState = .loaded(payload) - state.linkedProjects = Array(projects.prefix(1)) + state.linkedProjectsState = .loaded(Array(projects.prefix(1))) state.availableProjects = projects let fields = SecretMetaFields( secret: _previewSecret, diff --git a/Projects/DVPresentation/Sources/Form/FormLayout.swift b/Projects/DVPresentation/Sources/Form/FormLayout.swift index 2082b77c..564032a5 100644 --- a/Projects/DVPresentation/Sources/Form/FormLayout.swift +++ b/Projects/DVPresentation/Sources/Form/FormLayout.swift @@ -96,9 +96,13 @@ struct FormLayout: Equatable { case vertical(spacing: CGFloat) } - /// full-width 슬롯 사이즈. `widthPolicy == .fill`이면 **최소 폭**으로 동작한다. + /// full-width 슬롯 사이즈. + /// + /// `widthPolicy == .fill`이면 **프레임 계산에 쓰이지 않는다** — 하한은 `.fill(minimum:)`이 갖고 + /// 폭은 컨테이너를 따른다. 그래도 남겨두는 것은 토큰 자체를 읽는 컴포넌트가 있어서다 + /// (`DVMultiSelectDropdown`의 팝오버 폭). var fullWidthSize: DVComponentSize - /// 페어 슬롯 사이즈. `widthPolicy == .fill`이면 **최소 폭**으로 동작한다. + /// 페어 슬롯 사이즈. `.fill`에서의 해석은 ``fullWidthSize``와 같다. var pairedSize: DVComponentSize var pairAxis: PairAxis var rowSpacing: CGFloat @@ -125,8 +129,9 @@ extension FormLayout { /// CreateSecret의 넓은 폭 배열이며, **Detail 컬럼도 충분히 넓어지면 같은 배열을 쓴다** — /// 두 화면이 같은 모습이 되는 것은 의도된 동작이다. /// - /// `widthPolicy`가 `.fill`이라 사이즈는 **최소 폭**으로만 쓰인다. 프레임 상한(`maxFormWidth` 816, - /// 콘텐츠 776)에서 full-width는 776, 페어는 `(776 - 16) / 2 = 380 = .md`가 되어 정확히 맞물린다. + /// `widthPolicy`가 `.fill`이라 폭은 컨테이너가 정하고 하한은 `slotFloor`가 갖는다. 프레임 상한 + /// (`maxFormWidth` 816, 콘텐츠 776)에서 full-width는 776, 페어는 `(776 - 16) / 2 = 380`이 되어, + /// 아래 사이즈 토큰(`.lg` / `.md`)이 가리키던 폭과 결과적으로 맞물린다. /// /// `.fixed`로 두면 full-width가 `.lg`(700)에 머물러 페어 행(776)보다 좁아 **오른쪽 끝이 들쭉날쭉**해지고, /// `.detailFluid`에서 넘어올 때 필드가 775 → 700으로 줄어드는 역전이 생긴다. @@ -152,8 +157,8 @@ extension FormLayout { /// 가변 폭 1열 폼 — 컨테이너를 채우고 페어는 절반씩 나눈다. Detail 컬럼의 기본 배열. /// - /// 사이즈는 최소 폭으로만 쓰인다. 콘텐츠 380(컬럼 최소 420)에서 페어는 `(380 - 20) / 2 = 180 = .xs`, - /// 콘텐츠 776(프레임 상한 816)에서 각각 378이 되고 거기서 멈춘다. + /// 폭은 컨테이너를 따르고 하한은 `slotFloor`가 갖는다. 콘텐츠 380(컬럼 최소 420)에서 + /// 페어는 `(380 - 20) / 2 = 180`, 콘텐츠 776(프레임 상한 816)에서 각각 378이 되고 거기서 멈춘다. static let detailFluid = FormLayout( fullWidthSize: .md, pairedSize: .xs, diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index adb7ebf7..e20da9e0 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -130,9 +130,11 @@ struct SecretDetailFeatureTests { // 진입은 복호화를 시작하지 않는다. `revealPayload`를 스텁하지 않았으므로 // 진입이 복호화를 걸면 미구현 dependency 호출로 이 테스트가 실패한다. - await store.send(.task) + await store.send(.task) { + $0.linkedProjectsState = .loading + } await store.receive(.linkedProjectsResponse(.success(linked))) { - $0.linkedProjects = linked + $0.linkedProjectsState = .loaded(linked) } } @@ -150,14 +152,57 @@ struct SecretDetailFeatureTests { // 진입은 복호화를 시작하지 않는다. `revealPayload`를 스텁하지 않았으므로 // 진입이 복호화를 걸면 미구현 dependency 호출로 이 테스트가 실패한다. - await store.send(.task) + await store.send(.task) { + $0.linkedProjectsState = .loading + } await store.receive(.linkedProjectsResponse(.failure(.unexpected))) { - $0.alert = .projectsLoadFailed + $0.linkedProjectsState = .failed(.unexpected) + $0.alert = .linkedProjectsLoadFailed } // 값은 비어 있을 뿐 다른 정보는 영향받지 않는다. #expect(store.state.linkedProjects.isEmpty) } + /// 못 읽은 것을 "연결 없음"으로 삼고 편집에 들어가면 저장할 때 **실제 연결이 조용히 끊긴다.** + @Test("연결된 프로젝트를 못 읽으면 수정 진입이 막힌다") + func didTapEdit_whenLinkedProjectsFailed_doesNothing() async { + let secret = Self.makeSecret() + var initial = SecretDetailFeature.State(secret: secret) + initial.payloadState = .loaded(Self.editablePayload) + initial.linkedProjectsState = .failed(.unexpected) + + // `revealPayload`·`fetchProjects`를 스텁하지 않았으므로 진입이 뚫리면 미구현 호출로 실패한다. + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapEdit) + } + + /// 사용자가 스스로 복구할 수 있는 유일한 경로다. 재조회에 성공하면 수정이 다시 열린다. + @Test("연결된 프로젝트 조회 실패: Retry로 다시 읽는다") + func retryLinkedProjects_reloads() async { + let secret = Self.makeSecret() + let linked = [ + Project(id: UUID(), name: "DrinkiG", createdAt: Self.referenceDate, updatedAt: Self.referenceDate) + ] + var initial = SecretDetailFeature.State(secret: secret) + initial.linkedProjectsState = .failed(.unexpected) + initial.alert = .linkedProjectsLoadFailed + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.fetchLinkedProjects = { _ in linked } + } + + await store.send(.alert(.presented(.retryLinkedProjects))) { + $0.alert = nil + $0.linkedProjectsState = .loading + } + await store.receive(.linkedProjectsResponse(.success(linked))) { + $0.linkedProjectsState = .loaded(linked) + } + } + // MARK: - payload 재시도 // `didTapRetryReveal`은 effect가 reveal 하나뿐이라 수신 순서를 맞출 필요가 없다. @@ -694,7 +739,7 @@ struct SecretDetailFeatureTests { initialState: { var state = SecretDetailFeature.State(secret: secret) state.payloadState = .loaded(payload) - state.linkedProjects = [project] + state.linkedProjectsState = .loaded([project]) return state }() ) { @@ -719,7 +764,7 @@ struct SecretDetailFeatureTests { let secret = Self.makeSecret() let payload = Self.editablePayload - let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + let store = TestStore(initialState: Self.readyToEditState(secret: secret)) { SecretDetailFeature() } withDependencies: { $0.secretClient.revealPayload = { _, _ in payload } @@ -747,7 +792,7 @@ struct SecretDetailFeatureTests { func didTapEdit_decryptionFailure_staysViewing() async { let secret = Self.makeSecret() - let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { + let store = TestStore(initialState: Self.readyToEditState(secret: secret)) { SecretDetailFeature() } withDependencies: { $0.secretClient.revealPayload = { _, _ in @@ -814,6 +859,46 @@ struct SecretDetailFeatureTests { } } + /// 드롭다운은 선택을 Set으로 다루고 `Array(Set)`으로 되돌리므로 같은 프로젝트를 껐다 켜기만 해도 + /// 배열 순서가 달라진다. 그것을 변경으로 세면 **아무것도 안 바꾼 사용자에게 확인 alert가 뜬다.** + @Test("취소: 프로젝트 순서만 다른 것은 변경이 아니다") + func didTapCancelEdit_projectIdsReordered_returnsImmediately() async { + let first = UUID() + let second = UUID() + var initial = Self.editingState(projectIds: [first, second]) + initial.editFields?.projectIds = [second, first] + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapCancelEdit) { + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + } + + /// 남겨두면 다음 편집 진입이 **옛 목록으로 시작**해 그 사이 지워진 프로젝트가 선택지에 뜬다. + @Test("편집을 끝내면 프로젝트 선택 옵션과 로딩 표시도 함께 비워진다") + func endEditing_clearsProjectOptions() async { + var initial = Self.editingState() + initial.availableProjects = [ + Project(id: UUID(), name: "DrinkiG", createdAt: Self.referenceDate, updatedAt: Self.referenceDate) + ] + initial.isLoadingProjects = true + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapCancelEdit) { + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + $0.availableProjects = [] + $0.isLoadingProjects = false + } + } + // MARK: - 편집 폼의 프로젝트 목록 @Test("편집 진입 시 프로젝트 선택 옵션을 읽어 온다") @@ -825,11 +910,7 @@ struct SecretDetailFeatureTests { ] let store = TestStore( - initialState: { - var state = SecretDetailFeature.State(secret: secret) - state.payloadState = .loaded(payload) - return state - }() + initialState: Self.readyToEditState(secret: secret, payloadState: .loaded(payload)) ) { SecretDetailFeature() } withDependencies: { @@ -857,11 +938,7 @@ struct SecretDetailFeatureTests { let payload = Self.editablePayload let store = TestStore( - initialState: { - var state = SecretDetailFeature.State(secret: secret) - state.payloadState = .loaded(payload) - return state - }() + initialState: Self.readyToEditState(secret: secret, payloadState: .loaded(payload)) ) { SecretDetailFeature() } withDependencies: { @@ -924,6 +1001,25 @@ struct SecretDetailFeatureTests { } } + /// 순서 차이를 변경으로 세면 `updatedAt`만 바꾸는 write가 나가 목록의 "최근" 정렬이 흔들린다 — + /// 변경 없음 판정이 막으려던 바로 그 일이다. `updateSecret`을 스텁하지 않아, 불리면 실패한다. + @Test("저장: 프로젝트 순서만 다르면 write 없이 조회로 돌아간다") + func didTapSave_projectIdsReordered_skipsWrite() async { + let first = UUID() + let second = UUID() + var initial = Self.editingState(projectIds: [first, second]) + initial.editFields?.projectIds = [second, first] + + let store = TestStore(initialState: initial) { SecretDetailFeature() } + + await store.send(.didTapSave) { + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + } + @Test("저장: 필수 필드가 비면 인라인 경고만 세우고 저장하지 않는다") func didTapSave_missingRequired_setsValidationErrors() async { var initial = Self.editingState() @@ -956,6 +1052,7 @@ struct SecretDetailFeatureTests { SecretDetailFeature() } withDependencies: { $0.secretClient.updateSecret = { _, _, _, _ in updated } + $0.date = .constant(Self.referenceDate) } await store.send(.didTapSave) { @@ -974,6 +1071,42 @@ struct SecretDetailFeatureTests { await store.receive(.delegate(.secretUpdated(updated))) } + /// 조회 화면의 Project chip은 `linkedProjects`를 읽는다. 갱신하지 않으면 + /// **이전 프로젝트가 그대로 남아** 방금 저장한 것과 화면이 어긋난다. + @Test("저장 성공: 바뀐 프로젝트 연결이 조회용 목록에도 반영된다") + func didTapSave_success_updatesLinkedProjects() async { + let picked = Project(id: UUID(), name: "DrinkiG", createdAt: Self.referenceDate, updatedAt: Self.referenceDate) + let other = Project(id: UUID(), name: "CheerLot", createdAt: Self.referenceDate, updatedAt: Self.referenceDate) + + var initial = Self.editingState() + initial.availableProjects = [picked, other] + initial.editFields?.projectIds = [picked.id] + let updated = Self.makeSecret(name: "GitHub Token") + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.updateSecret = { _, _, _, _ in updated } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapSave) { + $0.isSaving = true + } + await store.receive(.saveResponse(.success(updated), saved: Self.editablePayload)) { + $0.isSaving = false + $0.secret = updated + $0.payloadState = .loaded(Self.editablePayload) + $0.linkedProjectsState = .loaded([picked]) + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + $0.availableProjects = [] + } + await store.receive(.delegate(.secretUpdated(updated))) + } + /// 조회로 되돌리면 사용자가 입력한 내용이 통째로 사라진다. @Test("저장 실패: alert를 띄우되 편집 모드와 입력을 유지한다") func didTapSave_failure_keepsEditing() async { @@ -984,6 +1117,7 @@ struct SecretDetailFeatureTests { SecretDetailFeature() } withDependencies: { $0.secretClient.updateSecret = { _, _, _, _ in throw SecretUseCaseError.unexpected } + $0.date = .constant(Self.referenceDate) } await store.send(.didTapSave) { @@ -1009,6 +1143,7 @@ struct SecretDetailFeatureTests { recorded.setValue(patch) return Self.makeSecret() } + $0.date = .constant(Self.referenceDate) } // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. @@ -1041,6 +1176,7 @@ struct SecretDetailFeatureTests { recorded.setValue(projectIds) return Self.makeSecret() } + $0.date = .constant(Self.referenceDate) } // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. @@ -1066,6 +1202,7 @@ struct SecretDetailFeatureTests { recorded.setValue(projectIds) return Self.makeSecret() } + $0.date = .constant(Self.referenceDate) } // 관심은 Client에 무엇이 전달됐는지 하나뿐이다. 상태 전이는 위 저장 성공 테스트가 본다. @@ -1089,6 +1226,19 @@ extension SecretDetailFeatureTests { } /// 편집 진입을 마친 상태. `beginEditing`이 세우는 것과 같은 조합이어야 한다. + /// 수정 버튼을 누를 수 있는 조회 상태. 연결 목록을 읽어 오기 전에는 수정 진입이 막히므로, + /// 진입 자체가 관심사가 아닌 테스트도 이 상태에서 출발해야 한다. + fileprivate static func readyToEditState( + secret: Secret, + payloadState: LoadingState = .idle, + linkedProjects: [Project] = [] + ) -> SecretDetailFeature.State { + var state = SecretDetailFeature.State(secret: secret) + state.payloadState = payloadState + state.linkedProjectsState = .loaded(linkedProjects) + return state + } + fileprivate static func editingState( secret: Secret = makeSecret(name: "GitHub Token"), projectIds: [Project.ID] = [] @@ -1096,6 +1246,9 @@ extension SecretDetailFeatureTests { let payload = editablePayload var state = SecretDetailFeature.State(secret: secret) state.payloadState = .loaded(payload) + // 실제 진입 경로를 따른다 — 편집에 들어왔다면 연결 목록을 읽었고 인증 창도 방금 열렸다. + state.linkedProjectsState = .loaded([]) + state.revealAuthorizedAt = referenceDate let fields = SecretMetaFields(secret: secret, payload: payload, projectIds: projectIds) state.editFields = fields state.editFieldsBaseline = fields From 3c2586c551a979a6c0414add3fb52e9c0d25da4a Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:57:20 +0900 Subject: [PATCH 23/61] =?UTF-8?q?[#76]=20fix:=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=A9=B0=20=EC=9D=B8=EC=A6=9D=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=B0=BD=EC=9D=B4=20=EB=8B=AB=ED=9E=8C=20?= =?UTF-8?q?=EC=B1=84=EB=A1=9C=20=EB=82=A8=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretDetail/SecretDetailFeature.swift | 9 +++ .../SecretDetailFeatureTests.swift | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 38d5cdbf..2accbee9 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -182,6 +182,9 @@ public struct SecretDetailFeature { ) /// 인증만 수행한 결과. payload를 이미 들고 있는데 창만 만료된 경우에 쓴다. case reauthenticateResponse(Result, revealing: SecretFieldID) + /// 저장 직전 재인증이 통과했다. 쓰기 결과와 별개로 **인증 창부터 다시 연다** — + /// 쓰기가 실패해 편집에 남더라도 방금 인증한 사실은 그대로이기 때문이다. + case saveAuthenticated /// 클립보드 복사 결과. 실패도 사용자에게 알린다 — 값이 복사된 줄 알고 붙여넣으면 더 혼란스럽다. case copyResponse(Result) case likeResponse(Result) @@ -328,6 +331,11 @@ public struct SecretDetailFeature { state.revealedFields.insert(field) return .none + // 갱신하지 않으면 저장하며 Touch ID를 통과한 직후 눈 버튼이 또 인증을 요구한다. + case .saveAuthenticated: + state.revealAuthorizedAt = now + return .none + case .copyResponse(.success(let didCopy)): guard !didCopy else { return .none } state.alert = .copyFailed @@ -595,6 +603,7 @@ public struct SecretDetailFeature { do { if needsAuthentication { try await secretClient.authenticate(AuthenticationReason.editSecret) + await send(.saveAuthenticated) } let updated = try await secretClient.updateSecret(id, patch, change, projectIds) await send(.saveResponse(.success(updated), saved: payload)) diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index e20da9e0..327a30bc 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -1071,6 +1071,85 @@ struct SecretDetailFeatureTests { await store.receive(.delegate(.secretUpdated(updated))) } + /// 갱신하지 않으면 저장하며 Touch ID를 통과한 직후 눈 버튼이 **또** 인증을 요구한다. + /// 3분 캐싱 정책이 이 경로에서만 무너진다. + @Test("저장 직전 재인증: 인증 창을 다시 연다") + func didTapSave_reauthenticates_refreshesAuthWindow() async { + var initial = Self.editingState() + // 인증 창이 닫힌 채로 저장에 들어가는 상황을 만든다. + initial.revealAuthorizedAt = Self.referenceDate.addingTimeInterval(-1_000) + initial.editFields?.content = .apiKeyToken( + APIKeyTokenFields(value: "ghp_new", authorityScope: "repo:read") + ) + let saved = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_new"), + APIKeyMetadata(scope: "repo:read") + ) + let updated = Self.makeSecret(name: "GitHub Token") + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.authenticate = { _ in } + $0.secretClient.updateSecret = { _, _, _, _ in updated } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapSave) { + $0.isSaving = true + } + await store.receive(.saveAuthenticated) { + $0.revealAuthorizedAt = Self.referenceDate + } + await store.receive(.saveResponse(.success(updated), saved: saved)) { + $0.isSaving = false + $0.secret = updated + $0.payloadState = .loaded(saved) + $0.mode = .viewing + $0.editFields = nil + $0.editFieldsBaseline = nil + $0.editPayloadBaseline = nil + } + await store.receive(.delegate(.secretUpdated(updated))) + } + + /// 인증이 취소되면 쓰기는 나가지 않으므로 인증 창도 열리면 안 된다. + /// `updateSecret`을 스텁하지 않아, 불리면 실패한다. + @Test("저장 직전 재인증 실패: 인증 창을 열지 않고 편집에 남는다") + func didTapSave_reauthenticationFails_keepsWindowClosed() async { + let expired = Self.referenceDate.addingTimeInterval(-1_000) + var initial = Self.editingState() + initial.revealAuthorizedAt = expired + initial.editFields?.content = .apiKeyToken( + APIKeyTokenFields(value: "ghp_new", authorityScope: "repo:read") + ) + let saved = CreateSecretPayload.apiKey( + APIKeyPayload(value: "ghp_new"), + APIKeyMetadata(scope: "repo:read") + ) + + let store = TestStore(initialState: initial) { + SecretDetailFeature() + } withDependencies: { + $0.secretClient.authenticate = { _ in + throw SecretUseCaseError.authenticationFailure(.cancelled) + } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.didTapSave) { + $0.isSaving = true + } + await store.receive( + .saveResponse(.failure(.authenticationFailure(.cancelled)), saved: saved) + ) { + $0.isSaving = false + $0.alert = .updateAuthRequired + } + #expect(store.state.revealAuthorizedAt == expired) + #expect(store.state.mode == .editing) + } + /// 조회 화면의 Project chip은 `linkedProjects`를 읽는다. 갱신하지 않으면 /// **이전 프로젝트가 그대로 남아** 방금 저장한 것과 화면이 어긋난다. @Test("저장 성공: 바뀐 프로젝트 연결이 조회용 목록에도 반영된다") From bbb7cf6d5b32fcd30509e18861e0e070cffc06c1 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:58:32 +0900 Subject: [PATCH 24/61] =?UTF-8?q?[#76]=20fix:=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EB=B3=B5=ED=98=B8=ED=99=94=20=EC=A4=91=20?= =?UTF-8?q?=EB=91=90=20=EB=B2=88=EC=A7=B8=20=EB=B3=B5=ED=98=B8=ED=99=94?= =?UTF-8?q?=EA=B0=80=20=EC=8B=9C=EC=9E=91=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretDetail/SecretDetailFeature.swift | 19 +++++++++- .../SecretDetail/SecretDetailView.swift | 13 +++++-- .../SecretDetailFeatureTests.swift | 36 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 2accbee9..60d8a4f1 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -131,6 +131,10 @@ public struct SecretDetailFeature { public var payloadState: LoadingState = .idle /// 마스킹이 해제된 payload 필드. 필드마다 따로 열고 닫는다. var revealedFields: Set = [] + /// 수정 진입 복호화가 진행 중. `payloadState == .loading`만으로는 부족하다 — + /// 눈·복사가 유발한 복호화와 구분해야 한다. 이 둘은 취소 그룹이 달라 서로를 + /// 대체하지 않으므로, 진행 중에 다른 복호화가 시작되면 둘이 동시에 돈다. + var isEnteringEdit = false /// 마지막 reveal 인증 성공 시각. `RevealAuthPolicy.ttl` 안에서는 재인증하지 않는다. /// /// State에 두는 것이 정책의 일부다 — 다른 시크릿을 선택하면 `MainFeature`가 이 State를 @@ -304,6 +308,7 @@ public struct SecretDetailFeature { if continuation.opensRevealWindow { state.revealAuthorizedAt = now } + if case .edit = continuation { state.isEnteringEdit = false } switch continuation { case .none: return .none @@ -317,8 +322,9 @@ public struct SecretDetailFeature { return .none } - case .payloadResponse(.failure(let error), _): + case .payloadResponse(.failure(let error), let continuation): state.payloadState = .failed(error) + if case .edit = continuation { state.isEnteringEdit = false } state.alert = .payloadRevealFailed(SecretDetailError.map(error)) return .none @@ -354,6 +360,16 @@ public struct SecretDetailFeature { state.revealedFields.remove(field) return .none + // 수정 진입 복호화가 도는 동안에는 새 복호화를 시작하지 않는다. 취소 그룹을 나눈 + // 뒤로 이 둘은 서로를 대체하지 않으므로, 막지 않으면 인증 시트가 둘 뜨고 늦게 온 + // 응답이 먼저 온 응답 위에 자기 continuation을 덮어쓴다. + // + // 눈·복사끼리는 막지 않는다 — 나중 요청이 앞 요청을 대체하는 것이 의도된 규칙이다 + // (``CancelID/reveal``). 여기서 함께 막으면 그 규칙까지 사라진다. + case .didTapToggleReveal where state.isEnteringEdit, + .didTapCopy where state.isEnteringEdit: + return .none + case .didTapToggleReveal(let field): // 값이 아직 없으면 복호화가 필요하고, 복호화 경로가 인증까지 함께 수행한다. guard case .loaded = state.payloadState else { @@ -444,6 +460,7 @@ public struct SecretDetailFeature { return projects } state.payloadState = .loading + state.isEnteringEdit = true return .merge(revealEffect(secret: state.secret, then: .edit), projects) // 건드린 것이 없으면 확인 없이 나간다. 물어보는 것 자체가 성가신 확인이 된다. diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index b78f7c2a..5b5eb6f8 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -55,6 +55,15 @@ public struct SecretDetailView: View { .formMaxWidth() .formLayout(layout) } + // 진행 중임을 알리는 지점은 화면당 하나로 모은다 — `windowBusy`는 서브트리마다 값을 + // 세우는 preference라, 조상과 자손이 각각 부르면 어느 쪽이 남는지가 배치에 좌우된다. + // + // 수정 진입 복호화도 저장과 같이 다룬다. 화면이 통째로 바뀌는 것을 기다리는 동안이라 + // 진행 표시가 필요하고, 창이 잠기면 그 사이 눈·복사가 끼어들 수 없다. + // + // 눈·복사가 유발한 복호화는 여기에 넣지 않는다 — 창을 잠그면 "나중 요청이 앞 요청을 + // 대체한다"는 규칙(``SecretDetailFeature/CancelID/reveal``)이 눌러볼 수 없게 되어 사라진다. + .windowBusy(store.isSaving || store.isEnteringEdit) // `id:`가 필수다. 다른 리스트 셀을 선택하면 MainFeature가 `secretDetail`에 새 State를 // 할당하지만, 뷰의 타입·위치가 그대로라 SwiftUI는 뷰를 재생성하지 않는다. 그러면 평범한 // `.task`는 다시 실행되지 않아 `linkedProjects` / `payloadState`가 빈 채로 남는다 @@ -175,9 +184,9 @@ extension SecretDetailView { detectedServices: [:], onCreateProject: { store.send(.didTapCreateProject) } ) - // 진행 표시는 창 루트가 그린다 (`.omc/GUIDELINES.md`). + // 진행 표시는 창 루트가 그린다 (`.omc/GUIDELINES.md`). 알리는 것은 `body`가 + // 한곳에서 하고, 여기서는 입력만 잠근다 — 잠금은 진행 표시와 별개다. .disabled(store.isSaving) - .windowBusy(store.isSaving) .environment(\.isProjectLoading, store.isLoadingProjects) } } diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index 327a30bc..e8f3f6e0 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -368,6 +368,38 @@ struct SecretDetailFeatureTests { #expect(store.state.revealedFields.isEmpty) } + /// 수정 진입 복호화와 눈·복사 복호화는 취소 그룹이 달라 서로를 대체하지 않는다. 막지 않으면 + /// 인증 시트가 둘 뜨고 늦게 온 응답이 먼저 온 응답의 continuation을 덮어쓴다. + /// `revealPayload`를 스텁하지 않아, 두 번째 복호화가 시작되면 실패한다. + @Test("수정 진입 복호화 중에는 눈·복사가 두 번째 복호화를 시작하지 않는다") + func reveal_whileEnteringEdit_startsNoSecondDecrypt() async { + var state = SecretDetailFeature.State(secret: Self.makeSecret()) + state.payloadState = .loading + state.isEnteringEdit = true + + let store = TestStore(initialState: state) { SecretDetailFeature() } + + await store.send(.didTapToggleReveal(.value)) + await store.send(.didTapCopy(.value)) + #expect(store.state.payloadState == .loading) + #expect(store.state.revealedFields.isEmpty) + } + + /// 막는 것은 새 복호화를 시작하는 방향뿐이다. 닫기는 노출을 줄이므로 언제나 통해야 한다. + @Test("수정 진입 복호화 중에도 열린 필드는 닫을 수 있다") + func closeRevealed_whileEnteringEdit_stillCloses() async { + var state = SecretDetailFeature.State(secret: Self.makeSecret()) + state.payloadState = .loading + state.isEnteringEdit = true + state.revealedFields = [.value] + + let store = TestStore(initialState: state) { SecretDetailFeature() } + + await store.send(.didTapToggleReveal(.value)) { + $0.revealedFields = [] + } + } + /// 닫는 방향은 노출을 줄이므로 인증을 타지 않는다. @Test("열린 필드를 닫을 때는 인증하지 않는다") func toggleReveal_closing_needsNoAuthentication() async { @@ -775,10 +807,12 @@ struct SecretDetailFeatureTests { await store.send(.didTapEdit) { $0.isLoadingProjects = true $0.payloadState = .loading + $0.isEnteringEdit = true } await store.receive(.payloadResponse(.success(payload), then: .edit)) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate + $0.isEnteringEdit = false $0.mode = .editing $0.editFields = SecretMetaFields(secret: secret, payload: payload, projectIds: []) $0.editFieldsBaseline = $0.editFields @@ -805,9 +839,11 @@ struct SecretDetailFeatureTests { await store.send(.didTapEdit) { $0.isLoadingProjects = true $0.payloadState = .loading + $0.isEnteringEdit = true } await store.receive(.payloadResponse(.failure(.authenticationFailure(.cancelled)), then: .edit)) { $0.payloadState = .failed(.authenticationFailure(.cancelled)) + $0.isEnteringEdit = false $0.alert = .payloadRevealFailed(.authRequired) } } From 2601c20353e35f59061803d62cc94c85128da6e4 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:59:07 +0900 Subject: [PATCH 25/61] =?UTF-8?q?[#76]=20docs:=20=ED=8F=BC=20=ED=8F=AD=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A3=BC=EC=84=9D=EC=9D=B4=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=8F=99=EC=9E=91=EA=B3=BC=20=EB=B0=98=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EB=82=A8=EC=95=84=20=EC=9E=88=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVPresentation/Sources/Form/FormLayoutContext.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift b/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift index 66d982a6..2d4c00c5 100644 --- a/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift +++ b/Projects/DVPresentation/Sources/Form/FormLayoutContext.swift @@ -20,8 +20,9 @@ protocol FormLayoutContext { /// 폼이 창 전체를 쓰는 **단일 화면** 맥락 — CreateSecret. /// -/// 사이즈는 항상 고정(`DVComponentSize` 그대로)이다. 좁아지면 2열 폼이 1열로 접히고 -/// full-width 필드가 `.lg` → `.md`로 줄어든다. +/// 두 배열 모두 폭은 컨테이너를 따른다. 좁아지면 2열 폼이 1열로 접히고, 접힌 뒤에는 +/// 다시 컨테이너를 따라 연속으로 줄어든다. 하한만 다르다 — `.dual`은 `slotFloor`, +/// `.single`은 한 칸이 창 전체를 쓰므로 `standaloneSlotFloor`. enum StandaloneFormLayout: FormLayoutContext { static func layout(for containerWidth: CGFloat) -> FormLayout { @@ -34,7 +35,7 @@ enum StandaloneFormLayout: FormLayoutContext { /// NavigationSplitView **detail 컬럼**에 놓이는 폼 맥락 — SecretDetail (2분할 화면). /// /// 단일 화면과 다른 점은 **임계값 아래에서의 배열**이다: -/// - 단일 화면은 고정 `.md` + 페어를 세로로 접음 +/// - 단일 화면은 페어를 세로로 접음 /// - detail 컬럼은 컨테이너를 채우고 페어를 절반씩 가로 유지 (Figma `1768:30806`) /// /// detail 컬럼은 폭 상한이 없다(`navigationSplitViewColumnWidth(min: 420, ideal: 480)`). From 343bb1d2504d3ba146ff20314eb8397a1f0b9671 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:57:38 +0900 Subject: [PATCH 26/61] =?UTF-8?q?[#76]=20feat:=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A5=BC=20=EC=97=AC=EB=9F=AC=20=EC=A4=84=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=ED=95=84=EB=93=9C=EB=A1=9C=20=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/Fields/MemoFieldView.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/MemoFieldView.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/MemoFieldView.swift index bb64245e..53575336 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/MemoFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Components/Fields/MemoFieldView.swift @@ -2,8 +2,13 @@ import SwiftUI -/// CreateSecret 폼의 Memo 필드 (모든 secretType 공통, optional). -/// `SecretMetaFields.memo`에 바인딩. Figma 실측상 단일 라인 텍스트 필드 (32pt). +/// 폼의 Memo 필드 (모든 secretType 공통, optional). +/// `SecretMetaFields.memo`에 바인딩하며, 생성 화면과 수정 화면이 `FormSectionScaffold`를 통해 +/// 이 필드 하나를 공유한다. +/// +/// 여러 줄 입력이다. 메모는 본질적으로 자유 서술이라 한 줄에 담기지 않는 경우가 흔한데, +/// 단일 라인으로 두면 긴 메모가 가로로만 흐르고 앞부분이 잘려 **이미 적어둔 내용을 확인하며 +/// 고칠 수가 없다.** 조회 화면은 `DVTextContainer`가 개행을 그대로 그리므로 별도 대응이 필요 없다. struct MemoFieldView: View { @Binding var memo: String @@ -13,6 +18,7 @@ struct MemoFieldView: View { label: .module("Memo"), placeholder: .module("optional"), text: $memo, + isMultiline: true, sizeMode: .fullWidth ) } @@ -36,6 +42,20 @@ struct MemoFieldView: View { .previewWidth(.narrow) } +/// 개행이 든 값. 높이가 고정이라 넘치는 줄은 스크롤로 넘어가는지 함께 본다. +#Preview("Multiline · Dual") { + MemoFieldPreview( + initial: """ + 분기마다 교체한다 + 교체 후 #infra 채널에 공유 + 만료 알림은 30일 전부터 + """ + ) + .padding() + .formLayout(.dual) + .previewWidth(.wide) +} + private struct MemoFieldPreview: View { @State private var memo: String From 45cd4b8f96a20c54cb40bfacb7334d129c13a329 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:06:32 +0900 Subject: [PATCH 27/61] =?UTF-8?q?[#76]=20fix:=20assertionFailure=EC=97=90?= =?UTF-8?q?=20=ED=8F=89=EB=AC=B8=20=EC=8B=9C=ED=81=AC=EB=A6=BF=EC=9D=B4=20?= =?UTF-8?q?=EC=8B=A4=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20cas?= =?UTF-8?q?e=20=EC=9D=B4=EB=A6=84=EB=A7=8C=20=EB=82=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Model/CreateSecretPayload+Diff.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift index 9c29e148..fe609267 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Model/CreateSecretPayload+Diff.swift @@ -50,7 +50,11 @@ extension CreateSecretPayload { // 서브타입이 고정이므로 도달할 수 없다. 도달했다면 매핑 버그이고, 그때 변경 없음으로 // 처리하면 사용자 입력이 조용히 사라진다 — 보수적으로 둘 다 다시 쓴다. default: - assertionFailure("서브타입이 다른 payload 조합: \(baseline) → \(updated)") + // payload를 그대로 보간하면 연관값의 평문(API 키·개인키·클라이언트 시크릿)이 + // 문자열에 실린다. `assertionFailure` 메시지는 Debug 크래시 리포트에 남으므로 + // 리포트가 수집·공유되는 순간 시크릿이 함께 나간다. 진단에 필요한 것은 어느 case끼리 + // 어긋났는지뿐이라 값 없는 이름만 남긴다. + assertionFailure("서브타입이 다른 payload 조합: \(baseline.caseName) → \(updated.caseName)") return (true, true) } } @@ -89,4 +93,25 @@ extension CreateSecretPayload { case .environmentVariableSet, .custom: return false } } + + /// 값을 뺀 case 이름. **진단 문자열에는 이것만 쓴다** — payload를 그대로 보간하면 + /// 연관값의 평문 시크릿이 로그·크래시 리포트로 새어 나간다. + /// + /// `String(describing:)`이나 `Mirror`로도 이름을 얻을 수 있지만 둘 다 값에 닿는다. + /// 여기서 명시적으로 나열하면 값이 실릴 길 자체가 없고, case가 늘면 컴파일러가 알려준다. + var caseName: String { + switch self { + case .apiKey: return "apiKey" + case .accessToken: return "accessToken" + case .webhookSecret: return "webhookSecret" + case .oauthClient: return "oauthClient" + case .serviceAccount: return "serviceAccount" + case .database: return "database" + case .sshKey: return "sshKey" + case .sslTlsCertificate: return "sslTlsCertificate" + case .environmentVariableSet: return "environmentVariableSet" + case .licenseKey: return "licenseKey" + case .custom: return "custom" + } + } } From e87c62830a4873a7bf616ed37c25ab61a6665c4f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:16 +0900 Subject: [PATCH 28/61] =?UTF-8?q?[#76]=20feat:=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=EC=97=90=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=EB=9D=BC=EB=B2=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVPresentation/Resources/Localizable.xcstrings | 3 +++ .../DVPresentation/Sources/Support/WindowBusyOverlay.swift | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 1900c236..6ac01291 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -549,6 +549,9 @@ "comment" : "Additional text below the button to inform the user that if Touch ID is unavailable, the system password will be used.", "isCommentAutoGenerated" : true }, + "In progress" : { + "comment" : "Accessibility label for the full-window overlay shown while a save or decryption is running. Tells assistive technology users that the window is temporarily locked." + }, "Individual" : { "comment" : "Description of a license tier when the user is an individual.", "isCommentAutoGenerated" : true diff --git a/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift b/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift index 3745f97c..2a218864 100644 --- a/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift +++ b/Projects/DVPresentation/Sources/Support/WindowBusyOverlay.swift @@ -44,6 +44,11 @@ extension View { // 아래로 클릭이 새지 않게 막는다. 진행 중에는 창 전체가 잠긴 것이 맞다. .allowsHitTesting(true) .ignoresSafeArea() + // 잠갔다는 사실을 보조 기술에도 알린다. `ProgressView`만으로는 읽어 주지 않아 + // VoiceOver 사용자에게는 창이 그냥 반응하지 않는 것으로만 보인다. + .accessibilityElement(children: .ignore) + .accessibilityLabel(String.module("In progress")) + .accessibilityAddTraits(.updatesFrequently) } } } From 9769ea43156dd3dc06259e2f55d47f4de64bc0bd Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:07:44 +0900 Subject: [PATCH 29/61] =?UTF-8?q?[#76]=20docs:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=97=AC=ED=8D=BC=20=EC=A3=BC=EC=84=9D=EC=9D=84=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EB=8C=80=EC=83=81=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/SecretDetail/SecretDetailFeatureTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index e8f3f6e0..3bc7ba63 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -1340,7 +1340,6 @@ extension SecretDetailFeatureTests { .apiKey(APIKeyPayload(value: "ghp_x"), APIKeyMetadata(scope: "repo:read")) } - /// 편집 진입을 마친 상태. `beginEditing`이 세우는 것과 같은 조합이어야 한다. /// 수정 버튼을 누를 수 있는 조회 상태. 연결 목록을 읽어 오기 전에는 수정 진입이 막히므로, /// 진입 자체가 관심사가 아닌 테스트도 이 상태에서 출발해야 한다. fileprivate static func readyToEditState( @@ -1354,6 +1353,7 @@ extension SecretDetailFeatureTests { return state } + /// 편집 진입을 마친 상태. `beginEditing`이 세우는 것과 같은 조합이어야 한다. fileprivate static func editingState( secret: Secret = makeSecret(name: "GitHub Token"), projectIds: [Project.ID] = [] From daafa4cadc7c11c91c3760ab1cb52980021df6c4 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:12:55 +0900 Subject: [PATCH 30/61] =?UTF-8?q?[#96]=20chore:=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A0=91=EA=B7=BC=EC=84=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=EA=B3=BC=20=EC=A3=BD=EC=9D=80=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/DVMultilineTextContainer.swift | 292 ------------------ .../Components/DVMultilineTextField.swift | 1 - .../DetailSSLRequiredFieldView.swift | 6 + .../SecretDetail/SecretDetailFeature.swift | 10 +- .../SecretDetailFeatureTests.swift | 54 +--- 5 files changed, 18 insertions(+), 345 deletions(-) delete mode 100644 Projects/DVDesign/Sources/Components/DVMultilineTextContainer.swift diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextContainer.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextContainer.swift deleted file mode 100644 index 384be7db..00000000 --- a/Projects/DVDesign/Sources/Components/DVMultilineTextContainer.swift +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -import SwiftUI - -/// 여러 줄 값을 **표시만** 하는 읽기 전용 컨테이너. ``DVMultilineTextField``의 read-only counterpart. -/// -/// ``DVTextField``↔``DVTextContainer``와 같은 관계다. 다만 ``DVTextContainer``가 한 줄만 보여주는 것과 달리, -/// 개행이 포함된 값(PEM 블록, JSON, `KEY=value` 목록 등)을 **줄 구조 그대로 전부** 노출한다. -/// -/// ## 시각 상태 -/// -/// | 상태 | 설명 | -/// |------|------| -/// | Filled | 본문 텍스트가 ``DVColor/gray900`` 으로 여러 줄 표시 | -/// | Empty | `text == ""` — 박스만 보이고 콘텐츠 없음 | -/// | hasAccessories | 우측 상단에 **인터랙티브** 액세서리(복사·표시 토글 등) 배치 | -/// -/// 배경(``DVColor/gray300``)·corner radius·좌우 padding·본문 폰트(``DVFont/bodyLG``)는 -/// ``DVTextContainer``와 **같은 값**이다. 한 화면에서 한 줄 필드와 여러 줄 필드가 섞여도 두 박스가 -/// 같은 계열로 읽혀야 하기 때문이다. -/// -/// ## 높이 -/// -/// 너비는 ``DVComponentSize``, 높이는 `height`로 **고정**한다 — ``DVMultilineTextField``와 같은 정책이며 -/// 기본값(100pt)도 같다. 값 길이에 따라 박스가 늘어나면 폼의 행 높이가 데이터마다 달라지므로 고정한다. -/// -/// ## 오버플로우 처리 -/// -/// 내용이 `height`를 넘으면 **세로 스크롤**된다. 긴 줄은 컨테이너 폭에서 접히므로(soft wrap) -/// 가로 스크롤은 쓰지 않는다 — 두 축을 모두 조작해야 값 전체를 확인할 수 있는 상황을 만들지 않기 위해서다. -/// -/// 스크롤 인디케이터는 ``DVTextContainer``와 달리 **표시한다**. 이 컴포넌트가 존재하는 이유 자체가 -/// "가려진 내용을 인지할 수 없다"는 문제이므로, 더 볼 것이 있다는 신호를 지우면 안 된다. -/// macOS overlay scroller라 스크롤·호버 시에만 나타나 평소 시각적 비용은 없다. -/// -/// ## 텍스트 선택·복사 -/// -/// 본문에 `.textSelection(.enabled)`이 적용되어 있어 드래그·⌘+C로 원하는 구간만 복사할 수 있다. -/// 세로 스크롤로 가려진 부분도 드래그를 이어가며 선택 가능하다. -/// -/// ## 액세서리 -/// -/// 액세서리는 우측 **상단**에 고정된다. 근거는 세 가지다: -/// -/// 1. 박스가 100pt 안팎으로 높아, 세로 중앙에 두면 버튼이 본문 한가운데에 떠서 특정 줄에 걸린 것처럼 보인다. -/// 2. 본문을 스크롤해도 버튼이 움직이지 않아 위치를 다시 찾을 필요가 없다. -/// 3. 값이 짧든 길든 버튼이 항상 같은 자리에 있어, 여러 필드가 세로로 쌓였을 때 액세서리 열이 흔들리지 않는다. -/// -/// 마스킹·클립보드 같은 동작은 컨테이너가 갖지 않는다 — ``DVTextContainer``와 마찬가지로 -/// 호출자가 `text`의 source of truth를 소유하고 컨테이너는 그 값을 그릴 뿐이다(상태 끌어올리기). -/// -/// ## 사용 -/// -/// ```swift -/// // 단순 표시 (액세서리 없음) -/// DVMultilineTextContainer(pemBlock, size: .md) -/// -/// // 마스킹 토글 + 복사 버튼을 직접 구성 -/// @State var isRevealed = false -/// DVMultilineTextContainer(isRevealed ? value : masked, size: .md) { -/// HStack(spacing: 10) { -/// Button { copy(value) } label: { Image(systemName: "doc.on.doc") } -/// Button { isRevealed.toggle() } label: { -/// Image(systemName: isRevealed ? "eye.slash" : "eye") -/// } -/// } -/// .font(.system(size: 11)) -/// .foregroundStyle(Color.dv(.gray900)) -/// .buttonStyle(.plain) -/// } -/// ``` -// MARK: - Metrics - -/// ``DVMultilineTextContainer``의 레이아웃 상수. -/// 제네릭 타입 안에 중첩하면 이 enum도 제네릭이 되어 static stored property를 가질 수 없다 — -/// ``DVMultiSelectDropdown``의 `DropdownMetrics`와 같은 이유로 파일 스코프에 둔다. -fileprivate enum MultilineContainerMetrics { - static let cornerRadius: CGFloat = 6 - static let leadingPadding: CGFloat = 8 - - /// 본문과 액세서리 사이 최소 간격. ``DVTextContainer``와 같은 값. - static let contentSpacing: CGFloat = 8 - - /// 상하 padding. 한 줄 컨테이너 박스에서 ``DVFont/bodyLG`` 한 줄이 갖는 여백과 같아, - /// 첫 줄이 ``DVTextContainer``와 같은 높이에서 시작한다. 박스 높이가 바뀌면 함께 따라가도록 - /// 리터럴 대신 토큰에서 파생시킨다. - static let verticalPadding: CGFloat = (DVComponentSize.fieldHeight - DVFont.bodyLG.lineHeight) / 2 -} - -public struct DVMultilineTextContainer: View { - - private typealias Metrics = MultilineContainerMetrics - - // MARK: - Properties - - private let text: String - private let size: DVComponentSize - private let height: CGFloat - private let textColor: DVColor - private let accessories: () -> Accessories - - /// 우측 padding (포인트). ``DVTextContainer``와 같은 규칙 — 액세서리가 있으면 4pt, - /// 없으면 좌우 대칭의 8pt. - private let trailingPadding: CGFloat - - // MARK: - Init - - /// 액세서리가 있는 여러 줄 텍스트 컨테이너를 생성합니다. - /// - /// - Parameters: - /// - text: 박스에 표시될 본문. 개행이 그대로 유지되며, 긴 줄은 컨테이너 폭에서 접힙니다. - /// 빈 문자열을 전달하면 Empty 상태가 됩니다. - /// - size: 너비 변형. 기본값 ``DVComponentSize/md``. - /// - height: 고정 높이. 기본값 100pt — ``DVMultilineTextField``와 같은 값입니다. - /// 내용이 이 높이를 넘으면 내부에서 세로 스크롤됩니다. - /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. - /// - accessories: 박스 우측 상단에 배치될 뷰를 만드는 빌더. 버튼이 외부 `@State`를 갱신하면 - /// 다음 렌더에서 `text` 인자가 바뀌어 본문 표시도 함께 갱신됩니다. - public init( - _ text: String, - size: DVComponentSize = .md, - height: CGFloat = 100, - textColor: DVColor = .gray900, - @ViewBuilder accessories: @escaping () -> Accessories - ) { - self.text = text - self.size = size - self.height = height - self.textColor = textColor - self.accessories = accessories - self.trailingPadding = 4 - } - - // MARK: - Body - - public var body: some View { - HStack(alignment: .top, spacing: Metrics.contentSpacing) { - scrollingText - accessories() - } - .padding(.leading, Metrics.leadingPadding) - .padding(.trailing, trailingPadding) - .padding(.vertical, Metrics.verticalPadding) - .dvComponentWidth(size, height: height, alignment: .topLeading) - .background { - RoundedRectangle(cornerRadius: Metrics.cornerRadius) - .fill(Color.dv(.gray300)) - } - } -} - -// MARK: - Subviews - -extension DVMultilineTextContainer { - - private var scrollingText: some View { - ScrollView(.vertical) { - Text(text) - .font(DVFont.bodyLG.font) - .foregroundStyle(Color.dv(textColor)) - .lineSpacing(DVFont.bodyLG.lineSpacing) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } -} - -// MARK: - Convenience Init - -extension DVMultilineTextContainer where Accessories == EmptyView { - - /// 액세서리 없는 여러 줄 텍스트 컨테이너를 생성합니다. - /// - /// 액세서리가 없으므로 좌우 8pt 대칭 padding이 적용되어 본문이 박스 양 끝에서 균등하게 떨어집니다. - /// - /// - Parameters: - /// - text: 박스에 표시될 본문. - /// - size: 너비 변형. 기본값 ``DVComponentSize/md``. - /// - height: 고정 높이. 기본값 100pt. - /// - textColor: 본문 텍스트 색상 토큰. 기본값 ``DVColor/gray900``. - public init( - _ text: String, - size: DVComponentSize = .md, - height: CGFloat = 100, - textColor: DVColor = .gray900 - ) { - self.text = text - self.size = size - self.height = height - self.textColor = textColor - self.accessories = { EmptyView() } - self.trailingPadding = 8 - } -} - -// MARK: - Previews - -#if DEBUG - -#Preview("한 줄 값") { - DVMultilineTextContainer("DeVault", size: .md) - .padding() -} - -#Preview("여러 줄 값") { - DVMultilineTextContainer( - """ - DATABASE_URL=postgres://user:pass@localhost:5432/mydb - SECRET_KEY=abc123 - """, - size: .md - ) - .padding() -} - -#Preview("높이 초과 · 세로 스크롤") { - DVMultilineTextContainer(DVMultilineTextContainerPreviewData.overflowing, size: .md) - .padding() -} - -#Preview("Empty") { - DVMultilineTextContainer("", size: .md) - .padding() -} - -#Preview("Accessories") { - DVMultilineTextContainerAccessoriesPreview() - .padding() -} - -#Preview("Sizes") { - VStack(alignment: .leading, spacing: 12) { - DVMultilineTextContainer("XS\nsecond line", size: .xs, height: 80) - DVMultilineTextContainer("SM\nsecond line", size: .sm, height: 80) - DVMultilineTextContainer("MD\nsecond line", size: .md, height: 80) - DVMultilineTextContainer("LG\nsecond line", size: .lg) - } - .padding() -} - -private enum DVMultilineTextContainerPreviewData { - /// 고정 높이(100pt)를 넘겨 세로 스크롤을 유도하는 값. - /// - /// 개인키 형태의 문자열은 쓰지 않는다 — 값이 가짜여도 시크릿 스캐너가 탐지 결과를 올려서, - /// 이 파일을 건드리는 모든 PR에 같은 지적이 반복된다. - static let overflowing = """ - DATABASE_URL=postgres://user:pass@localhost:5432/mydb - REDIS_URL=redis://localhost:6379/0 - SMTP_HOST=smtp.example.com - SMTP_PORT=587 - LOG_LEVEL=debug - FEATURE_SYNC_ENABLED=true - RETRY_BACKOFF_SECONDS=30 - """ -} - -private struct DVMultilineTextContainerAccessoriesPreview: View { - @State private var isRevealed = false - - private let value = """ - DATABASE_URL=postgres://user:pass@localhost:5432/mydb - SECRET_KEY=abc123 - """ - - /// 여러 줄 값의 마스킹은 줄 단위로 `•`를 채워 줄 구조를 남기는 것이 호출부 권장 패턴이다. - private var masked: String { - value - .split(separator: "\n", omittingEmptySubsequences: false) - .map { String(repeating: "•", count: $0.count) } - .joined(separator: "\n") - } - - var body: some View { - DVMultilineTextContainer(isRevealed ? value : masked, size: .md) { - HStack(spacing: 10) { - Button(action: {}) { - Image(systemName: "doc.on.doc") - } - Button { - isRevealed.toggle() - } label: { - Image(systemName: isRevealed ? "eye.slash" : "eye") - } - } - .font(.system(size: 11)) - .foregroundStyle(Color.dv(.gray900)) - .buttonStyle(.plain) - } - } -} - -#endif diff --git a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift index 6466c4f4..5f3b3c36 100644 --- a/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift +++ b/Projects/DVDesign/Sources/Components/DVMultilineTextField.swift @@ -161,7 +161,6 @@ extension DVMultilineTextField { /// 줄 구조를 유지한 마스킹 값. 전체를 하나의 `•` 덩어리로 만들지 않는다 — /// 가려진 상태에서도 몇 줄이 채워졌는지 확인할 수 있어야 한다. - /// 조회 화면(``DVMultilineTextContainer`` 호출부)이 쓰는 규칙과 같다. private var maskedValue: String { text .split(separator: "\n", omittingEmptySubsequences: false) diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailSSLRequiredFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailSSLRequiredFieldView.swift index 4443aea3..3043eb6f 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailSSLRequiredFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailSSLRequiredFieldView.swift @@ -20,6 +20,12 @@ struct DetailSSLRequiredFieldView: View { .foregroundStyle(Color.dv(.gray700)) DVCheckBox(readOnly: isRequired) } + // 체크 상태를 **값으로 따로 읽어 준다.** `DVCheckBox(readOnly:)`는 비인터랙티브 도형이라 + // 접근성 트리에 아무것도 남기지 않는다 — 묶기만 하면 VoiceOver가 "SSL Required"만 읽고 + // 정작 필요한 켜짐/꺼짐은 알려주지 않는다. 다른 조회 필드가 라벨 + 값으로 읽히므로 + // 이 행만 값이 비면 목록을 훑을 때 한 칸이 빈 것처럼 들린다. + .accessibilityElement(children: .combine) + .accessibilityValue(Text(isRequired ? .module("Required") : .module("Not Required"))) } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift index 60d8a4f1..08312763 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailFeature.swift @@ -157,9 +157,10 @@ public struct SecretDetailFeature { case task case binding(BindingAction) case didTapClose - /// payload 복호화 재시도. 인증 취소로 `.failed`가 된 뒤 다시 시도할 유일한 경로다. - case didTapRetryReveal /// 필드의 눈 버튼. 여는 경우에만 인증이 필요할 수 있고, 닫는 것은 언제나 즉시 처리된다. + /// + /// **복호화 실패 후의 재시도 경로이기도 하다.** `.failed`에서 다시 누르면 그 필드를 + /// continuation에 실은 채로 복호화를 새로 걸므로, 성공하면 누른 필드가 실제로 열린다. case didTapToggleReveal(SecretFieldID) /// 필드의 복사 버튼. Copy UseCase가 설정에 따라 인증하고, payload가 없으면 화면에 /// 공개하지 않는 Copy 전용 경로로 먼저 복호화한다. @@ -272,11 +273,6 @@ public struct SecretDetailFeature { } ) - // 복호화 실패 후 재시도. 어떤 필드가 유발했는지는 이미 잃었으므로 값만 다시 받아온다. - case .didTapRetryReveal: - state.payloadState = .loading - return revealEffect(secret: state.secret, then: .none) - // 디자인에서 close(×) 버튼을 제거했으므로 현재 이 액션을 발생시키는 UI 경로가 없다. // detail은 사이드바 전환·리스트 선택 해제(`secretSelected(nil)`)로 닫힌다. // 삭제 성공 후 닫기에서 재사용할 예정이라 액션과 delegate는 유지한다. diff --git a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift index 3bc7ba63..900ac94a 100644 --- a/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretDetail/SecretDetailFeatureTests.swift @@ -205,49 +205,10 @@ struct SecretDetailFeatureTests { // MARK: - payload 재시도 - // `didTapRetryReveal`은 effect가 reveal 하나뿐이라 수신 순서를 맞출 필요가 없다. - - @Test("didTapRetryReveal 성공: payloadState .loading → .loaded") - func retryReveal_success() async { - let secret = Self.makeSecret() - let payload = CreateSecretPayload.apiKey(APIKeyPayload(value: "test_token"), nil) - - let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { - SecretDetailFeature() - } withDependencies: { - $0.secretClient.revealPayload = { _, _ in payload } - $0.date = .constant(Self.referenceDate) - } - - await store.send(.didTapRetryReveal) { - $0.payloadState = .loading - } - await store.receive(.payloadResponse(.success(payload), then: .none)) { - $0.payloadState = .loaded(payload) - $0.revealAuthorizedAt = Self.referenceDate - } - } - - @Test("didTapRetryReveal 실패: payloadState .failed + alert 노출") - func retryReveal_failure() async { - let secret = Self.makeSecret() - - let store = TestStore(initialState: SecretDetailFeature.State(secret: secret)) { - SecretDetailFeature() - } withDependencies: { - $0.secretClient.revealPayload = { _, _ in throw SecretUseCaseError.cryptoFailure(.decryptionFailed) } - } - - await store.send(.didTapRetryReveal) { - $0.payloadState = .loading - } - await store.receive(.payloadResponse(.failure(.cryptoFailure(.decryptionFailed)), then: .none)) { - $0.payloadState = .failed(.cryptoFailure(.decryptionFailed)) - $0.alert = .payloadRevealFailed(.decryptionFailed) - } - } - - @Test("인증 취소 후 재시도: alert를 닫고 다시 시도하면 payload가 노출된다") + /// 실패 후 재시도는 **눈 버튼이 그대로 맡는다.** `.failed`에서 다시 누르면 그 필드를 + /// continuation에 실은 채 복호화를 새로 걸므로, 성공하면 누른 필드가 실제로 열린다 — + /// 이 테스트가 확인하는 것이 그 점이고, 전용 재시도 액션이 필요 없는 근거다. + @Test("인증 취소 후 재시도: alert를 닫고 눈 버튼을 다시 누르면 그 필드가 열린다") func retryReveal_afterAuthenticationCancellation() async { let secret = Self.makeSecret() let payload = CreateSecretPayload.apiKey(APIKeyPayload(value: "test_token"), nil) @@ -282,12 +243,15 @@ struct SecretDetailFeatureTests { $0.alert = nil } - await store.send(.didTapRetryReveal) { + await store.send(.didTapToggleReveal(.value)) { $0.payloadState = .loading } - await store.receive(.payloadResponse(.success(payload), then: .none)) { + await store.receive(.payloadResponse(.success(payload), then: .reveal(.value))) { $0.payloadState = .loaded(payload) $0.revealAuthorizedAt = Self.referenceDate + // 전용 재시도 액션은 continuation을 잃어 값만 받아왔다 — 인증을 통과하고도 + // 모든 필드가 마스킹된 채 남아 아무 일도 안 일어난 것처럼 보였다. + $0.revealedFields = [.value] } // 매 시도가 생체인증을 새로 타므로 호출 횟수 자체가 재시도 성립의 근거다. #expect(attemptCount.value == 2) From df20d6f86d250f36bcf651ee22daa05317bde326 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 00:58:06 +0900 Subject: [PATCH 31/61] =?UTF-8?q?[#76]=20feat:=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20defaultEnvironment=EC=9D=84=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateSecret/CreateSecretFeature.swift | 9 ++++ .../CreateSecretFeatureTests.swift | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift index e0dd626f..26e4b91c 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift @@ -106,6 +106,7 @@ public struct CreateSecretFeature { @Dependency(\.secretManagementClient) var secretManagementClient @Dependency(\.projectClient) var projectClient @Dependency(\.detectionClient) var detectionClient + @Dependency(\.generalSettingsClient) var generalSettingsClient @Dependency(\.date.now) var now // MARK: - Init @@ -121,6 +122,14 @@ public struct CreateSecretFeature { // MARK: View case .task: + // 설정의 기본 환경을 폼 초기값으로 얹는다. 이 화면은 열 때마다 State가 새로 + // 만들어지므로(`MainFeature`) 사용자가 고른 값을 덮어쓸 창이 없다. + // + // 저장된 문자열이 폼 enum에 없으면 `.dev`로 떨어뜨린다 — 설정 화면이 읽을 때와 + // 같은 규칙이다(`GeneralSettingsFeature.task`). + state.meta.environment = SecretEnvironment( + rawValue: generalSettingsClient.defaultEnvironment() + ) ?? .dev state.isLoadingProjects = true return .run { send in do { diff --git a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift index a3948693..5a0b307b 100644 --- a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift @@ -23,6 +23,8 @@ struct CreateSecretFeatureTests { let store = TestStore(initialState: .init(secretType: .apiKeyToken)) { CreateSecretFeature() } withDependencies: { + // 기본 환경은 이 테스트의 관심사가 아니다. State 초기값과 같은 값을 물려 폼이 그대로이게 한다. + $0.generalSettingsClient.defaultEnvironment = { "dev" } $0.projectClient.fetchProjects = { projects } } @@ -40,6 +42,7 @@ struct CreateSecretFeatureTests { let store = TestStore(initialState: .init(secretType: .apiKeyToken)) { CreateSecretFeature() } withDependencies: { + $0.generalSettingsClient.defaultEnvironment = { "dev" } $0.projectClient.fetchProjects = { throw ProjectUseCaseError.repositoryFailure(.storageUnavailable) } @@ -60,6 +63,7 @@ struct CreateSecretFeatureTests { let store = TestStore(initialState: .init(secretType: .apiKeyToken)) { CreateSecretFeature() } withDependencies: { + $0.generalSettingsClient.defaultEnvironment = { "dev" } $0.projectClient.fetchProjects = { throw ProjectUseCaseError.unexpected } @@ -74,6 +78,52 @@ struct CreateSecretFeatureTests { } } + // MARK: - 기본 환경(설정) 반영 + + /// 설정에서 고른 기본 환경이 생성 폼의 Environment 라디오에 미리 선택돼 있어야 한다. + @Test("task: 설정의 기본 환경이 폼 초기값으로 얹힌다") + func task_appliesDefaultEnvironmentFromSettings() async { + let store = TestStore(initialState: .init(secretType: .apiKeyToken)) { + CreateSecretFeature() + } withDependencies: { + $0.generalSettingsClient.defaultEnvironment = { "prod" } + $0.projectClient.fetchProjects = { [] } + } + + await store.send(.task) { + $0.meta.environment = .prod + $0.isLoadingProjects = true + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + } + + /// 앱이 환경 목록을 바꾼 뒤 이전 값이 설정에 남은 경우다. 설정 화면이 읽을 때와 같은 규칙으로 + /// `.dev`에 떨어져야 한다 — 폼이 빈 선택으로 열리면 안 된다. + @Test("task: 설정에 알 수 없는 환경이 저장돼 있으면 dev로 떨어진다") + func task_unknownDefaultEnvironment_fallsBackToDev() async { + // 초기값을 `.dev`가 아닌 것으로 두고 시작한다. `.dev`에서 출발하면 "폴백해서 dev가 됐다"와 + // "아무것도 하지 않았다"가 같은 결과라 단언이 공허해진다. + var initial = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.meta.environment = .prod + + let store = TestStore(initialState: initial) { + CreateSecretFeature() + } withDependencies: { + $0.generalSettingsClient.defaultEnvironment = { "canary" } + $0.projectClient.fetchProjects = { [] } + } + + await store.send(.task) { + $0.meta.environment = .dev + $0.isLoadingProjects = true + } + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + } + // MARK: - selectedSubType binding @Test("selectedSubType 변경: content 교체 + validationErrors / detection state 초기화") From 83e0efbc0aa18b4478dd1fb93bb81af6f70135fc Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:00 +0900 Subject: [PATCH 32/61] =?UTF-8?q?[#76]=20fix:=20reason=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=B9=A0=EC=A0=B8=20DVDomain=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=BB=B4=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증 문구를 호출자가 정하도록 `revealPayload`에 `reason`을 더하면서 이 테스트 호출부만 따라가지 않았다. 기본값이 없는 파라미터라 테스트 타겟이 통째로 컴파일되지 않는다. --- .../RevealSecretPayloadUseCaseImplTests.swift | 6 +- .../Resources/Localizable.xcstrings | 106 +++++++++++------- 2 files changed, 68 insertions(+), 44 deletions(-) diff --git a/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift b/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift index 7e7532ce..ee8e1de3 100644 --- a/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift +++ b/Projects/DVDomain/Tests/Core/UseCase/Secret/RevealSecretPayloadUseCaseImplTests.swift @@ -95,7 +95,11 @@ struct RevealSecretPayloadUseCaseImplTests { settingsRepository: settingsRepository ) - let revealed: APIKeyPayload = try await sut.revealPayload(id: secret.id, as: APIKeyPayload.self) + let revealed: APIKeyPayload = try await sut.revealPayload( + id: secret.id, + as: APIKeyPayload.self, + reason: AuthenticationReason.revealSecret + ) #expect(revealed == payload) #expect(auth.authenticateCount == 1) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 6ac01291..bc551f4a 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -15,14 +15,14 @@ "comment" : "A label for an option in the \"Auto-clear clipboard\" dropdown. The argument is the number of seconds selected.", "isCommentAutoGenerated" : true }, - "3 days before expiration" : { - "comment" : "Label for an expiry alert day option that corresponds to 3 days before expiration.", - "isCommentAutoGenerated" : true - }, "1 min" : { "comment" : "A selectable option in the settings picker row.", "isCommentAutoGenerated" : true }, + "3 days before expiration" : { + "comment" : "Label for an expiry alert day option that corresponds to 3 days before expiration.", + "isCommentAutoGenerated" : true + }, "5 min" : { "comment" : "A selectable option in the settings picker row.", "isCommentAutoGenerated" : true @@ -67,9 +67,6 @@ "Abnormal access detected" : { "comment" : "Title of the notification when an abnormal access is detected.", "isCommentAutoGenerated" : true - }, - "Allow DeVault in System Settings to launch it automatically when you log in." : { - }, "About" : { "comment" : "Title of the \"About\" settings category.", @@ -107,13 +104,6 @@ "Alert on repeated clipboard copies" : { "comment" : "Description of a setting that alerts the user when they copy from the clipboard more than once.", "isCommentAutoGenerated" : true - }, - "An unexpected error occurred. Your changes were not saved and are still here." : { - "comment": "Text displayed in a notification when an unexpected error occurred while saving changes.", - "isCommentAutoGenerated": true - }, - "Approval required" : { - }, "Alert timing" : { "comment" : "Label for the timing of the expiration alert.", @@ -121,9 +111,16 @@ }, "All" : { + }, + "Allow DeVault in System Settings to launch it automatically when you log in." : { + }, "An unexpected error occurred. Please try again." : { + }, + "An unexpected error occurred. Your changes were not saved and are still here." : { + "comment" : "Text displayed in a notification when an unexpected error occurred while saving changes.", + "isCommentAutoGenerated" : true }, "API Key" : { "comment" : "Label for the \"API Key\" option in the \"Create Secret\" screen.", @@ -141,6 +138,13 @@ "comment" : "Title of the settings section related to app shortcuts.", "isCommentAutoGenerated" : true }, + "Approval required" : { + + }, + "Ascending" : { + "comment" : "A label for the ascending sort direction.", + "isCommentAutoGenerated" : true + }, "Authentication" : { "comment" : "Section title in the settings view for authentication-related settings.", "isCommentAutoGenerated" : true @@ -153,10 +157,6 @@ "comment" : "A description of an abnormal access notification. The argument is the number of times authentication failed in a short period.", "isCommentAutoGenerated" : true }, - "Authentication is required to reveal this secret." : { - "comment" : "Alert 제목으로 사용.", - "isCommentAutoGenerated" : true - }, "Authentication required" : { }, @@ -284,10 +284,6 @@ }, "De" : { - }, - "Decrypting secret…" : { - "comment" : "Text shown while decrypting a secret.", - "isCommentAutoGenerated" : true }, "Default environment" : { "comment" : "Title of a section in the General Settings view that describes the default environment setting.", @@ -313,6 +309,10 @@ }, "Deleted" : { + }, + "Descending" : { + "comment" : "A label for the descending sort direction.", + "isCommentAutoGenerated" : true }, "Devault" : { "comment" : "Name of the company behind the app.", @@ -328,6 +328,10 @@ }, "Development" : { + }, + "Direction" : { + "comment" : "A label for the direction of sorting.", + "isCommentAutoGenerated" : true }, "Discard" : { @@ -348,10 +352,6 @@ "comment" : "Placeholder text for the private key field in the SSL/TLS certificate form section.", "isCommentAutoGenerated" : true }, - "e.g deploy.example.com" : { - "comment": "Placeholder text for a label.", - "isCommentAutoGenerated": true - }, "e.g {\"type\": \"service_account\", ...}" : { }, @@ -366,6 +366,10 @@ "comment" : "Placeholder text for the value field in the custom secret form.", "isCommentAutoGenerated" : true }, + "e.g deploy.example.com" : { + "comment" : "Placeholder text for a label.", + "isCommentAutoGenerated" : true + }, "e.g DeVault" : { "comment" : "Placeholder text for the Name field in the CreateSecret form.", "isCommentAutoGenerated" : true @@ -480,9 +484,21 @@ "comment" : "Title of the sidebar filter option for expired items.", "isCommentAutoGenerated" : true }, + "Expires in %lld days" : { + "comment" : "Text describing the expiry bucket for items that expire within 7 days.", + "isCommentAutoGenerated" : true + }, + "Expires within %lld days" : { + "comment" : "A tooltip text for the `SecretExpiryStatus.critical` case.", + "isCommentAutoGenerated" : true + }, + "Expiry" : { + "comment" : "A label displayed in the context menu.", + "isCommentAutoGenerated" : true + }, "Failed to copy" : { - "comment": "Text displayed in a notification when a value could not be copied to the clipboard.", - "isCommentAutoGenerated": true + "comment" : "Text displayed in a notification when a value could not be copied to the clipboard.", + "isCommentAutoGenerated" : true }, "Failed to delete secret" : { "comment" : "Text displayed in an alert when a secret deletion fails.", @@ -506,8 +522,8 @@ }, "Failed to save changes" : { - "comment": "Text displayed in a confirmation alert when an update operation fails.", - "isCommentAutoGenerated": true + "comment" : "Text displayed in a confirmation alert when an update operation fails.", + "isCommentAutoGenerated" : true }, "Failed to update favorite" : { "comment" : "Text displayed in an alert when updating a favorite status of a secret fails.", @@ -611,8 +627,8 @@ }, "No project selected" : { - "comment": "Text displayed when a project is not selected.", - "isCommentAutoGenerated": true + "comment" : "Text displayed when a project is not selected.", + "isCommentAutoGenerated" : true }, "No projects yet" : { @@ -704,8 +720,8 @@ }, "Project information could not be loaded. Other details are unaffected." : { - "comment": "Alert message when project details cannot be loaded.", - "isCommentAutoGenerated": true + "comment" : "Alert message when project details cannot be loaded.", + "isCommentAutoGenerated" : true }, "Project Name" : { @@ -756,8 +772,8 @@ "isCommentAutoGenerated" : true }, "Sample" : { - "comment": "Label for a text field.", - "isCommentAutoGenerated": true + "comment" : "Label for a text field.", + "isCommentAutoGenerated" : true }, "Save" : { "comment" : "The label of a button that saves the current input.", @@ -804,6 +820,10 @@ }, "Sign in to iCloud in System Settings, then try again." : { + }, + "Sort by" : { + "comment" : "A label for a picker that lets the user sort the list of secrets.", + "isCommentAutoGenerated" : true }, "SSH & Credentials" : { @@ -871,17 +891,13 @@ }, "The value could not be copied to the clipboard. Please try again." : { - "comment": "Alert message when a clipboard copy operation fails.", - "isCommentAutoGenerated": true + "comment" : "Alert message when a clipboard copy operation fails.", + "isCommentAutoGenerated" : true }, "This action cannot be undone." : { "comment" : "Warning text displayed above the \"Delete All Data\" button in the Data Settings view.", "isCommentAutoGenerated" : true }, - "This secret could not be decrypted." : { - "comment" : "Alert message when a secret cannot be decrypted.", - "isCommentAutoGenerated" : true - }, "This will also delete data from iCloud and all synced devices." : { "comment" : "Warning shown in Data Settings when iCloud Sync is enabled.", "isCommentAutoGenerated" : true @@ -890,6 +906,10 @@ "comment" : "Message displayed in an alert when the user confirms deleting all data.", "isCommentAutoGenerated" : true }, + "Time" : { + "comment" : "A label for sorting by time.", + "isCommentAutoGenerated" : true + }, "Try Again" : { "comment" : "Label for a button that triggers the action of retrying an operation.", "isCommentAutoGenerated" : true @@ -1019,4 +1039,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file From 3ef690aa8bef0124d1e2248322f965bde4a4751f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 03:59:45 +0900 Subject: [PATCH 33/61] =?UTF-8?q?[#76]=20fix:=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=93=9C=EB=82=98=EB=93=A4=20?= =?UTF-8?q?=EB=95=8C=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=EA=B0=80=20?= =?UTF-8?q?=EA=B9=9C=EB=B9=A1=EC=9D=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.task`는 최초 진입이 아니라 화면이 다시 만들어질 때마다 실행된다. `MainView`가 2컬럼 ↔ 3컬럼을 오가면 `NavigationSplitView` 타입이 달라져 사이드바까지 새로 만들어지는데, 그때마다 `.loading`으로 되돌려 목록과 숫자가 사라졌다 나타났다. - 이미 받아둔 값이 있으면 `.loading`으로 되돌리지 않고 `isRefreshingProjects`만 세운다. `countsRefreshRequested`가 이미 쓰던 규칙을 목록에도 적용했다. - 스피너를 걷어내고 opacity로 바꾼다. 이전 값이 있으면 흐리게 두고, 없으면 비워 두었다가 도착할 때 페이드인한다. 어느 쪽도 레이아웃이 튀지 않는다. --- .../Features/Sidebar/SidebarFeature.swift | 19 +++++++-- .../Features/Sidebar/SidebarView.swift | 29 +++++++++----- .../Tests/Main/MainFeatureTests.swift | 25 +++++++++--- .../Tests/Sidebar/SidebarFeatureTests.swift | 39 ++++++++++++++++++- 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 0bffeff3..181487b8 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -56,6 +56,8 @@ public struct SidebarFeature { var projectsState: LoadingState, SidebarError> = .idle /// "아직 안 불러옴"과 "0건"을 구분하기 위해 LoadingState로 감싼다 (TCA_GUIDELINES 2.4). var countsState: LoadingState = .idle + /// 이미 보여줄 값이 있는 채로 다시 받아오는 중. 화면은 이전 값을 두고 opacity만 낮춘다. + var isRefreshingProjects = false var renamingProjectID: ProjectItem.ID? var renameText: String = "" var deletingProjectID: ProjectItem.ID? @@ -151,22 +153,33 @@ public struct SidebarFeature { public var body: some ReducerOf { Reduce { state, action in switch action { + // `.task`는 최초 진입이 아니라 **화면이 다시 만들어질 때마다** 실행된다 — `MainView`가 + // 2컬럼 ↔ 3컬럼을 오가면 `NavigationSplitView` 타입이 달라져 사이드바까지 새로 만들어진다. + // 그때 `.loading`으로 되돌리면 목록과 숫자가 사라졌다 나타난다. case .task: - // 최초 진입에만 스피너를 보인다 — 보여줄 이전 값이 정말로 없는 유일한 경우다. - state.projectsState = .loading - state.countsState = .loading + if case .loaded = state.projectsState { + state.isRefreshingProjects = true + } else { + state.projectsState = .loading + } + if case .loaded = state.countsState {} else { + state.countsState = .loading + } return fetchProjectsEffect() case .refresh: + state.isRefreshingProjects = true return fetchProjectsEffect() case .projectsResponse(.success(let projects)): + state.isRefreshingProjects = false let sorted = projects.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } state.projectsState = .loaded(IdentifiedArray(uniqueElements: sorted)) // 프로젝트별 개수를 세려면 ID 목록이 필요하므로 목록 로드 성공 후에 이어서 집계한다. return countsEffect(projectIDs: sorted.map(\.id)) case .projectsResponse(.failure(let error)): + state.isRefreshingProjects = false state.projectsState = .failed(error) // 프로젝트 목록이 실패해도 필터 카드 개수는 독립적으로 유효하므로 집계는 계속 시도한다. return countsEffect(projectIDs: []) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 2ddd4147..de4e96d6 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -9,6 +9,14 @@ import DVDesign struct SidebarView: View { + // MARK: - Metrics + + private enum Metrics { + /// 갱신 중 이전 값에 씌우는 흐림. 더 낮추면 목록이 비활성처럼 보인다. + static let refreshingOpacity: Double = 0.55 + static let fade: Animation = .easeInOut(duration: 0.18) + } + // MARK: - Properties @Bindable var store: StoreOf @@ -101,25 +109,28 @@ extension SidebarView { Spacer(minLength: 0) } } + // `value`를 좁히지 않으면 이름 변경 입력 한 글자마다 섹션 전체가 다시 애니메이션된다. + .animation(Metrics.fade, value: store.projectsState) + .animation(Metrics.fade, value: store.isRefreshingProjects) } + /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦은 화면이라(시크릿 생성, 프로젝트 + /// 추가·이름 변경·삭제) 스피너로 갈아끼우면 목록이 사라졌다 나타나는 것으로 보인다. @ViewBuilder private var projectSectionBody: some View { - switch store.projectsState { - case .idle, .loading: - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 8) - Spacer(minLength: 0) - case .loaded: - projectList - case .failed: + if case .failed = store.projectsState { Text(.module("Failed to load")) .dvFont(.bodyMD) .foregroundStyle(Color.dv(.danger)) .frame(maxWidth: .infinity) .padding(.top, 8) Spacer(minLength: 0) + } else if store.projects.isEmpty { + // 아직 못 받았거나 정말로 비어 있다. 둘 다 보여줄 것이 없으므로 자리만 잡아 둔다. + Spacer(minLength: 0) + } else { + projectList + .opacity(store.isRefreshingProjects ? Metrics.refreshingOpacity : 1) } } diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 00027029..cdec88e0 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -234,8 +234,11 @@ struct MainFeatureTests { projectName: "Backend" ) } - await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } await store.receive(.sidebar(.projectsResponse(.success([item])))) { + $0.sidebar.isRefreshingProjects = false $0.sidebar.projectsState = .loaded([item]) } await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { @@ -263,8 +266,11 @@ struct MainFeatureTests { $0.createProject = nil // selection 및 secretList 변경 없음 } - await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } await store.receive(.sidebar(.projectsResponse(.success([item])))) { + $0.sidebar.isRefreshingProjects = false $0.sidebar.projectsState = .loaded([item]) } await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { @@ -441,8 +447,11 @@ struct MainFeatureTests { } await store.send(.secretDetail(.delegate(.projectsChanged))) - await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } await store.receive(.sidebar(.projectsResponse(.success(created)))) { + $0.sidebar.isRefreshingProjects = false $0.sidebar.projectsState = .loaded(IdentifiedArray(uniqueElements: created)) } await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { @@ -468,8 +477,11 @@ struct MainFeatureTests { } await store.send(.createSecret(.delegate(.projectsChanged))) - await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } await store.receive(.sidebar(.projectsResponse(.success(created)))) { + $0.sidebar.isRefreshingProjects = false $0.sidebar.projectsState = .loaded(IdentifiedArray(uniqueElements: created)) } await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { @@ -588,8 +600,11 @@ struct MainFeatureTests { ) } // 이후 refetch로 목록도 동기화 - await store.receive(.sidebar(.refresh)) + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } await store.receive(.sidebar(.projectsResponse(.success([renamed])))) { + $0.sidebar.isRefreshingProjects = false $0.sidebar.projectsState = .loaded([renamed]) } await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index af6f1b56..bea35281 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -45,6 +45,35 @@ struct SidebarFeatureTests { } } + /// 2컬럼 ↔ 3컬럼 전환으로 사이드바가 다시 만들어지면 `.task`가 한 번 더 실행된다. + /// 여기서 `.loading`으로 되돌리면 생성 화면을 드나들 때마다 사이드바가 깜빡인다. + @Test("이미 로드된 상태에서 task가 다시 실행돼도 값을 비우지 않는다") + func taskAfterReloadKeepsLoadedValues() async { + let projects = [ProjectItem(id: UUID(), name: "Backend")] + let counts = SecretCounts(byFilter: [.all: 5], byProject: [:]) + + var initial = SidebarFeature.State() + initial.projectsState = .loaded(IdentifiedArray(uniqueElements: projects)) + initial.countsState = .loaded(counts) + + let store = TestStore(initialState: initial) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { projects } + $0.sidebarClient.fetchCounts = { _, _ in counts } + $0.date = .constant(Self.referenceDate) + } + + // `projectsState`·`countsState`를 단언하지 않는 것 자체가 검증이다 — 바뀌면 전수 검사에 걸린다. + await store.send(.task) { + $0.isRefreshingProjects = true + } + await store.receive(.projectsResponse(.success(projects))) { + $0.isRefreshingProjects = false + } + await store.receive(.countsResponse(.success(counts))) + } + @Test("task fetch 실패 시 projectsState가 .failed로 전환된다") func taskFetchFailureSetsFailedState() async { let counts = SecretCounts(byFilter: [.all: 5], byProject: [:]) @@ -208,8 +237,11 @@ struct SidebarFeatureTests { } await store.receive(.renameResponse(.success(renamed))) await store.receive(.delegate(.projectRenamed(renamed))) - await store.receive(.refresh) + await store.receive(.refresh) { + $0.isRefreshingProjects = true + } await store.receive(.projectsResponse(.success([renamed]))) { + $0.isRefreshingProjects = false $0.projectsState = .loaded([renamed]) } await store.receive(.countsResponse(.success(SecretCounts()))) { @@ -321,8 +353,11 @@ struct SidebarFeatureTests { $0.selection = .filter(.all) } await store.receive(.delegate(.selectionChanged(.filter(.all)))) - await store.receive(.refresh) + await store.receive(.refresh) { + $0.isRefreshingProjects = true + } await store.receive(.projectsResponse(.success([]))) { + $0.isRefreshingProjects = false $0.projectsState = .loaded([]) } await store.receive(.countsResponse(.success(SecretCounts()))) { From 5cfcbcc6f99ac03184e54890606bd176d546bce0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:30:12 +0900 Subject: [PATCH 34/61] =?UTF-8?q?[#96]=20refactor:=20=EB=B7=B0=20=EB=B6=84?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20screen=EC=9C=BC=EB=A1=9C=20=EB=AA=A8?= =?UTF-8?q?=EC=9C=BC=EA=B3=A0=20split=20view=EB=A5=BC=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 44 +++++++++++---- .../Sources/Features/Main/MainView.swift | 56 ++++++++++++------- .../Tests/Main/MainFeatureTests.swift | 45 +++++++++++++++ 3 files changed, 114 insertions(+), 31 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 3ab94262..78288c55 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -23,6 +23,21 @@ public struct MainFeature { /// 별도 Window가 아니라 콘텐츠 스위칭으로 처리하므로 @Presents 미사용 var settings: SettingsFeature.State? + /// 지금 무엇을 그릴지. **화면 분기는 이 값 하나만 본다.** + /// 뷰에서 optional을 직접 조합하면 같은 판정이 렌더 지점마다 흩어진다. + var screen: Screen { + if settings != nil { return .settings } + if createSecret != nil || selectSecretType != nil { return .creating } + return .browsing + } + + /// 서로 배타적인 최상위 화면. + enum Screen: Equatable { + case browsing + case creating + case settings + } + public init() {} } @@ -172,8 +187,7 @@ public struct MainFeature { return .none case .createSecret(.delegate(.secretCreated(_))): - state.createSecret = nil - state.selectSecretType = nil + exitCreating(&state) // `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉬우므로 순차 실행한다. return .concatenate( .send(.sidebar(.setCreatingSecret(false))), @@ -235,20 +249,13 @@ extension MainFeature { ) -> Effect { switch delegate { case .selectionChanged(let selection): - state.selectSecretType = nil - state.createSecret = nil + exitCreating(&state) state.secretDetail = nil state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) return .send(.sidebar(.setCreatingSecret(false))) case .addButtonTapped: - state.createSecret = nil - state.selectSecretType = .init() - // 생성 플로우로 들어가면 조회 중이던 시크릿을 놓는다. 생성 중에는 2컬럼이라 상세가 - // 화면에서 사라지지만 State는 살아남으므로, 정리하지 않으면 생성을 마치고 3컬럼으로 - // 돌아올 때 이전 시크릿 상세가 다시 나타나고 `.task(id:)`가 Touch ID까지 다시 요구한다. - state.secretDetail = nil - state.secretList.selectedSecretID = nil + enterCreating(&state) return .send(.sidebar(.setCreatingSecret(true))) case .addProjectTapped: @@ -267,6 +274,21 @@ extension MainFeature { } } + /// 생성 플로우로 들어간다. 조회 중이던 시크릿을 함께 놓는다 — 상세 State가 살아남으면 + /// 돌아올 때 되살아나고 `.task(id:)`가 Touch ID까지 다시 요구한다. + private func enterCreating(_ state: inout State) { + state.createSecret = nil + state.selectSecretType = .init() + state.secretDetail = nil + state.secretList.selectedSecretID = nil + } + + /// 생성 플로우를 벗어난다. 한쪽만 남으면 `screen`이 `.creating`으로 판정돼 컬럼이 접힌 채 남는다. + private func exitCreating(_ state: inout State) { + state.createSecret = nil + state.selectSecretType = nil + } + private func makeSecretListState( selection: SidebarSelection, projects: IdentifiedArrayOf diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index ee2a8de8..aeef9033 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -36,27 +36,25 @@ struct MainView: View { extension MainView { + /// **`NavigationSplitView`는 하나뿐이다.** 생성 화면은 가운데 컬럼을 접어 2컬럼처럼 보이게 한다. + /// + /// 컬럼 수가 다른 두 `NavigationSplitView`는 타입이 달라 `if/else`로 교체하면 서브트리가 통째로 + /// 새로 만들어진다 — 사이드바까지 재생성되어 토글 버튼이 튀고 `.task`가 재실행돼 목록이 깜빡였다. @ViewBuilder private var content: some View { - if let settingsStore = store.scope(state: \.settings, action: \.settings) { - SettingsView(store: settingsStore) - } else if store.createSecret != nil || store.selectSecretType != nil { - // 사이드바 + 시크릿 생성 폼/타입 선택 그리드 (2컬럼) - NavigationSplitView { - sidebarColumn - } detail: { - twoColumnDetail + switch store.screen { + case .settings: + if let settingsStore = store.scope(state: \.settings, action: \.settings) { + SettingsView(store: settingsStore) } - .navigationSplitViewStyle(.balanced) - .toolbarBackground(.hidden, for: .windowToolbar) - } else { - // 사이드바 + 시크릿 목록 + 상세 (3컬럼) + + case .browsing, .creating: NavigationSplitView { sidebarColumn } content: { contentColumn } detail: { - detailColumn + detailArea } .navigationSplitViewStyle(.balanced) .toolbarBackground(.hidden, for: .windowToolbar) @@ -64,11 +62,16 @@ extension MainView { } @ViewBuilder - private var twoColumnDetail: some View { - if let createSecretStore = store.scope(state: \.createSecret, action: \.createSecret) { - CreateSecretView(store: createSecretStore) - } else if let selectStore = store.scope(state: \.selectSecretType, action: \.selectSecretType) { - SelectSecretTypeView(store: selectStore) + private var detailArea: some View { + if store.screen == .creating { + // 폼이 떠 있는 동안에도 타입 선택 State는 살아 있으므로 폼을 먼저 본다. + if let createSecretStore = store.scope(state: \.createSecret, action: \.createSecret) { + CreateSecretView(store: createSecretStore) + } else if let selectStore = store.scope(state: \.selectSecretType, action: \.selectSecretType) { + SelectSecretTypeView(store: selectStore) + } + } else { + detailColumn } } @@ -77,10 +80,23 @@ extension MainView { .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 270) } + /// 생성 중에는 폭 0으로 접혀 2컬럼처럼 보인다. + /// + /// 뷰를 없애거나 다른 modifier를 붙이면 뷰 타입이 달라져 위 주석의 재생성 문제가 돌아온다. + /// 살려둔 채 **같은 modifier의 값만** 바꾼다. private var contentColumn: some View { - SecretListView(store: store.scope(state: \.secretList, action: \.secretList)) + let isCollapsed = store.screen == .creating + return SecretListView(store: store.scope(state: \.secretList, action: \.secretList)) .navigationTitle("") - .navigationSplitViewColumnWidth(min: 300, ideal: 320, max: 350) + .opacity(isCollapsed ? 0 : 1) + // 드래그 하한은 컬럼이 아니라 콘텐츠가 갖는다. 컬럼 `min`으로 주면 접힐 때 + // `width >= 300`과 `MaxSize <= 0`이 공존해 AppKit이 제약 충돌을 뱉는다. + .frame(minWidth: isCollapsed ? 0 : 300) + .navigationSplitViewColumnWidth( + min: 0, + ideal: isCollapsed ? 0 : 320, + max: isCollapsed ? 0 : 350 + ) } private var detailColumn: some View { diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index cdec88e0..87171df6 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -14,6 +14,51 @@ struct MainFeatureTests { /// 사이드바 카운트 집계의 기준 시각을 고정한다. static let referenceDate = Date(timeIntervalSince1970: 1_700_000_000) + nonisolated static func makeSecret(name: String = "Test Token") -> Secret { + Secret( + id: UUID(), + name: name, + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + } + + // MARK: - Screen 판정 + + /// 화면 분기가 이 값 하나에 걸려 있다. + @Test("screen: 아무것도 없으면 browsing") + func screenDefaultsToBrowsing() { + #expect(MainFeature.State().screen == .browsing) + } + + @Test("screen: 타입 선택만 있어도, 폼만 있어도 creating") + func screenIsCreatingForBothCreationSteps() { + var selecting = MainFeature.State() + selecting.selectSecretType = .init() + #expect(selecting.screen == .creating) + + var form = MainFeature.State() + form.createSecret = .init(secretType: .apiKeyToken) + #expect(form.screen == .creating) + } + + @Test("screen: 설정이 생성보다 우선한다") + func screenPrefersSettingsOverCreating() { + var state = MainFeature.State() + state.selectSecretType = .init() + state.settings = .init() + #expect(state.screen == .settings) + } + + @Test("screen: 상세가 열려 있어도 browsing이다") + func screenStaysBrowsingWithDetail() { + var state = MainFeature.State() + state.secretDetail = .init(secret: Self.makeSecret()) + #expect(state.screen == .browsing) + } + // MARK: - Count Refresh @Test("secretList의 secretsChanged 델리게이트는 사이드바 카운트를 다시 세게 한다") From 9f020b462294265d2dd0942bc84bd762b1a0d5e2 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:31:46 +0900 Subject: [PATCH 35/61] =?UTF-8?q?[#96]=20refactor:=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EC=84=A0=ED=83=9D=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20mode=20enum=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EC=9C=BC?= =?UTF-8?q?=EA=B3=A0=20=EC=83=9D=EC=84=B1=20=EC=A4=91=20=EC=9E=AC=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=9D=B4=20=EC=82=BC=EC=BC=9C=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 6 +- .../Features/Sidebar/SidebarFeature.swift | 59 ++++++++++++++++--- .../Features/Sidebar/SidebarView.swift | 7 +-- .../Tests/Main/MainFeatureTests.swift | 28 ++++----- .../Tests/Sidebar/SidebarFeatureTests.swift | 32 ++++++++++ 5 files changed, 103 insertions(+), 29 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 78288c55..544fd3cb 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -208,7 +208,8 @@ public struct MainFeature { case .createProject(.presented(.delegate(.projectCreated(let item)))): state.createProject = nil - if !state.sidebar.isCreatingSecret { + // 생성 중이면 목록을 옮기지 않는다 — 마치고 엉뚱한 목록으로 돌아오게 된다. + if case .browsing = state.sidebar.mode { state.sidebar.selection = .project(id: item.id) state.secretList = .init(collection: .project(id: item.id), projectName: item.name) } @@ -316,8 +317,7 @@ extension MainFeature { state.createProject = nil state.createSecret = nil state.secretDetail = nil - state.sidebar.isCreatingSecret = false - state.sidebar.selection = .filter(.all) + state.sidebar.mode = .browsing(.filter(.all)) state.secretList = .init(collection: .all) } } diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 181487b8..cc208f8e 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -50,9 +50,37 @@ public struct SidebarFeature { @ObservableState public struct State: Equatable { - public internal(set) var selection: SidebarSelection = .filter(.all) + /// 사이드바가 지금 무엇을 하고 있는지. **강조 판정은 여기서만 파생된다.** + /// 선택과 생성 여부를 두 필드로 나눠 두면 조합 규칙이 뷰와 리듀서에 따로 생겨 어긋난다. + public internal(set) var mode: Mode = .browsing(.filter(.all)) public internal(set) var isProjectSectionExpanded: Bool = true - public internal(set) var isCreatingSecret: Bool = false + + public enum Mode: Equatable { + case browsing(SidebarSelection) + /// 생성 중에는 어떤 항목도 강조하지 않으며, 끝나면 `previous`로 돌아간다. + case creating(previous: SidebarSelection) + } + + /// 지금 강조할 항목. **뷰와 리듀서가 함께 보는 단일 기준이다.** + public var highlighted: SidebarSelection? { + guard case .browsing(let selection) = mode else { return nil } + return selection + } + + /// 강조 여부와 무관한 "돌아갈 곳". + public internal(set) var selection: SidebarSelection { + get { + switch mode { + case .browsing(let selection), .creating(previous: let selection): return selection + } + } + set { + switch mode { + case .browsing: mode = .browsing(newValue) + case .creating: mode = .creating(previous: newValue) + } + } + } var projectsState: LoadingState, SidebarError> = .idle /// "아직 안 불러옴"과 "0건"을 구분하기 위해 LoadingState로 감싼다 (TCA_GUIDELINES 2.4). var countsState: LoadingState = .idle @@ -198,14 +226,21 @@ public struct SidebarFeature { state.countsState = .failed(error) return .none - // 이미 선택된 항목을 다시 눌러도 macOS List는 선택 이벤트를 보낸다. 그대로 흘리면 - // MainFeature가 목록 State를 새로 만들어 이미 불러온 항목이 버려지는데, - // `collection` 값은 그대로라 `.task(id:)`가 재조회를 걸지 않아 목록이 빈 채로 남는다. - case .didSelect(let selection) where selection == state.selection: + // 이미 강조된 항목을 다시 눌러도 macOS List는 선택 이벤트를 보낸다. 흘려보내면 + // 목록 State가 새로 만들어져 버려진다. + // + // `selection`이 아니라 `highlighted`를 본다 — 생성 중에는 강조가 없어야 같은 필터를 + // 다시 눌러 목록으로 돌아갈 수 있다. + case .didSelect(let selection) where state.highlighted == selection: return .none + // 생성 중이면 강조를 켜지 않고 목적지만 갈아둔다 — 확인을 취소했을 때 생성 화면인데 + // 항목이 선택된 것처럼 보이기 때문이다. 강조는 `setCreatingSecret(false)`가 켠다. case .didSelect(let selection): - state.selection = selection + switch state.mode { + case .browsing: state.mode = .browsing(selection) + case .creating: state.mode = .creating(previous: selection) + } return .send(.delegate(.selectionChanged(selection))) case .didTapAddButton: @@ -231,8 +266,16 @@ public struct SidebarFeature { state.renameText = text return .none + // 강조만 끄고 켠다. "돌아갈 곳"은 유지해야 생성을 마치고 보던 목록으로 돌아간다. case .setCreatingSecret(let value): - state.isCreatingSecret = value + switch (value, state.mode) { + case (true, .browsing(let selection)): + state.mode = .creating(previous: selection) + case (false, .creating(previous: let selection)): + state.mode = .browsing(selection) + case (true, .creating), (false, .browsing): + break + } return .none case .didConfirmRename: diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index de4e96d6..4e53afed 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -71,7 +71,7 @@ extension SidebarView { title: SidebarFilter.all.title, count: count(for: .all), systemImage: SidebarFilter.all.icon, - isSelected: !store.isCreatingSecret && store.selection == .filter(.all) + isSelected: store.highlighted == .filter(.all) ) { store.send(.didSelect(.filter(.all))) } @@ -88,7 +88,7 @@ extension SidebarView { count: count(for: filter), systemImage: filter.icon, iconColor: filter.iconColor, - isSelected: !store.isCreatingSecret && store.selection == .filter(filter) + isSelected: store.highlighted == .filter(filter) ) { store.send(.didSelect(.filter(filter))) } @@ -156,8 +156,7 @@ extension SidebarView { List( selection: Binding( get: { - guard !store.isCreatingSecret else { return nil } - if case .project(id: let id) = store.selection { return id } + if case .project(id: let id) = store.highlighted { return id } return nil }, set: { id in diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 87171df6..37135a5e 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -111,11 +111,11 @@ struct MainFeatureTests { await store.receive(.sidebar(.setCreatingSecret(false))) } - @Test("selectionChanged는 selectSecretType을 닫고 isCreatingSecret을 false로 만든다") + @Test("selectionChanged는 selectSecretType을 닫고 생성 모드를 벗어난다") func selectionChangedClosesSecretTypeSelection() async { var initial = MainFeature.State() initial.selectSecretType = .init() - initial.sidebar.isCreatingSecret = true + initial.sidebar.mode = .creating(previous: initial.sidebar.selection) initial.sidebar.selection = .filter(.starred) let store = TestStore(initialState: initial) { @@ -129,11 +129,11 @@ struct MainFeatureTests { $0.selectSecretType = nil } await store.receive(.sidebar(.setCreatingSecret(false))) { - $0.sidebar.isCreatingSecret = false + $0.sidebar.mode = .browsing($0.sidebar.selection) } } - @Test("addButtonTapped은 selectSecretType을 열고 isCreatingSecret을 true로 만든다") + @Test("addButtonTapped은 selectSecretType을 열고 생성 모드로 들어간다") func addButtonTappedOpensSecretTypeSelection() async { let store = TestStore(initialState: MainFeature.State()) { MainFeature() @@ -144,7 +144,7 @@ struct MainFeatureTests { $0.selectSecretType = .init() } await store.receive(.sidebar(.setCreatingSecret(true))) { - $0.sidebar.isCreatingSecret = true + $0.sidebar.mode = .creating(previous: $0.sidebar.selection) } } @@ -176,7 +176,7 @@ struct MainFeatureTests { $0.secretList.selectedSecretID = nil } await store.receive(.sidebar(.setCreatingSecret(true))) { - $0.sidebar.isCreatingSecret = true + $0.sidebar.mode = .creating(previous: $0.sidebar.selection) } } @@ -203,7 +203,7 @@ struct MainFeatureTests { var initial = MainFeature.State() initial.selectSecretType = .init() initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) - initial.sidebar.isCreatingSecret = true + initial.sidebar.mode = .creating(previous: initial.sidebar.selection) let store = TestStore(initialState: initial) { MainFeature() @@ -217,7 +217,7 @@ struct MainFeatureTests { $0.selectSecretType = nil } await store.receive(.sidebar(.setCreatingSecret(false))) { - $0.sidebar.isCreatingSecret = false + $0.sidebar.mode = .browsing($0.sidebar.selection) } await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.sidebar(.countsResponse(.success(counts)))) { @@ -255,13 +255,13 @@ struct MainFeatureTests { // MARK: - CreateProject Delegate - @Test("projectCreated는 isCreatingSecret이 false일 때 selection을 새 프로젝트로 설정하고 refetch한다") + @Test("projectCreated는 생성 중이 아닐 때 selection을 새 프로젝트로 설정하고 refetch한다") func projectCreatedSelectsNewProject() async { let item = ProjectItem(id: UUID(), name: "Backend") var initial = MainFeature.State() initial.createProject = .init() - initial.sidebar.isCreatingSecret = false + initial.sidebar.mode = .browsing(initial.sidebar.selection) let store = TestStore(initialState: initial) { MainFeature() @@ -291,13 +291,13 @@ struct MainFeatureTests { } } - @Test("projectCreated는 isCreatingSecret이 true일 때 selection을 변경하지 않는다") + @Test("projectCreated는 생성 중일 때 selection을 변경하지 않는다") func projectCreatedDoesNotSelectWhenCreatingSecret() async { let item = ProjectItem(id: UUID(), name: "Backend") var initial = MainFeature.State() initial.createProject = .init() - initial.sidebar.isCreatingSecret = true + initial.sidebar.mode = .creating(previous: initial.sidebar.selection) let store = TestStore(initialState: initial) { MainFeature() @@ -707,7 +707,7 @@ struct MainFeatureTests { initial.createProject = .init() initial.createSecret = .init(secretType: .apiKeyToken) initial.sidebar.selection = .project(id: projectID) - initial.sidebar.isCreatingSecret = true + initial.sidebar.mode = .creating(previous: initial.sidebar.selection) initial.secretList = .init(collection: .project(id: projectID), projectName: "Old") let store = TestStore(initialState: initial) { @@ -725,7 +725,7 @@ struct MainFeatureTests { $0.createProject = nil $0.createSecret = nil $0.sidebar.selection = .filter(.all) - $0.sidebar.isCreatingSecret = false + $0.sidebar.mode = .browsing($0.sidebar.selection) $0.secretList = .init(collection: .all) } } diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index bea35281..6917b567 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -169,6 +169,38 @@ struct SidebarFeatureTests { /// delegate가 나가면 MainFeature가 목록 State를 새로 만드는데, collection 값은 그대로라 /// `.task(id:)`가 재조회를 걸지 않아 목록이 빈 채로 남았다. 발신 자체를 막아야 한다. + /// `selection`으로 판정하면 "같은 선택"으로 삼켜져 All에서 생성에 들어가면 돌아오지 못했다. + @Test("생성 중에는 같은 항목을 다시 눌러도 delegate가 나간다") + func didSelectSameItemWhileCreatingStillNotifies() async { + var state = SidebarFeature.State() + state.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: state) { SidebarFeature() } + + await store.send(.didSelect(.filter(.all))) + await store.receive(.delegate(.selectionChanged(.filter(.all)))) + #expect(store.state.highlighted == nil) + } + + @Test("setCreatingSecret은 강조만 끄고 돌아갈 곳은 유지한다") + func setCreatingSecretKeepsReturnDestination() async { + var state = SidebarFeature.State() + state.mode = .browsing(.filter(.starred)) + + let store = TestStore(initialState: state) { SidebarFeature() } + + await store.send(.setCreatingSecret(true)) { + $0.mode = .creating(previous: .filter(.starred)) + } + #expect(store.state.highlighted == nil) + #expect(store.state.selection == .filter(.starred)) + + await store.send(.setCreatingSecret(false)) { + $0.mode = .browsing(.filter(.starred)) + } + #expect(store.state.highlighted == .filter(.starred)) + } + @Test("이미 선택된 항목을 다시 눌러도 delegate를 보내지 않는다") func didSelectSameSelectionSendsNothing() async { // 기본 selection이 .filter(.all)이므로 그대로 다시 누른다. From 8373c44932dfac671fc237b07c72761a57f3ffa9 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:32:57 +0900 Subject: [PATCH 36/61] =?UTF-8?q?[#96]=20feat:=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=EB=A5=BC=20?= =?UTF-8?q?=EB=88=84=EB=A5=B4=EB=A9=B4=20=ED=99=95=EC=9D=B8=EC=9D=84=20?= =?UTF-8?q?=EA=B1=B0=EC=B3=90=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EA=B0=80=EB=8F=84=EB=A1=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 35 +++++++-- .../Tests/Main/MainFeatureTests.swift | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 544fd3cb..a0897a17 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -23,6 +23,9 @@ public struct MainFeature { /// 별도 Window가 아니라 콘텐츠 스위칭으로 처리하므로 @Presents 미사용 var settings: SettingsFeature.State? + /// 생성 폼의 취소 확인을 기다리는 동안 보관하는 이동 목적지. + var pendingSelection: SidebarSelection? + /// 지금 무엇을 그릴지. **화면 분기는 이 값 하나만 본다.** /// 뷰에서 optional을 직접 조합하면 같은 판정이 렌더 지점마다 흩어진다. var screen: Screen { @@ -198,9 +201,20 @@ public struct MainFeature { case .createSecret(.delegate(.projectsChanged)): return .send(.sidebar(.refresh)) + // 사이드바가 시작한 취소면 목록으로, 폼의 Cancel이면 타입 그리드로 — 목적지가 다르다. case .createSecret(.delegate(.cancelled)): - state.createSecret = nil - state.selectSecretType = .init() + guard let pending = state.pendingSelection else { + state.createSecret = nil + state.selectSecretType = .init() + return .none + } + state.pendingSelection = nil + applySelection(pending, &state) + return .send(.sidebar(.setCreatingSecret(false))) + + // 목적지를 버리지 않으면 다음에 폼의 Cancel을 눌렀을 때 엉뚱하게 목록으로 나간다. + case .createSecret(.alert(.dismiss)): + state.pendingSelection = nil return .none case .createSecret: @@ -249,10 +263,14 @@ extension MainFeature { delegate: SidebarFeature.Action.Delegate ) -> Effect { switch delegate { + // 폼이 열려 있으면 입력이 있을 수 있으므로 자식의 기존 취소 alert를 거친다. + // 타입 선택 단계는 입력이 없어 확인 없이 나간다. case .selectionChanged(let selection): - exitCreating(&state) - state.secretDetail = nil - state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) + guard state.createSecret == nil else { + state.pendingSelection = selection + return .send(.createSecret(.didTapCancel)) + } + applySelection(selection, &state) return .send(.sidebar(.setCreatingSecret(false))) case .addButtonTapped: @@ -275,6 +293,13 @@ extension MainFeature { } } + /// 사이드바에서 고른 곳으로 이동한다. 생성 중이었다면 그 플로우를 접는다. + private func applySelection(_ selection: SidebarSelection, _ state: inout State) { + exitCreating(&state) + state.secretDetail = nil + state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) + } + /// 생성 플로우로 들어간다. 조회 중이던 시크릿을 함께 놓는다 — 상세 State가 살아남으면 /// 돌아올 때 되살아나고 `.task(id:)`가 Touch ID까지 다시 요구한다. private func enterCreating(_ state: inout State) { diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 37135a5e..7a87de2d 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -241,6 +241,83 @@ struct MainFeatureTests { } } + /// 문구를 복제하지 않으려고 자식의 기존 취소 alert를 그대로 띄운다. + @Test("생성 폼에서 사이드바를 누르면 바로 나가지 않고 취소 확인을 띄운다") + func sidebarSelectionWhileFormOpenAsksForConfirmation() async { + var initial = MainFeature.State() + initial.selectSecretType = .init() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.sidebar.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.sidebar(.didSelect(.filter(.starred)))) { + $0.sidebar.mode = .creating(previous: .filter(.starred)) + } + await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.starred))))) { + $0.pendingSelection = .filter(.starred) + } + await store.receive(.createSecret(.didTapCancel)) { + $0.createSecret?.alert = AlertState { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmCancel) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } + } + } + + /// 폼의 Cancel과 문구는 같지만 목적지가 다르다. + @Test("확인하면 타입 선택이 아니라 누른 목록으로 나간다") + func confirmingLeavesToSelectedList() async { + var initial = MainFeature.State() + initial.selectSecretType = .init() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.sidebar.mode = .creating(previous: .filter(.starred)) + initial.pendingSelection = .filter(.starred) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.createSecret(.delegate(.cancelled))) { + $0.pendingSelection = nil + $0.createSecret = nil + $0.selectSecretType = nil + $0.secretList = SecretListFeature.State(collection: .liked) + } + await store.receive(.sidebar(.setCreatingSecret(false))) { + $0.sidebar.mode = .browsing(.filter(.starred)) + } + } + + /// 목적지를 버리지 않으면 나중에 폼의 Cancel이 엉뚱하게 목록으로 나간다. + @Test("확인 없이 alert를 닫으면 기억해 둔 목적지를 버린다") + func dismissingConfirmationDropsPendingSelection() async { + var initial = MainFeature.State() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.createSecret?.alert = AlertState { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmCancel) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } + initial.pendingSelection = .filter(.starred) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.createSecret(.alert(.dismiss))) { + $0.createSecret?.alert = nil + $0.pendingSelection = nil + } + } + @Test("addProjectTapped은 createProject sheet를 연다") func addProjectTappedOpensSheet() async { let store = TestStore(initialState: MainFeature.State()) { From 80be1ec0639e74a837f95eae625577c8caf33afb Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:33:31 +0900 Subject: [PATCH 37/61] =?UTF-8?q?[#96]=20fix:=20=EC=82=AD=EC=A0=9C=20alert?= =?UTF-8?q?=EB=A5=BC=20=ED=99=95=EC=9D=B8=20=EC=97=86=EC=9D=B4=20=EB=8B=AB?= =?UTF-8?q?=EC=95=84=EB=8F=84=20=EB=8C=80=EC=83=81=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EA=B0=80=20=EB=82=A8=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Sidebar/SidebarFeature.swift | 5 ++++ .../Tests/Sidebar/SidebarFeatureTests.swift | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index cc208f8e..d275bf7a 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -337,6 +337,11 @@ public struct SidebarFeature { } } + // 대상 ID를 남겨두면 다음 삭제가 엉뚱한 프로젝트를 지울 여지가 생긴다. + case .alert(.dismiss): + state.deletingProjectID = nil + return .none + case .alert: return .none diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index 6917b567..4e3592e3 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -351,6 +351,32 @@ struct SidebarFeatureTests { } } + /// 대상 ID가 남으면 다음 삭제가 엉뚱한 프로젝트를 지울 수 있다. + @Test("삭제 alert를 확인 없이 닫으면 대상이 지워진다") + func dismissingDeleteAlertClearsTarget() async { + let item = ProjectItem(id: UUID(), name: "Backend") + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + + let store = TestStore(initialState: state) { SidebarFeature() } + + await store.send(.didTapDelete(id: item.id)) { + $0.deletingProjectID = item.id + $0.alert = AlertState { + TextState("'\(item.name)' 프로젝트를 삭제할까요?") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete) { TextState("삭제") } + ButtonState(role: .cancel) { TextState("취소") } + } message: { + TextState("프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다.") + } + } + await store.send(.alert(.dismiss)) { + $0.deletingProjectID = nil + $0.alert = nil + } + } + @Test("삭제 확인 후 선택 중이던 프로젝트면 selection이 .all로 초기화되고 refetch한다") func deleteResetsSelectionAndRefetches() async { let item = ProjectItem(id: UUID(), name: "Backend") From 4163233c761e2710a35f444f6f5d210ffb67457c Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:34:14 +0900 Subject: [PATCH 38/61] =?UTF-8?q?[#96]=20fix:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=ED=95=9C=20=EC=9D=B4=EB=A6=84=EC=9D=B4=20=EC=82=AC?= =?UTF-8?q?=EB=9D=BC=EC=A7=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Sidebar/SidebarFeature.swift | 5 +++-- .../Tests/Sidebar/SidebarFeatureTests.swift | 11 +++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index d275bf7a..f6bb0a28 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -278,11 +278,10 @@ public struct SidebarFeature { } return .none + // 편집 상태는 성공했을 때만 닫는다. 여기서 닫으면 실패 시 입력한 이름이 사라진다. case .didConfirmRename: guard let id = state.renamingProjectID else { return .none } let name = state.renameText.trimmingCharacters(in: .whitespacesAndNewlines) - state.renamingProjectID = nil - state.renameText = "" guard !name.isEmpty else { state.alert = makeRenameEmptyNameAlert() return .none @@ -304,6 +303,8 @@ public struct SidebarFeature { return .none case .renameResponse(.success(let updated)): + state.renamingProjectID = nil + state.renameText = "" return .concatenate( .send(.delegate(.projectRenamed(updated))), .send(.refresh) diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index 4e3592e3..623aebc3 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -263,11 +263,12 @@ struct SidebarFeatureTests { $0.date = .constant(Self.referenceDate) } - await store.send(.didConfirmRename) { + // 편집 상태는 성공 응답에서 닫힌다. + await store.send(.didConfirmRename) + await store.receive(.renameResponse(.success(renamed))) { $0.renamingProjectID = nil $0.renameText = "" } - await store.receive(.renameResponse(.success(renamed))) await store.receive(.delegate(.projectRenamed(renamed))) await store.receive(.refresh) { $0.isRefreshingProjects = true @@ -295,10 +296,8 @@ struct SidebarFeatureTests { $0.sidebarClient.renameProject = { _, _ in throw SidebarError.nameTaken } } - await store.send(.didConfirmRename) { - $0.renamingProjectID = nil - $0.renameText = "" - } + // 실패해도 닫지 않는다 — 닫으면 입력한 이름이 사라진다. + await store.send(.didConfirmRename) await store.receive(.renameResponse(.failure(.nameTaken))) { $0.alert = AlertState { TextState("이미 사용 중인 이름이에요") From 6734210b7d9f5be48a70ac236c11d83bc7d947c0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:35:22 +0900 Subject: [PATCH 39/61] =?UTF-8?q?[#96]=20feat:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EA=B0=80=20=ED=95=98=EB=82=98=EB=8F=84=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=EC=97=90=20=EC=95=88=EB=82=B4=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Sidebar/SidebarView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 4e53afed..9e29a048 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -126,7 +126,14 @@ extension SidebarView { .padding(.top, 8) Spacer(minLength: 0) } else if store.projects.isEmpty { - // 아직 못 받았거나 정말로 비어 있다. 둘 다 보여줄 것이 없으므로 자리만 잡아 둔다. + // 다 받아 놓고 정말 없을 때만 안내한다 — 로딩 중에 "없다"고 하면 잘못 알린 셈이 된다. + if case .loaded = store.projectsState { + Text(.module("No projects yet")) + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.gray500)) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } Spacer(minLength: 0) } else { projectList From f8c9a3f6fc50e994de300cb6b54063980993153a Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:37:01 +0900 Subject: [PATCH 40/61] =?UTF-8?q?[#96]=20fix:=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9C=BC=EB=A1=9C=20=EB=8F=8C=EC=95=84?= =?UTF-8?q?=EC=98=AC=20=EB=95=8C=20=EB=B6=88=EB=9F=AC=EC=98=A8=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EB=B9=84=EC=9B=8C=EC=A7=80=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 28 ++++++----- .../SecretList/SecretListFeature.swift | 18 +++++++- .../Tests/Main/MainFeatureTests.swift | 46 +++++++++++++++++++ 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index a0897a17..b631aeb8 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -1,6 +1,7 @@ // Copyright © 2026 Devault. All rights reserved import ComposableArchitecture +import DVDomain // MARK: - MainFeature @@ -225,7 +226,7 @@ public struct MainFeature { // 생성 중이면 목록을 옮기지 않는다 — 마치고 엉뚱한 목록으로 돌아오게 된다. if case .browsing = state.sidebar.mode { state.sidebar.selection = .project(id: item.id) - state.secretList = .init(collection: .project(id: item.id), projectName: item.name) + state.secretList.retarget(to: .project(id: item.id), projectName: item.name) } return .send(.sidebar(.refresh)) @@ -285,19 +286,22 @@ extension MainFeature { state.settings = .init() return .none + // State를 갈아끼우면 목록이 버려지는데 `collection`이 같아 다시 읽지도 않는다. case .projectRenamed(let item): if case .project(id: item.id) = state.sidebar.selection { - state.secretList = .init(collection: .project(id: item.id), projectName: item.name) + state.secretList.projectName = item.name } return .none } } /// 사이드바에서 고른 곳으로 이동한다. 생성 중이었다면 그 플로우를 접는다. + /// 고른 곳이 이미 보고 있던 곳일 수 있어(생성 중 같은 필터 재선택) `retarget`을 거친다. private func applySelection(_ selection: SidebarSelection, _ state: inout State) { exitCreating(&state) state.secretDetail = nil - state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) + let target = makeCollection(selection: selection, projects: state.sidebar.projects) + state.secretList.retarget(to: target.collection, projectName: target.projectName) } /// 생성 플로우로 들어간다. 조회 중이던 시크릿을 함께 놓는다 — 상세 State가 살아남으면 @@ -315,24 +319,24 @@ extension MainFeature { state.selectSecretType = nil } - private func makeSecretListState( + /// 교체 여부는 `SecretListFeature.State.retarget(to:projectName:)`이 정하므로 값만 돌려준다. + private func makeCollection( selection: SidebarSelection, projects: IdentifiedArrayOf - ) -> SecretListFeature.State { + ) -> (collection: SecretQuery.Collection, projectName: String?) { switch selection { case .filter(.all): - return .init(collection: .all) + return (.all, nil) case .filter(.starred): - return .init(collection: .liked) + return (.liked, nil) case .filter(.notice): - return .init(collection: .notice(referenceDate: now)) + return (.notice(referenceDate: now), nil) case .filter(.expired): - return .init(collection: .expired(referenceDate: now)) + return (.expired(referenceDate: now), nil) case .filter(.deleted): - return .init(collection: .deleted) + return (.deleted, nil) case .project(id: let id): - let projectName = projects[id: id]?.name - return .init(collection: .project(id: id), projectName: projectName) + return (.project(id: id), projects[id: id]?.name) } } diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 6b62183e..03ae517e 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -17,7 +17,7 @@ public struct SecretListFeature { public let collection: SecretQuery.Collection /// `.project`일 때 표시할 프로젝트 이름. `SecretQuery.Collection.project`은 id만 가지고 있어서, /// 어떤 프로젝트인지 알고 있는 호출부(사이드바)가 이름을 함께 넘겨준다. - public let projectName: String? + public internal(set) var projectName: String? public internal(set) var secretsState: LoadingState, SecretUseCaseError> = .idle public internal(set) var selectedSecretID: Secret.ID? public internal(set) var searchText = "" @@ -30,6 +30,22 @@ public struct SecretListFeature { self.projectName = projectName } + /// 다른 대상을 보도록 바꾼다. **State를 직접 갈아끼우지 말고 이걸 쓴다.** + /// + /// 대상이 같으면 목록을 그대로 둔다. 새로 만들면 `secretsState`가 비는데 뷰의 + /// `.task(id: collection)`은 `collection`이 그대로라 다시 돌지 않아 빈 화면이 남는다. + /// + /// **데이터가 무효해진 경우에는 쓰지 않는다** — 저장소 전환은 이전 시크릿이 남으면 안 되므로 + /// 통째로 새로 만든다(`MainFeature.resetVaultContent`). + mutating func retarget(to collection: SecretQuery.Collection, projectName: String? = nil) { + guard self.collection == collection else { + self = .init(collection: collection, projectName: projectName) + return + } + if let projectName { self.projectName = projectName } + selectedSecretID = nil + } + /// `.expired`는 "이미 지남 + N일 이내 예정"을 한 화면에서 섹션으로 나눠 보여준다. /// window 계산은 `SecretQuery.Collection.expiringWindow(from:)` 한 곳에만 두고 사이드바 개수 집계와 공유한다. /// 화면에 표시되는 `collection`(및 그 referenceDate)은 실제 "오늘"을 유지 — 섹션 분류 기준으로 View가 그대로 쓴다. diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 7a87de2d..42996462 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -111,6 +111,52 @@ struct MainFeatureTests { await store.receive(.sidebar(.setCreatingSecret(false))) } + /// State를 새로 만들면 `secretsState`가 비는데 `.task(id: collection)`은 다시 돌지 않아 + /// 개수는 0이 아닌데 목록만 빈 화면이 남는다. + @Test("같은 대상으로 돌아오면 이미 불러온 목록을 버리지 않는다") + func returningToSameCollectionKeepsLoadedSecrets() async { + let secret = Self.makeSecret() + + var initial = MainFeature.State() + initial.selectSecretType = .init() + initial.sidebar.mode = .creating(previous: .filter(.all)) + initial.secretList.secretsState = .loaded([secret]) + initial.secretList.selectedSecretID = secret.id + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.sidebar(.didSelect(.filter(.all)))) + await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.all))))) { + $0.selectSecretType = nil + // `secretsState`를 단언하지 않는 것 자체가 검증이다. + $0.secretList.selectedSecretID = nil + } + await store.receive(.sidebar(.setCreatingSecret(false))) { + $0.sidebar.mode = .browsing(.filter(.all)) + } + #expect(store.state.secretList.secretsState == .loaded([secret])) + } + + /// State를 갈아끼우면 목록이 버려지는데 `collection`이 같아 다시 읽지도 않는다. + @Test("프로젝트 이름 변경은 목록을 버리지 않고 이름만 갈아끼운다") + func projectRenameKeepsLoadedSecrets() async { + let projectID = UUID() + let secret = Self.makeSecret() + let renamed = ProjectItem(id: projectID, name: "Backend V2") + + var initial = MainFeature.State() + initial.sidebar.mode = .browsing(.project(id: projectID)) + initial.secretList = .init(collection: .project(id: projectID), projectName: "Backend") + initial.secretList.secretsState = .loaded([secret]) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.sidebar(.delegate(.projectRenamed(renamed)))) { + $0.secretList.projectName = "Backend V2" + } + #expect(store.state.secretList.secretsState == .loaded([secret])) + } + @Test("selectionChanged는 selectSecretType을 닫고 생성 모드를 벗어난다") func selectionChangedClosesSecretTypeSelection() async { var initial = MainFeature.State() From 53046ab2da98b3ec7dcf03be273ac67260b8653d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:37:53 +0900 Subject: [PATCH 41/61] =?UTF-8?q?[#96]=20fix:=20=EC=8B=9C=ED=81=AC?= =?UTF-8?q?=EB=A6=BF=EC=9D=84=20=EB=A7=8C=EB=93=A0=20=EB=92=A4=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVPresentation/Sources/Features/Main/MainFeature.swift | 3 +++ Projects/DVPresentation/Tests/Main/MainFeatureTests.swift | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index b631aeb8..d5476dc4 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -190,11 +190,14 @@ public struct MainFeature { case .selectSecretType: return .none + // 목록도 함께 다시 읽는다. 컬럼만 접히고 뷰는 살아 있어 `.task`가 다시 돌지 않으므로, + // 이게 없으면 방금 만든 시크릿이 목록에 나타나지 않는다. case .createSecret(.delegate(.secretCreated(_))): exitCreating(&state) // `.merge`는 도착 순서를 보장하지 않아 테스트가 깨지기 쉬우므로 순차 실행한다. return .concatenate( .send(.sidebar(.setCreatingSecret(false))), + .send(.secretList(.refresh)), .send(.sidebar(.countsRefreshRequested)) ) diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 42996462..7c3fa4b1 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -255,6 +255,7 @@ struct MainFeatureTests { MainFeature() } withDependencies: { $0.sidebarClient.fetchCounts = { _, _ in counts } + $0.secretClient.fetchByQuery = { _ in [] } $0.date = .constant(Self.referenceDate) } @@ -265,10 +266,15 @@ struct MainFeatureTests { await store.receive(.sidebar(.setCreatingSecret(false))) { $0.sidebar.mode = .browsing($0.sidebar.selection) } + // 지시가 먼저 다 나가고 비동기 응답이 뒤따른다. + await store.receive(.secretList(.refresh)) await store.receive(.sidebar(.countsRefreshRequested)) await store.receive(.sidebar(.countsResponse(.success(counts)))) { $0.sidebar.countsState = .loaded(counts) } + await store.receive(.secretList(.secretsResponse(.success([])))) { + $0.secretList.secretsState = .loaded([]) + } } @Test("cancelled는 createSecret을 닫고 selectSecretType을 초기화해 타입 선택으로 돌아간다") From c5ee422c655f9d5ab0f067d16c0a2fed6122a1e6 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:38:55 +0900 Subject: [PATCH 42/61] =?UTF-8?q?[#103]=20refactor:=20=EC=B0=BD=C2=B7?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EB=84=88=EB=B9=84=EB=A5=BC=20WindowLayout?= =?UTF-8?q?Metrics=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=ED=8F=AD=EC=9D=84=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=C2=B7=EC=84=A4=EC=A0=95=20=EB=8F=99=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainView.swift | 19 +++++-- .../Features/Settings/SettingsView.swift | 7 ++- .../Sources/Support/WindowLayoutMetrics.swift | 52 +++++++++++++++++++ .../Support/WindowLayoutMetricsTests.swift | 34 ++++++++++++ Projects/Devault/Sources/DevaultApp.swift | 12 +++-- 5 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift create mode 100644 Projects/DVPresentation/Tests/Support/WindowLayoutMetricsTests.swift diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index aeef9033..85d26904 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -75,9 +75,15 @@ extension MainView { } } + /// 폭을 고정한다(min == ideal == max). 범위를 주면 `.balanced`가 남는 폭을 컬럼끼리 나눠 가져, + /// 가운데가 접힐 때 사이드바 폭이 흔들린다. private var sidebarColumn: some View { SidebarView(store: store.scope(state: \.sidebar, action: \.sidebar)) - .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 270) + .navigationSplitViewColumnWidth( + min: WindowLayoutMetrics.sidebarWidth, + ideal: WindowLayoutMetrics.sidebarWidth, + max: WindowLayoutMetrics.sidebarWidth + ) } /// 생성 중에는 폭 0으로 접혀 2컬럼처럼 보인다. @@ -91,11 +97,11 @@ extension MainView { .opacity(isCollapsed ? 0 : 1) // 드래그 하한은 컬럼이 아니라 콘텐츠가 갖는다. 컬럼 `min`으로 주면 접힐 때 // `width >= 300`과 `MaxSize <= 0`이 공존해 AppKit이 제약 충돌을 뱉는다. - .frame(minWidth: isCollapsed ? 0 : 300) + .frame(minWidth: isCollapsed ? 0 : WindowLayoutMetrics.listMinWidth) .navigationSplitViewColumnWidth( min: 0, - ideal: isCollapsed ? 0 : 320, - max: isCollapsed ? 0 : 350 + ideal: isCollapsed ? 0 : WindowLayoutMetrics.listIdealWidth, + max: isCollapsed ? 0 : WindowLayoutMetrics.listMaxWidth ) } @@ -113,7 +119,10 @@ extension MainView { .navigationTitle("") // max는 주지 않는다 — 컬럼이 창을 채우지 못하면 윈도우 배경이 양옆에 드러난다. // 폼 폭 상한은 컬럼이 아니라 `SecretDetailView` 안의 `formMaxWidth()`가 담당한다. - .navigationSplitViewColumnWidth(min: 420, ideal: 480) + .navigationSplitViewColumnWidth( + min: WindowLayoutMetrics.detailMinWidth, + ideal: WindowLayoutMetrics.detailIdealWidth + ) } private var lockButton: some View { diff --git a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift index e93d1984..bbf170e4 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift @@ -67,7 +67,12 @@ extension SettingsView { } } .listStyle(.sidebar) - .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 320) + // 메인 화면 사이드바와 같은 값으로 고정한다. 설정을 드나들 때 폭이 달라지지 않는다. + .navigationSplitViewColumnWidth( + min: WindowLayoutMetrics.sidebarWidth, + ideal: WindowLayoutMetrics.sidebarWidth, + max: WindowLayoutMetrics.sidebarWidth + ) } private var backToAppRow: some View { diff --git a/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift b/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift new file mode 100644 index 00000000..d4bbe6c6 --- /dev/null +++ b/Projects/DVPresentation/Sources/Support/WindowLayoutMetrics.swift @@ -0,0 +1,52 @@ +// Copyright © 2026 Devault. All rights reserved + +import CoreGraphics + +// MARK: - WindowLayoutMetrics + +/// 창과 컬럼의 너비. +/// +/// 창 최소 너비는 컬럼 하한의 합이라 서로 묶여 있다. **합은 직접 적지 않고 계산한다.** +/// +/// 폼 안쪽(라벨·입력 슬롯) 너비는 `FormLayoutMetrics`가 갖는다. 여기는 분할 뷰가 나누는 단위만 다룬다. +public enum WindowLayoutMetrics { + + // MARK: - 사이드바 + + /// 고정 폭. 범위를 주면 `.balanced`가 폭을 재분배해 가운데가 접힐 때 사이드바가 흔들린다. + public static let sidebarWidth: CGFloat = 250 + + // MARK: - 목록 컬럼 + + /// 드래그 하한. 컬럼이 아니라 콘텐츠가 갖는다 — 컬럼 `min`은 접힐 때 `MaxSize <= 0`과 충돌한다. + public static let listMinWidth: CGFloat = 300 + public static let listIdealWidth: CGFloat = 320 + public static let listMaxWidth: CGFloat = 350 + + // MARK: - 상세 컬럼 + + /// max는 두지 않는다 — 컬럼이 창을 못 채우면 윈도우 배경이 드러난다. + public static let detailMinWidth: CGFloat = 420 + public static let detailIdealWidth: CGFloat = 480 + + // MARK: - 생성 화면 + + /// 이보다 좁으면 SelectSecretType 그리드의 카드가 겹친다. + public static let createContentMinWidth: CGFloat = 700 + + // MARK: - 창 + + /// 구분선 두께와 반올림을 흡수하는 여유. + private static let slack: CGFloat = 30 + + public static let browsingMinWidth = sidebarWidth + listMinWidth + detailMinWidth + public static let creatingMinWidth = sidebarWidth + createContentMinWidth + + /// 두 배치 중 넓은 쪽에 여유를 더한다. + public static let windowMinWidth = max(browsingMinWidth, creatingMinWidth) + slack + public static let windowMinHeight: CGFloat = 600 + + /// 하한에 붙여 열면 3컬럼이 전부 최소 폭이라 답답하다. + public static let windowDefaultWidth: CGFloat = 1120 + public static let windowDefaultHeight: CGFloat = 700 +} diff --git a/Projects/DVPresentation/Tests/Support/WindowLayoutMetricsTests.swift b/Projects/DVPresentation/Tests/Support/WindowLayoutMetricsTests.swift new file mode 100644 index 00000000..2619991c --- /dev/null +++ b/Projects/DVPresentation/Tests/Support/WindowLayoutMetricsTests.swift @@ -0,0 +1,34 @@ +// Copyright © 2026 Devault. All rights reserved + +import Testing + +@testable import DVPresentation + +@Suite("WindowLayoutMetrics") +struct WindowLayoutMetricsTests { + + /// 창이 컬럼 하한의 합보다 좁으면 어느 컬럼이든 하한을 못 지켜 분할 뷰가 폭을 깎기 시작한다. + @Test("창 최소 너비는 두 배치의 하한을 모두 담는다") + func windowMinWidthCoversBothLayouts() { + #expect(WindowLayoutMetrics.windowMinWidth >= WindowLayoutMetrics.browsingMinWidth) + #expect(WindowLayoutMetrics.windowMinWidth >= WindowLayoutMetrics.creatingMinWidth) + } + + /// 기본 크기가 하한보다 작으면 창이 열리자마자 하한으로 끌려 올라가 `defaultSize`가 무의미해진다. + @Test("기본 크기는 최소 크기보다 크다") + func defaultSizeExceedsMinimum() { + #expect(WindowLayoutMetrics.windowDefaultWidth >= WindowLayoutMetrics.windowMinWidth) + #expect(WindowLayoutMetrics.windowDefaultHeight >= WindowLayoutMetrics.windowMinHeight) + } + + @Test("목록 컬럼 폭은 min ≤ ideal ≤ max") + func listWidthsAreOrdered() { + #expect(WindowLayoutMetrics.listMinWidth <= WindowLayoutMetrics.listIdealWidth) + #expect(WindowLayoutMetrics.listIdealWidth <= WindowLayoutMetrics.listMaxWidth) + } + + @Test("상세 컬럼 폭은 min ≤ ideal") + func detailWidthsAreOrdered() { + #expect(WindowLayoutMetrics.detailMinWidth <= WindowLayoutMetrics.detailIdealWidth) + } +} diff --git a/Projects/Devault/Sources/DevaultApp.swift b/Projects/Devault/Sources/DevaultApp.swift index 0d493e06..9f31e393 100644 --- a/Projects/Devault/Sources/DevaultApp.swift +++ b/Projects/Devault/Sources/DevaultApp.swift @@ -30,7 +30,10 @@ extension DevaultApp { DevaultRootView(store: store) } .windowStyle(.hiddenTitleBar) - .defaultSize(width: 960, height: 700) + .defaultSize( + width: WindowLayoutMetrics.windowDefaultWidth, + height: WindowLayoutMetrics.windowDefaultHeight + ) } } @@ -41,12 +44,11 @@ private struct DevaultRootView: View { var body: some View { AppView(store: store) - // minWidth: 사이드바(200) + SelectSecretType 그리드 요구 너비(약 700)를 담을 수 있는 하한. - // 이보다 좁아지면 그리드 열이 찌그러져 카드가 겹친다. + // 컬럼 하한의 합에서 파생된다 (`WindowLayoutMetrics`). 컬럼 폭을 바꾸면 창도 함께 따라온다. .frame( - minWidth: 920, + minWidth: WindowLayoutMetrics.windowMinWidth, maxWidth: .infinity, - minHeight: 600, + minHeight: WindowLayoutMetrics.windowMinHeight, maxHeight: .infinity ) .background( From 52d9ea1586aeca196a60775ad9c7e5a3ddb37622 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:41:05 +0900 Subject: [PATCH 43/61] =?UTF-8?q?[#96]=20feat:=20=ED=99=94=EB=A9=B4=C2=B7?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=A0=84=ED=99=98=EA=B3=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=9E=AC=EB=B0=B0=EC=B9=98=EC=97=90=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/Model/SecretQuery.swift | 3 +- .../Sources/Features/AppFeature.swift | 15 +++++++++ .../Sources/Features/AppView.swift | 12 +++++++ .../Sources/Features/Main/MainView.swift | 17 ++++++++++ .../Features/SecretList/SecretListView.swift | 32 +++++++++++++++---- .../Features/Sidebar/SidebarView.swift | 29 +++++++++++++---- .../Tests/AppFeatureTests.swift | 24 ++++++++++++++ 7 files changed, 117 insertions(+), 15 deletions(-) diff --git a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift index 2c67cec8..91d55e08 100644 --- a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift @@ -29,7 +29,8 @@ public struct SecretQuery: Equatable, Sendable { } extension SecretQuery { - public enum Collection: Equatable, Sendable { + /// `Hashable`인 것은 목록 뷰가 "무엇을 보고 있는지"의 정체성(`.id(_:)`)으로 쓰기 때문이다. + public enum Collection: Equatable, Hashable, Sendable { case all case liked /// 아직 안 지났지만 곧 지날 것만 모은 컬렉션. Expired(30일)의 부분집합이며, 이미 지난 건 제외한다. diff --git a/Projects/DVPresentation/Sources/Features/AppFeature.swift b/Projects/DVPresentation/Sources/Features/AppFeature.swift index 9b48b399..e66cd098 100644 --- a/Projects/DVPresentation/Sources/Features/AppFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AppFeature.swift @@ -19,6 +19,21 @@ public struct AppFeature { var main: MainFeature.State? public var isWindowCaptureBlockingEnabled = true + /// 지금 어느 화면인지. 전환 애니메이션이 이 값 하나를 본다. + /// 셋을 다 비웠다가 하나를 세우는 구간이 있어(`task`) 옵셔널이다. + var screen: Screen? { + if onboarding != nil { return .onboarding } + if locked != nil { return .locked } + if main != nil { return .main } + return nil + } + + enum Screen: Equatable { + case onboarding + case locked + case main + } + public init() {} } diff --git a/Projects/DVPresentation/Sources/Features/AppView.swift b/Projects/DVPresentation/Sources/Features/AppView.swift index 667be576..45cd0530 100644 --- a/Projects/DVPresentation/Sources/Features/AppView.swift +++ b/Projects/DVPresentation/Sources/Features/AppView.swift @@ -18,10 +18,17 @@ public struct AppView: View { self.store = store } + // MARK: - Metrics + + private enum Metrics { + static let screenFade: Animation = .easeInOut(duration: 0.25) + } + // MARK: - Body public var body: some View { content + .animation(Metrics.screenFade, value: store.screen) // 진행 오버레이를 그리는 유일한 지점 (`.omc/GUIDELINES.md`). .windowBusyOverlay() .task { store.send(.task) } @@ -32,14 +39,19 @@ public struct AppView: View { extension AppView { + /// **메인은 사라질 때 애니메이션하지 않는다(`removal: .identity`).** 잠기는 순간 서서히 + /// 흐려지면 그 시간 동안 시크릿 목록이 화면에 남는다. @ViewBuilder private var content: some View { if let store = store.scope(state: \.onboarding, action: \.onboarding) { OnboardingContainerView(store: store) + .transition(.opacity) } else if let store = store.scope(state: \.locked, action: \.locked) { LockView(store: store) + .transition(.opacity) } else if let store = store.scope(state: \.main, action: \.main) { MainView(store: store) + .transition(.asymmetric(insertion: .opacity, removal: .identity)) } } } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 85d26904..00cc601e 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -9,6 +9,15 @@ import DVDesign struct MainView: View { + // MARK: - Metrics + + private enum Metrics { + /// 컬럼 내용이 갈릴 때 쓰는 겹침. 목록 컬럼(`SecretListView`)·화면 전환과 모두 같은 값이다. + static let contentFade: Animation = .easeInOut(duration: 0.25) + /// 설정처럼 화면 전체가 갈리는 전환. `AppView`의 값과 맞춘다. + static let screenFade: Animation = .easeInOut(duration: 0.25) + } + // MARK: - Properties @Bindable var store: StoreOf @@ -17,6 +26,8 @@ struct MainView: View { var body: some View { content + // `screen`으로 좁히지 않으면 목록·상세가 바뀔 때마다 화면 전체가 다시 페이드된다. + .animation(Metrics.screenFade, value: store.screen) .dvScreenBackground() .task { store.send(.task) } .sheet( @@ -46,6 +57,7 @@ extension MainView { case .settings: if let settingsStore = store.scope(state: \.settings, action: \.settings) { SettingsView(store: settingsStore) + .transition(.opacity) } case .browsing, .creating: @@ -58,6 +70,7 @@ extension MainView { } .navigationSplitViewStyle(.balanced) .toolbarBackground(.hidden, for: .windowToolbar) + .transition(.opacity) } } @@ -109,13 +122,17 @@ extension MainView { Group { if let detailStore = store.scope(state: \.secretDetail, action: \.secretDetail) { SecretDetailView(store: detailStore) + .transition(.opacity) } else { Text(.module("No secret selected")) .dvFont(.captionLG) .foregroundStyle(Color.dv(.gray700)) .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) } } + // id로 좁힌다. State 전체로 넓히면 조회 화면 안에서 필드를 열 때마다 상세가 다시 페이드된다. + .animation(Metrics.contentFade, value: store.secretDetail?.id) .navigationTitle("") // max는 주지 않는다 — 컬럼이 창을 채우지 못하면 윈도우 배경이 양옆에 드러난다. // 폼 폭 상한은 컬럼이 아니라 `SecretDetailView` 안의 `formMaxWidth()`가 담당한다. diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 8b25d636..b14c00dd 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -11,6 +11,15 @@ import DVDomain struct SecretListView: View { + // MARK: - Metrics + + private enum Metrics { + /// 정렬 변경으로 행이 자리를 바꾸거나, 새로 읽은 목록이 들어올 때 쓰는 전환. + static let reorder: Animation = .smooth(duration: 0.28) + /// 목록·빈 상태·오류가 갈릴 때, 그리고 필터가 바뀌어 내용이 통째로 갈릴 때 쓰는 겹침. + static let contentFade: Animation = .easeInOut(duration: 0.25) + } + // MARK: - Properties @Bindable var store: StoreOf @@ -42,14 +51,17 @@ extension SecretListView { ) .padding(.horizontal, 12) - switch store.secretsState { - case .failed: - errorView - case .loaded(let secrets) where secrets.isEmpty: - emptyView - default: - list + Group { + switch store.secretsState { + case .failed: + errorView.transition(.opacity) + case .loaded(let secrets) where secrets.isEmpty: + emptyView.transition(.opacity) + default: + list.transition(.opacity) + } } + .animation(Metrics.contentFade, value: store.secretsState) } } @@ -66,6 +78,12 @@ extension SecretListView { .listStyle(.sidebar) .scrollContentBackground(.hidden) .tint(Color(nsColor: .controlAccentColor).opacity(0.6)) + .animation(Metrics.reorder, value: secrets) + // 필터가 바뀌면 행이 통째로 갈린다. 같은 목록으로 두면 무관한 행을 하나씩 지우고 넣는 + // 것으로 그려 어수선해지므로 새 내용으로 본다. + .id(store.collection) + .transition(.opacity) + .animation(Metrics.contentFade, value: store.collection) } /// "이미 지남"과 "N일 이내 예정"을 섹션으로 나눠 보여준다. 쿼리가 이미 `expiringSoon` 순으로 정렬해 와서 diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 9e29a048..195c20ed 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -15,6 +15,8 @@ struct SidebarView: View { /// 갱신 중 이전 값에 씌우는 흐림. 더 낮추면 목록이 비활성처럼 보인다. static let refreshingOpacity: Double = 0.55 static let fade: Animation = .easeInOut(duration: 0.18) + /// 프로젝트 섹션 접기·펴기. 높이가 함께 움직이므로 fade보다 조금 길게 잡는다. + static let sectionToggle: Animation = .smooth(duration: 0.28) } // MARK: - Properties @@ -103,15 +105,27 @@ extension SidebarView { VStack(spacing: 12) { projectSectionHeader .padding(.horizontal, 8) + collapsibleProjectBody + } + // `value`를 좁히지 않으면 이름 변경 입력 한 글자마다 섹션 전체가 다시 애니메이션된다. + .animation(Metrics.fade, value: store.projectsState) + .animation(Metrics.fade, value: store.isRefreshingProjects) + } + + /// **`.clipped()`가 헤더를 포함하면 안 된다.** 클립 경계가 헤더보다 위에 생겨, + /// 접히는 목록이 "Project" 라벨 위를 지나간 뒤에야 잘린다. + private var collapsibleProjectBody: some View { + VStack(spacing: 0) { if store.isProjectSectionExpanded { projectSectionBody + // opacity만 주면 자리는 그대로인 채 내용만 없어져 접히는 것으로 보이지 않는다. + .transition(.move(edge: .top).combined(with: .opacity)) } else { Spacer(minLength: 0) } } - // `value`를 좁히지 않으면 이름 변경 입력 한 글자마다 섹션 전체가 다시 애니메이션된다. - .animation(Metrics.fade, value: store.projectsState) - .animation(Metrics.fade, value: store.isRefreshingProjects) + .clipped() + .animation(Metrics.sectionToggle, value: store.isProjectSectionExpanded) } /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦은 화면이라(시크릿 생성, 프로젝트 @@ -149,11 +163,12 @@ extension SidebarView { Spacer() projectHeaderButton(icon: "plus.circle") { store.send(.didTapAddProject) } .accessibilityLabel(String.module("Add Project")) - projectHeaderButton( - icon: store.isProjectSectionExpanded ? "chevron.down" : "chevron.up" - ) { + // 아이콘을 갈아끼우면 서로 다른 뷰라 툭 바뀐다. 하나를 돌려야 각도가 이어진다. + projectHeaderButton(icon: "chevron.down") { store.send(.didToggleProjectSection) } + .rotationEffect(.degrees(store.isProjectSectionExpanded ? 0 : 180)) + .animation(Metrics.sectionToggle, value: store.isProjectSectionExpanded) .accessibilityLabel(store.isProjectSectionExpanded ? String.module("Collapse Projects") : String.module("Expand Projects")) } } @@ -188,7 +203,7 @@ extension SidebarView { .listStyle(.sidebar) // 프로젝트가 추가·삭제·이름 변경될 때 행이 뚝 나타나지 않게 한다. `refresh` 경로가 목록을 // 비우지 않으므로 실제로 바뀐 행만 움직인다. - .animation(.smooth(duration: 0.25), value: store.projects) + .animation(Metrics.sectionToggle, value: store.projects) .scrollContentBackground(.hidden) .padding(.horizontal, -12) .onKeyPress(.return) { diff --git a/Projects/DVPresentation/Tests/AppFeatureTests.swift b/Projects/DVPresentation/Tests/AppFeatureTests.swift index 5e5e1edc..66721f21 100644 --- a/Projects/DVPresentation/Tests/AppFeatureTests.swift +++ b/Projects/DVPresentation/Tests/AppFeatureTests.swift @@ -10,6 +10,30 @@ import Testing @Suite("AppFeature") struct AppFeatureTests { + // MARK: - Screen 판정 + + /// 화면 전환 애니메이션이 이 값 하나에 걸려 있다. + @Test("screen: 세 화면이 각자 판정된다") + func screenReportsCurrentScreen() { + var onboarding = AppFeature.State() + onboarding.onboarding = .init() + #expect(onboarding.screen == .onboarding) + + var locked = AppFeature.State() + locked.locked = .init() + #expect(locked.screen == .locked) + + var main = AppFeature.State() + main.main = .init() + #expect(main.screen == .main) + } + + /// 셋을 다 비웠다가 하나를 세우는 구간이 있다(`task`). + @Test("screen: 아무것도 없으면 nil이다") + func screenIsNilWhenNothingPresented() { + #expect(AppFeature.State().screen == nil) + } + @Test("task는 온보딩을 완료했으면 locked 상태로 시작한다") func taskStartsLockedWhenOnboardingCompleted() async { let store = TestStore(initialState: AppFeature.State()) { From baca55209e81208849ff7f90a57820fe165e0b9f Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:42:35 +0900 Subject: [PATCH 44/61] =?UTF-8?q?[#96]=20refactor:=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=83=81=EC=88=98=EB=A5=BC=20Mot?= =?UTF-8?q?ionMetrics=EB=A1=9C=20=EB=AA=A8=EC=9C=BC=EA=B3=A0=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=EB=A5=BC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/DVCategory.swift | 4 +- .../Foundations/Motion/MotionMetrics.swift | 38 +++++++++++++++++++ .../Sources/Features/AppView.swift | 9 +---- .../Sources/Features/Main/MainView.swift | 13 +------ .../Components/DetailReadOnlyFieldView.swift | 2 +- .../SecretDetail/SecretDetailView.swift | 2 +- .../Features/SecretList/SecretListView.swift | 15 ++------ .../Features/Settings/SettingsView.swift | 2 +- .../Features/Sidebar/SidebarView.swift | 13 +++---- 9 files changed, 55 insertions(+), 43 deletions(-) create mode 100644 Projects/DVDesign/Sources/Foundations/Motion/MotionMetrics.swift diff --git a/Projects/DVDesign/Sources/Components/DVCategory.swift b/Projects/DVDesign/Sources/Components/DVCategory.swift index 9446d0fe..51dcfc42 100644 --- a/Projects/DVDesign/Sources/Components/DVCategory.swift +++ b/Projects/DVDesign/Sources/Components/DVCategory.swift @@ -43,8 +43,8 @@ public struct DVCategory: View { .contentShape(Rectangle()) } .buttonStyle(DVCategoryButtonStyle(isHovered: isHovered, isSelected: isSelected)) - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.12), value: isSelected) + .animation(MotionMetrics.hover, value: isHovered) + .animation(MotionMetrics.hover, value: isSelected) .onHover { isHovered = $0 } } } diff --git a/Projects/DVDesign/Sources/Foundations/Motion/MotionMetrics.swift b/Projects/DVDesign/Sources/Foundations/Motion/MotionMetrics.swift new file mode 100644 index 00000000..39b9e6e3 --- /dev/null +++ b/Projects/DVDesign/Sources/Foundations/Motion/MotionMetrics.swift @@ -0,0 +1,38 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +// MARK: - MotionMetrics + +/// 화면 전환·재배치에 쓰는 움직임 토큰. +/// +/// **용도로 나누고 시간은 공유한다.** 겹쳐 넘기는 것과 자리를 옮기는 것은 곡선이 달라야 +/// 자연스럽지만, 길이가 다르면 함께 일어날 때 따로 논다 — 사이드바를 한 번 누르면 목록과 +/// 상세가 같이 움직인다. +public enum MotionMetrics { + + // MARK: - Duration + + /// 더 길면 굼떠 보이고, 더 짧으면 전환이 아니라 깜빡임으로 읽힌다. + public static let standardDuration: TimeInterval = 0.25 + + /// 눈길을 끌면 안 되는 변화. + public static let subtleDuration: TimeInterval = 0.18 + + /// 커서보다 느리면 굼떠 보이므로 눈에 띄게 짧다. + public static let hoverDuration: TimeInterval = 0.12 + + // MARK: - Animation + + /// **내용이 갈릴 때.** 화면 전환, 컬럼 내용 교체. + public static let transition: Animation = .easeInOut(duration: standardDuration) + + /// **위치나 크기가 움직일 때.** 행 재배치, 섹션 접고 펴기. + public static let layout: Animation = .smooth(duration: standardDuration) + + /// **값만 살짝 바뀔 때.** 갱신 중 흐리기 등. + public static let subtle: Animation = .easeInOut(duration: subtleDuration) + + /// **hover·선택 되먹임.** + public static let hover: Animation = .easeOut(duration: hoverDuration) +} diff --git a/Projects/DVPresentation/Sources/Features/AppView.swift b/Projects/DVPresentation/Sources/Features/AppView.swift index 45cd0530..173704c7 100644 --- a/Projects/DVPresentation/Sources/Features/AppView.swift +++ b/Projects/DVPresentation/Sources/Features/AppView.swift @@ -3,6 +3,7 @@ import SwiftUI import ComposableArchitecture +import DVDesign // MARK: - AppView @@ -18,17 +19,11 @@ public struct AppView: View { self.store = store } - // MARK: - Metrics - - private enum Metrics { - static let screenFade: Animation = .easeInOut(duration: 0.25) - } - // MARK: - Body public var body: some View { content - .animation(Metrics.screenFade, value: store.screen) + .animation(MotionMetrics.transition, value: store.screen) // 진행 오버레이를 그리는 유일한 지점 (`.omc/GUIDELINES.md`). .windowBusyOverlay() .task { store.send(.task) } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 00cc601e..627e2443 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -9,15 +9,6 @@ import DVDesign struct MainView: View { - // MARK: - Metrics - - private enum Metrics { - /// 컬럼 내용이 갈릴 때 쓰는 겹침. 목록 컬럼(`SecretListView`)·화면 전환과 모두 같은 값이다. - static let contentFade: Animation = .easeInOut(duration: 0.25) - /// 설정처럼 화면 전체가 갈리는 전환. `AppView`의 값과 맞춘다. - static let screenFade: Animation = .easeInOut(duration: 0.25) - } - // MARK: - Properties @Bindable var store: StoreOf @@ -27,7 +18,7 @@ struct MainView: View { var body: some View { content // `screen`으로 좁히지 않으면 목록·상세가 바뀔 때마다 화면 전체가 다시 페이드된다. - .animation(Metrics.screenFade, value: store.screen) + .animation(MotionMetrics.transition, value: store.screen) .dvScreenBackground() .task { store.send(.task) } .sheet( @@ -132,7 +123,7 @@ extension MainView { } } // id로 좁힌다. State 전체로 넓히면 조회 화면 안에서 필드를 열 때마다 상세가 다시 페이드된다. - .animation(Metrics.contentFade, value: store.secretDetail?.id) + .animation(MotionMetrics.transition, value: store.secretDetail?.id) .navigationTitle("") // max는 주지 않는다 — 컬럼이 창을 채우지 못하면 윈도우 배경이 양옆에 드러난다. // 폼 폭 상한은 컬럼이 아니라 `SecretDetailView` 안의 `formMaxWidth()`가 담당한다. diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift index fa1789dc..39b082bb 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/Components/DetailReadOnlyFieldView.swift @@ -85,7 +85,7 @@ struct DetailReadOnlyFieldView: View { valueContainer } // 값을 키로 잡아야 즉시 열리는 경우와 인증을 거쳐 늦게 도착하는 경우가 같은 전환을 탄다. - .animation(.smooth(duration: 0.25), value: displayedValue) + .animation(MotionMetrics.layout, value: displayedValue) } } diff --git a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift index 5b5eb6f8..c11deb1a 100644 --- a/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretDetail/SecretDetailView.swift @@ -50,7 +50,7 @@ public struct SecretDetailView: View { } // 모드 전환만 애니메이션한다. `value`를 좁히지 않으면 편집 중 타이핑 한 글자마다 // 폼 전체가 다시 애니메이션된다. - .animation(.smooth(duration: 0.25), value: store.mode) + .animation(MotionMetrics.layout, value: store.mode) // 프레임 상한. header/footer는 본문과 분리된 채 각자 이 프레임을 따라간다. .formMaxWidth() .formLayout(layout) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index b14c00dd..84fe7ce7 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -11,15 +11,6 @@ import DVDomain struct SecretListView: View { - // MARK: - Metrics - - private enum Metrics { - /// 정렬 변경으로 행이 자리를 바꾸거나, 새로 읽은 목록이 들어올 때 쓰는 전환. - static let reorder: Animation = .smooth(duration: 0.28) - /// 목록·빈 상태·오류가 갈릴 때, 그리고 필터가 바뀌어 내용이 통째로 갈릴 때 쓰는 겹침. - static let contentFade: Animation = .easeInOut(duration: 0.25) - } - // MARK: - Properties @Bindable var store: StoreOf @@ -61,7 +52,7 @@ extension SecretListView { list.transition(.opacity) } } - .animation(Metrics.contentFade, value: store.secretsState) + .animation(MotionMetrics.transition, value: store.secretsState) } } @@ -78,12 +69,12 @@ extension SecretListView { .listStyle(.sidebar) .scrollContentBackground(.hidden) .tint(Color(nsColor: .controlAccentColor).opacity(0.6)) - .animation(Metrics.reorder, value: secrets) + .animation(MotionMetrics.layout, value: secrets) // 필터가 바뀌면 행이 통째로 갈린다. 같은 목록으로 두면 무관한 행을 하나씩 지우고 넣는 // 것으로 그려 어수선해지므로 새 내용으로 본다. .id(store.collection) .transition(.opacity) - .animation(Metrics.contentFade, value: store.collection) + .animation(MotionMetrics.transition, value: store.collection) } /// "이미 지남"과 "N일 이내 예정"을 섹션으로 나눠 보여준다. 쿼리가 이미 `expiringSoon` 순으로 정렬해 와서 diff --git a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift index bbf170e4..2bd35581 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift @@ -98,7 +98,7 @@ extension SettingsView { .foregroundStyle(isBackToAppHovered ? Color.dv(.gray900) : Color.dv(.gray700)) .listRowSeparator(.hidden) .onHover { isBackToAppHovered = $0 } - .animation(.easeOut(duration: 0.12), value: isBackToAppHovered) + .animation(MotionMetrics.hover, value: isBackToAppHovered) } private var detailColumn: some View { diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 195c20ed..2991bf79 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -14,9 +14,6 @@ struct SidebarView: View { private enum Metrics { /// 갱신 중 이전 값에 씌우는 흐림. 더 낮추면 목록이 비활성처럼 보인다. static let refreshingOpacity: Double = 0.55 - static let fade: Animation = .easeInOut(duration: 0.18) - /// 프로젝트 섹션 접기·펴기. 높이가 함께 움직이므로 fade보다 조금 길게 잡는다. - static let sectionToggle: Animation = .smooth(duration: 0.28) } // MARK: - Properties @@ -108,8 +105,8 @@ extension SidebarView { collapsibleProjectBody } // `value`를 좁히지 않으면 이름 변경 입력 한 글자마다 섹션 전체가 다시 애니메이션된다. - .animation(Metrics.fade, value: store.projectsState) - .animation(Metrics.fade, value: store.isRefreshingProjects) + .animation(MotionMetrics.subtle, value: store.projectsState) + .animation(MotionMetrics.subtle, value: store.isRefreshingProjects) } /// **`.clipped()`가 헤더를 포함하면 안 된다.** 클립 경계가 헤더보다 위에 생겨, @@ -125,7 +122,7 @@ extension SidebarView { } } .clipped() - .animation(Metrics.sectionToggle, value: store.isProjectSectionExpanded) + .animation(MotionMetrics.layout, value: store.isProjectSectionExpanded) } /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦은 화면이라(시크릿 생성, 프로젝트 @@ -168,7 +165,7 @@ extension SidebarView { store.send(.didToggleProjectSection) } .rotationEffect(.degrees(store.isProjectSectionExpanded ? 0 : 180)) - .animation(Metrics.sectionToggle, value: store.isProjectSectionExpanded) + .animation(MotionMetrics.layout, value: store.isProjectSectionExpanded) .accessibilityLabel(store.isProjectSectionExpanded ? String.module("Collapse Projects") : String.module("Expand Projects")) } } @@ -203,7 +200,7 @@ extension SidebarView { .listStyle(.sidebar) // 프로젝트가 추가·삭제·이름 변경될 때 행이 뚝 나타나지 않게 한다. `refresh` 경로가 목록을 // 비우지 않으므로 실제로 바뀐 행만 움직인다. - .animation(Metrics.sectionToggle, value: store.projects) + .animation(MotionMetrics.layout, value: store.projects) .scrollContentBackground(.hidden) .padding(.horizontal, -12) .onKeyPress(.return) { From 166fd8a35d16870feae7f82f10450a88685ae7cb Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:43:47 +0900 Subject: [PATCH 45/61] =?UTF-8?q?[#104]=20fix:=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=B0=BD=EC=97=90=20=EC=BB=A4=EC=84=9C=EA=B0=80=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EA=B0=84=20=EB=92=A4=20=EB=8B=A4=EB=A5=B8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=EC=9D=84=20=EB=88=8C=EB=9F=AC=EB=8F=84=20=ED=8F=AC?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=EA=B0=80=20=ED=92=80=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDesign/Sources/Components/DVTitleBar.swift | 16 ++++++++++++++++ .../Features/SecretList/SecretListView.swift | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Projects/DVDesign/Sources/Components/DVTitleBar.swift b/Projects/DVDesign/Sources/Components/DVTitleBar.swift index b9fd0fb6..f2112c97 100644 --- a/Projects/DVDesign/Sources/Components/DVTitleBar.swift +++ b/Projects/DVDesign/Sources/Components/DVTitleBar.swift @@ -12,6 +12,11 @@ public struct DVTitleBar: View { /// 정렬 버튼을 누르면 펼쳐질 메뉴 내용. `nil`이면 정렬 버튼 자체를 그리지 않는다. /// 클로저가 반환하는 뷰가 시스템 `Menu` 안에 들어가므로, 바깥 클릭·ESC·포커스 상실 처리는 시스템이 담당한다. public let sortMenuContent: (() -> AnyView)? + /// 검색 필드의 포커스를 바깥에서 풀 수 있게 열어 둔다 — 한 번 커서가 들어가면 다른 곳을 + /// 눌러도 놓지 않는 경우가 있다. `nil`이면 시스템에 맡긴다. + public let isSearchFocused: Binding? + + @FocusState private var searchFieldFocused: Bool // MARK: - Init @@ -19,11 +24,13 @@ public struct DVTitleBar: View { titleText: String, searchText: Binding, searchPromptText: String = "Search", + isSearchFocused: Binding? = nil, sortMenuContent: (() -> AnyView)? = nil ) { self.titleText = titleText self.searchText = searchText self.searchPromptText = searchPromptText + self.isSearchFocused = isSearchFocused self.sortMenuContent = sortMenuContent } @@ -78,6 +85,15 @@ extension DVTitleBar { .foregroundStyle(Color.dv(.gray900)) .textFieldStyle(.plain) .frame(maxWidth: .infinity) + .focused($searchFieldFocused) + // `@FocusState`는 뷰 안에서만 쓸 수 있어 두 방향을 각각 흘려보내야 한다. + .onChange(of: searchFieldFocused) { _, focused in + isSearchFocused?.wrappedValue = focused + } + .onChange(of: isSearchFocused?.wrappedValue) { _, requested in + guard let requested, requested != searchFieldFocused else { return } + searchFieldFocused = requested + } } .padding(.horizontal, 10) .frame(maxWidth: .infinity) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 84fe7ce7..8a7cf854 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -15,6 +15,9 @@ struct SecretListView: View { @Bindable var store: StoreOf + /// 목록을 누르면 내려 준다. 그러지 않으면 커서가 검색창에 남아 타이핑이 그리로 들어간다. + @State private var isSearchFocused = false + // MARK: - Body var body: some View { @@ -38,6 +41,7 @@ extension SecretListView { DVTitleBar( titleText: titleText, searchText: searchTextBinding, + isSearchFocused: $isSearchFocused, sortMenuContent: showsSort ? { AnyView(sortMenuContent) } : nil ) .padding(.horizontal, 12) @@ -75,6 +79,9 @@ extension SecretListView { .id(store.collection) .transition(.opacity) .animation(MotionMetrics.transition, value: store.collection) + // 빈 영역 클릭. 행 클릭은 선택 바인딩이 따로 처리한다. + .contentShape(Rectangle()) + .onTapGesture { isSearchFocused = false } } /// "이미 지남"과 "N일 이내 예정"을 섹션으로 나눠 보여준다. 쿼리가 이미 `expiringSoon` 순으로 정렬해 와서 @@ -241,7 +248,10 @@ extension SecretListView { private var selectedSecretIDBinding: Binding { Binding( get: { store.selectedSecretID }, - set: { store.send(.didSelectSecret(id: $0)) } + set: { + isSearchFocused = false + store.send(.didSelectSecret(id: $0)) + } ) } From eab2c9e95261acda7ce6cd0c3b8bc7d710e431e9 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:45:20 +0900 Subject: [PATCH 46/61] =?UTF-8?q?[#104]=20chore:=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=85=80=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Add=20to=20Project=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 4 --- .../SecretList/SecretListFeature.swift | 31 ------------------- .../Features/SecretList/SecretListView.swift | 8 ----- .../SecretList/SecretListFeatureTests.swift | 12 ------- 4 files changed, 55 deletions(-) diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index bc551f4a..5411a51a 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -93,10 +93,6 @@ "comment" : "Accessibility label for the \"Add to favorites\" button in the secret detail header.", "isCommentAutoGenerated" : true }, - "Add to Project" : { - "comment" : "A context menu item that adds a secret to a project.", - "isCommentAutoGenerated" : true - }, "Alert on repeated authentication failures" : { "comment" : "Text displayed in a settings row that allows the user to enable or disable alerts when repeated authentication failures occur.", "isCommentAutoGenerated" : true diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 03ae517e..4dd7f982 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -22,7 +22,6 @@ public struct SecretListFeature { public internal(set) var selectedSecretID: Secret.ID? public internal(set) var searchText = "" public internal(set) var sort: SecretQuery.Sort = .recentlyAdded - @Presents var destination: Destination.State? @Presents var alert: AlertState? public init(collection: SecretQuery.Collection = .all, projectName: String? = nil) { @@ -82,7 +81,6 @@ public struct SecretListFeature { case didSelectSecret(id: Secret.ID?) case didChangeSearchText(String) case didSelectSort(SecretQuery.Sort) - case didTapAddToProject(id: Secret.ID) case didTapDelete(id: Secret.ID) case didTapRecover(id: Secret.ID) case didTapDeleteForever(id: Secret.ID) @@ -99,7 +97,6 @@ public struct SecretListFeature { // MARK: - Child - case destination(PresentationAction) case alert(PresentationAction) // MARK: - Delegate @@ -116,13 +113,6 @@ public struct SecretListFeature { public enum Alert: Equatable {} } - // MARK: - Destination - - @Reducer - public enum Destination { - case addToProject(AddToProjectFeature) - } - // MARK: - Dependencies @Dependency(\.secretClient) var secretClient @@ -168,10 +158,6 @@ public struct SecretListFeature { state.selectedSecretID = id return .send(.delegate(.secretSelected(id))) - case .didTapAddToProject(let id): - state.destination = .addToProject(AddToProjectFeature.State(secretID: id)) - return .none - case .didTapDelete(let id): return mutationEffect(id: id) { try await secretClient.softDelete(id) } @@ -199,16 +185,6 @@ public struct SecretListFeature { } return .none - // 프로젝트 연결로 프로젝트별 개수가 바뀌므로 목록 갱신과 함께 부모에게도 알린다. - case .destination(.presented(.addToProject(.delegate(.projectLinked)))): - return .concatenate( - .send(.delegate(.secretsChanged)), - fetchSecretsEffect(query: state.query, debounced: false) - ) - - case .destination: - return .none - case .alert: return .none @@ -216,7 +192,6 @@ public struct SecretListFeature { return .none } } - .ifLet(\.$destination, action: \.destination) .ifLet(\.$alert, action: \.alert) } @@ -248,9 +223,3 @@ public struct SecretListFeature { } } } - -// MARK: - Destination Equatable - -// swift-composable-architecture는 @Reducer enum의 State/Action에 Equatable을 자동으로 붙이지 않는다 (공식 예제도 동일하게 명시적으로 붙임). -extension SecretListFeature.Destination.State: Equatable {} -extension SecretListFeature.Destination.Action: Equatable {} diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 8a7cf854..5c9dc3d4 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -23,11 +23,6 @@ struct SecretListView: View { var body: some View { content .task(id: store.collection) { store.send(.task) } - .sheet( - item: $store.scope(state: \.destination?.addToProject, action: \.destination.addToProject) - ) { addToProjectStore in - AddToProjectView(store: addToProjectStore) - } .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -145,9 +140,6 @@ extension SecretListView { } case .all, .liked, .notice, .expired, .project: - Button("Add to Project") { - store.send(.didTapAddToProject(id: secret.id)) - } Button("Delete", role: .destructive) { store.send(.didTapDelete(id: secret.id)) } diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index ab6ea212..69c5fa62 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -250,18 +250,6 @@ struct SecretListFeatureTests { } } - @Test("didTapAddToProject는 destination을 연다") - func addToProjectPresentsDestination() async { - let secretID = UUID() - let store = TestStore(initialState: SecretListFeature.State()) { - SecretListFeature() - } - - await store.send(.didTapAddToProject(id: secretID)) { - $0.destination = .addToProject(AddToProjectFeature.State(secretID: secretID)) - } - } - // MARK: - Helpers private func makeSecret(name: String) -> Secret { From a2c47b9f56eedddb5a011c21d4cccb6884cc5179 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:50:23 +0900 Subject: [PATCH 47/61] =?UTF-8?q?[#103]=20chore:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=85=80=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EC=97=90=EC=84=9C=20New=20Secret=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVPresentation/Sources/Features/Sidebar/SidebarView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 2991bf79..16b1803f 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -189,7 +189,6 @@ extension SidebarView { .tag(project.id) .contextMenu { Button(String.module("Rename")) { store.send(.didTapRename(id: project.id)) } - Button(String.module("New Secret")) { store.send(.didTapAddButton) } Divider() Button(String.module("Delete Project"), role: .destructive) { store.send(.didTapDelete(id: project.id)) From 9bc671fdcc1853bb9ab5e174256142da96db3fff Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:52:15 +0900 Subject: [PATCH 48/61] =?UTF-8?q?[#103]=20feat:=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=ED=95=98=EB=8B=A8=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=C2=B7=EC=84=A4=EC=A0=95=20=EB=B2=84=ED=8A=BC=EC=9D=84=20Liquid?= =?UTF-8?q?=20Glass=EB=A1=9C=20(macOS=2026=20=EC=9D=B4=EC=83=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/Sidebar/SidebarView.swift | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 16b1803f..9f60b420 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -255,15 +255,32 @@ extension SidebarView { .buttonStyle(.plain) } + /// macOS 26부터는 Liquid Glass 버튼으로 그린다. + /// + /// 배포 타겟이 macOS 14라 분기가 필요하다. 이 SDK의 SwiftUI에는 `glassEffect` modifier가 없고 + /// 버튼용 `GlassProminentButtonStyle`이 제공되므로 그쪽을 쓴다 — 색을 잃지 않도록 `.glass`가 + /// 아니라 tint를 받는 prominent 쪽을 골랐다. 26 미만은 기존 초록 원형 그대로다. + @ViewBuilder private func circleIconButton(icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: icon) - .dvFont(.captionLG) - .frame(width: 32, height: 32) - .background(Color.dv(.vaultGreen), in: Circle()) - .foregroundStyle(Color.dv(.white)) + if #available(macOS 26.0, *) { + Button(action: action) { + Image(systemName: icon) + .dvFont(.captionLG) + .frame(width: 32, height: 32) + } + .buttonStyle(.glassProminent) + .buttonBorderShape(.circle) + .tint(Color.dv(.vaultGreen)) + } else { + Button(action: action) { + Image(systemName: icon) + .dvFont(.captionLG) + .frame(width: 32, height: 32) + .background(Color.dv(.vaultGreen), in: Circle()) + .foregroundStyle(Color.dv(.white)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } From c080814fdbea1d57dc547d5d18dc0bc1dfbe4f92 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:55:23 +0900 Subject: [PATCH 49/61] =?UTF-8?q?[#96]=20feat:=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EB=B2=84=ED=8A=BC=20hover=C2=B7press=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Components/DVIconButton.swift | 96 +++++++++++++++++++ .../Sources/Components/DVTitleBar.swift | 7 +- .../Sources/Features/Main/MainView.swift | 16 ++-- .../Features/Sidebar/SidebarView.swift | 27 +++--- 4 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 Projects/DVDesign/Sources/Components/DVIconButton.swift diff --git a/Projects/DVDesign/Sources/Components/DVIconButton.swift b/Projects/DVDesign/Sources/Components/DVIconButton.swift new file mode 100644 index 00000000..08bc4aa5 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVIconButton.swift @@ -0,0 +1,96 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +// MARK: - DVIconButton + +/// 라벨 없이 아이콘만 있는 작은 버튼. hover·press 되먹임을 준다. +/// +/// **색은 호출부가 정한다.** 회색 아이콘은 회색 계열에서, 초록 아이콘은 초록 계열에서 +/// 움직여야 버튼의 성격이 유지된다. +public struct DVIconButton: View { + + // MARK: - Properties + + private let systemName: String + private let font: DVFont + private let idle: DVColor + private let hovered: DVColor + private let pressed: DVColor + /// 색 토큰만으로 press와 hover가 구분되지 않을 때 쓰는 보조 수단. + private let pressedOpacity: Double + private let hitSize: CGFloat + private let action: () -> Void + + @State private var isHovered = false + + // MARK: - Init + + public init( + systemName: String, + font: DVFont = .captionLG, + idle: DVColor, + hovered: DVColor, + pressed: DVColor, + pressedOpacity: Double = 1, + hitSize: CGFloat = 22, + action: @escaping () -> Void + ) { + self.systemName = systemName + self.font = font + self.idle = idle + self.hovered = hovered + self.pressed = pressed + self.pressedOpacity = pressedOpacity + self.hitSize = hitSize + self.action = action + } + + // MARK: - Body + + public var body: some View { + Button(action: action) { + Image(systemName: systemName) + .dvFont(font) + } + .buttonStyle( + DVIconButtonStyle( + isHovered: isHovered, + idle: idle, + hovered: hovered, + pressed: pressed, + pressedOpacity: pressedOpacity, + hitSize: hitSize + ) + ) + .onHover { isHovered = $0 } + .animation(MotionMetrics.hover, value: isHovered) + } +} + +// MARK: - ButtonStyle + +private struct DVIconButtonStyle: ButtonStyle { + + let isHovered: Bool + let idle: DVColor + let hovered: DVColor + let pressed: DVColor + let pressedOpacity: Double + let hitSize: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(Color.dv(tint(isPressed: configuration.isPressed))) + .opacity(configuration.isPressed ? pressedOpacity : 1) + // 배경이 없으면 넓힌 영역이 클릭을 받지 못한다. + .frame(width: hitSize, height: hitSize) + .contentShape(Rectangle()) + .animation(MotionMetrics.hover, value: configuration.isPressed) + } + + private func tint(isPressed: Bool) -> DVColor { + if isPressed { return pressed } + return isHovered ? hovered : idle + } +} diff --git a/Projects/DVDesign/Sources/Components/DVTitleBar.swift b/Projects/DVDesign/Sources/Components/DVTitleBar.swift index f2112c97..d40bdda2 100644 --- a/Projects/DVDesign/Sources/Components/DVTitleBar.swift +++ b/Projects/DVDesign/Sources/Components/DVTitleBar.swift @@ -17,6 +17,7 @@ public struct DVTitleBar: View { public let isSearchFocused: Binding? @FocusState private var searchFieldFocused: Bool + @State private var isSortHovered = false // MARK: - Init @@ -62,17 +63,21 @@ extension DVTitleBar { .padding(.vertical, 4) } + /// hover만 준다. `Menu`는 `ButtonStyle`을 타지 않아 눌림 상태를 알 수 없고, + /// 누르는 즉시 메뉴가 열려 그 자체가 되먹임이 된다. private func sortMenu(content: () -> AnyView) -> some View { Menu { content() } label: { Image(systemName: "arrow.up.arrow.down") .dvFont(.bodyXL) - .foregroundStyle(Color.dv(.gray800)) + .foregroundStyle(Color.dv(isSortHovered ? .gray900 : .gray800)) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) .fixedSize() + .onHover { isSortHovered = $0 } + .animation(MotionMetrics.hover, value: isSortHovered) } private var searchField: some View { diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 627e2443..3a40e9cd 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -133,15 +133,19 @@ extension MainView { ) } + /// 초록 토큰이 둘뿐이라 press는 색만으로 hover와 구분되지 않아 흐림을 함께 준다. private var lockButton: some View { - Button { + DVIconButton( + systemName: "lock", + font: .headingLG, + idle: .vaultGreen, + hovered: .vaultGreenDark, + pressed: .vaultGreenDark, + pressedOpacity: 0.7, + hitSize: 28 + ) { store.send(.didTapLock) - } label: { - Image(systemName: "lock") - .foregroundStyle(Color.dv(.vaultGreen)) - .dvFont(.headingLG) } - .buttonStyle(.plain) .accessibilityLabel(String.module("Lock App")) } } diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 9f60b420..2237929d 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -158,10 +158,22 @@ extension SidebarView { .dvFont(.captionMDSemibold) .foregroundStyle(Color.dv(.vaultGreen)) Spacer() - projectHeaderButton(icon: "plus.circle") { store.send(.didTapAddProject) } - .accessibilityLabel(String.module("Add Project")) + DVIconButton( + systemName: "plus.circle", + idle: .gray500, + hovered: .gray700, + pressed: .gray800 + ) { + store.send(.didTapAddProject) + } + .accessibilityLabel(String.module("Add Project")) // 아이콘을 갈아끼우면 서로 다른 뷰라 툭 바뀐다. 하나를 돌려야 각도가 이어진다. - projectHeaderButton(icon: "chevron.down") { + DVIconButton( + systemName: "chevron.down", + idle: .gray500, + hovered: .gray700, + pressed: .gray800 + ) { store.send(.didToggleProjectSection) } .rotationEffect(.degrees(store.isProjectSectionExpanded ? 0 : 180)) @@ -246,15 +258,6 @@ extension SidebarView { store.counts?.count(for: filter) } - private func projectHeaderButton(icon: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Image(systemName: icon) - .dvFont(.captionLG) - .foregroundStyle(Color.dv(.gray500)) - } - .buttonStyle(.plain) - } - /// macOS 26부터는 Liquid Glass 버튼으로 그린다. /// /// 배포 타겟이 macOS 14라 분기가 필요하다. 이 SDK의 SwiftUI에는 `glassEffect` modifier가 없고 From 6bc225e74401a9b8465f1fab3d7e13f8caeb1e72 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 06:57:42 +0900 Subject: [PATCH 50/61] =?UTF-8?q?[#96]=20chore:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94=20=EB=88=84=EB=9D=BD=EB=B6=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=EA=B3=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20doc?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=9C=84=EC=B9=98=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/Model/SecretQuery.swift | 4 +- .../Features/Settings/SettingsView.swift | 3 +- .../Features/Sidebar/SidebarFeature.swift | 5 +- .../Features/Sidebar/SidebarView.swift | 7 +-- .../CreateSecretFeatureTests.swift | 4 +- .../Tests/Main/MainFeatureTests.swift | 3 +- .../Tests/Sidebar/SidebarFeatureTests.swift | 59 +++++++++---------- 7 files changed, 41 insertions(+), 44 deletions(-) diff --git a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift index 91d55e08..254fedcb 100644 --- a/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift +++ b/Projects/DVDomain/Sources/Repository/Model/SecretQuery.swift @@ -29,7 +29,9 @@ public struct SecretQuery: Equatable, Sendable { } extension SecretQuery { - /// `Hashable`인 것은 목록 뷰가 "무엇을 보고 있는지"의 정체성(`.id(_:)`)으로 쓰기 때문이다. + /// `Hashable`인 것은 화면이 "무엇을 보고 있는지"의 정체성으로 쓰기 때문이다 — + /// 목록 뷰가 컬렉션이 바뀔 때 내용을 새로 그리는 기준(`.id(_:)`)으로 삼는다. + /// 연관값이 `Date`·`UUID`뿐이라 자동 합성으로 충분하다. public enum Collection: Equatable, Hashable, Sendable { case all case liked diff --git a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift index 2bd35581..52119dba 100644 --- a/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift +++ b/Projects/DVPresentation/Sources/Features/Settings/SettingsView.swift @@ -67,7 +67,8 @@ extension SettingsView { } } .listStyle(.sidebar) - // 메인 화면 사이드바와 같은 값으로 고정한다. 설정을 드나들 때 폭이 달라지지 않는다. + // 메인 화면 사이드바와 같은 값으로 고정한다(`MainView.sidebarColumn`). 설정을 드나들 때 + // 사이드바 폭이 달라지지 않고, 범위를 없애 `.balanced`가 폭을 재분배할 여지도 남기지 않는다. .navigationSplitViewColumnWidth( min: WindowLayoutMetrics.sidebarWidth, ideal: WindowLayoutMetrics.sidebarWidth, diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index f6bb0a28..ee1b6c87 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -181,9 +181,8 @@ public struct SidebarFeature { public var body: some ReducerOf { Reduce { state, action in switch action { - // `.task`는 최초 진입이 아니라 **화면이 다시 만들어질 때마다** 실행된다 — `MainView`가 - // 2컬럼 ↔ 3컬럼을 오가면 `NavigationSplitView` 타입이 달라져 사이드바까지 새로 만들어진다. - // 그때 `.loading`으로 되돌리면 목록과 숫자가 사라졌다 나타난다. + // `.task`는 화면이 다시 만들어질 때마다 실행된다. 그때 `.loading`으로 되돌리면 + // 이미 받아둔 목록과 숫자가 사라졌다 나타난다. case .task: if case .loaded = state.projectsState { state.isRefreshingProjects = true diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 2237929d..8a783e69 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -125,8 +125,8 @@ extension SidebarView { .animation(MotionMetrics.layout, value: store.isProjectSectionExpanded) } - /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦은 화면이라(시크릿 생성, 프로젝트 - /// 추가·이름 변경·삭제) 스피너로 갈아끼우면 목록이 사라졌다 나타나는 것으로 보인다. + /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦아 스피너로 갈아끼우면 + /// 목록이 사라졌다 나타나는 것으로 보인다. @ViewBuilder private var projectSectionBody: some View { if case .failed = store.projectsState { @@ -209,8 +209,6 @@ extension SidebarView { } } .listStyle(.sidebar) - // 프로젝트가 추가·삭제·이름 변경될 때 행이 뚝 나타나지 않게 한다. `refresh` 경로가 목록을 - // 비우지 않으므로 실제로 바뀐 행만 움직인다. .animation(MotionMetrics.layout, value: store.projects) .scrollContentBackground(.hidden) .padding(.horizontal, -12) @@ -258,6 +256,7 @@ extension SidebarView { store.counts?.count(for: filter) } + /// macOS 26부터는 Liquid Glass 버튼으로 그린다. /// /// 배포 타겟이 macOS 14라 분기가 필요하다. 이 SDK의 SwiftUI에는 `glassEffect` modifier가 없고 diff --git a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift index 5a0b307b..1e0623dd 100644 --- a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift @@ -23,7 +23,6 @@ struct CreateSecretFeatureTests { let store = TestStore(initialState: .init(secretType: .apiKeyToken)) { CreateSecretFeature() } withDependencies: { - // 기본 환경은 이 테스트의 관심사가 아니다. State 초기값과 같은 값을 물려 폼이 그대로이게 한다. $0.generalSettingsClient.defaultEnvironment = { "dev" } $0.projectClient.fetchProjects = { projects } } @@ -103,8 +102,7 @@ struct CreateSecretFeatureTests { /// `.dev`에 떨어져야 한다 — 폼이 빈 선택으로 열리면 안 된다. @Test("task: 설정에 알 수 없는 환경이 저장돼 있으면 dev로 떨어진다") func task_unknownDefaultEnvironment_fallsBackToDev() async { - // 초기값을 `.dev`가 아닌 것으로 두고 시작한다. `.dev`에서 출발하면 "폴백해서 dev가 됐다"와 - // "아무것도 하지 않았다"가 같은 결과라 단언이 공허해진다. + // `.dev`에서 출발하면 "폴백했다"와 "아무것도 안 했다"가 같은 결과라 단언이 공허해진다. var initial = CreateSecretFeature.State(secretType: .apiKeyToken) initial.meta.environment = .prod diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 7c3fa4b1..2be7c43f 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -194,8 +194,7 @@ struct MainFeatureTests { } } - /// 생성 중에는 2컬럼이라 상세가 화면에서 사라지지만 State는 살아남는다. 정리하지 않으면 - /// 생성을 마치고 3컬럼으로 돌아올 때 이전 상세가 다시 나타나고 Touch ID까지 다시 요구한다. + /// 상세 State가 살아남으면 돌아올 때 되살아나고 Touch ID까지 다시 요구한다. @Test("addButtonTapped은 조회 중이던 secretDetail과 목록 선택을 놓는다") func addButtonTappedClearsViewingSecret() async { let secret = Secret( diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index 623aebc3..d7a75977 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -45,8 +45,7 @@ struct SidebarFeatureTests { } } - /// 2컬럼 ↔ 3컬럼 전환으로 사이드바가 다시 만들어지면 `.task`가 한 번 더 실행된다. - /// 여기서 `.loading`으로 되돌리면 생성 화면을 드나들 때마다 사이드바가 깜빡인다. + /// 화면이 다시 만들어지면 `.task`가 한 번 더 실행된다. 여기서 `.loading`으로 되돌리면 깜빡인다. @Test("이미 로드된 상태에서 task가 다시 실행돼도 값을 비우지 않는다") func taskAfterReloadKeepsLoadedValues() async { let projects = [ProjectItem(id: UUID(), name: "Backend")] @@ -167,8 +166,6 @@ struct SidebarFeatureTests { await store.receive(.delegate(.selectionChanged(.project(id: id)))) } - /// delegate가 나가면 MainFeature가 목록 State를 새로 만드는데, collection 값은 그대로라 - /// `.task(id:)`가 재조회를 걸지 않아 목록이 빈 채로 남았다. 발신 자체를 막아야 한다. /// `selection`으로 판정하면 "같은 선택"으로 삼켜져 All에서 생성에 들어가면 돌아오지 못했다. @Test("생성 중에는 같은 항목을 다시 눌러도 delegate가 나간다") func didSelectSameItemWhileCreatingStillNotifies() async { @@ -201,6 +198,34 @@ struct SidebarFeatureTests { #expect(store.state.highlighted == .filter(.starred)) } + /// 대상 ID가 남으면 다음 삭제가 엉뚱한 프로젝트를 지울 수 있다. + @Test("삭제 alert를 확인 없이 닫으면 대상이 지워진다") + func dismissingDeleteAlertClearsTarget() async { + let item = ProjectItem(id: UUID(), name: "Backend") + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + + let store = TestStore(initialState: state) { SidebarFeature() } + + await store.send(.didTapDelete(id: item.id)) { + $0.deletingProjectID = item.id + $0.alert = AlertState { + TextState("'\(item.name)' 프로젝트를 삭제할까요?") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete) { TextState("삭제") } + ButtonState(role: .cancel) { TextState("취소") } + } message: { + TextState("프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다.") + } + } + await store.send(.alert(.dismiss)) { + $0.deletingProjectID = nil + $0.alert = nil + } + } + + /// 흘려보내면 MainFeature가 목록 State를 새로 만드는데, `collection`이 그대로라 + /// `.task(id:)`가 재조회를 걸지 않아 목록이 빈 채로 남는다. @Test("이미 선택된 항목을 다시 눌러도 delegate를 보내지 않는다") func didSelectSameSelectionSendsNothing() async { // 기본 selection이 .filter(.all)이므로 그대로 다시 누른다. @@ -350,32 +375,6 @@ struct SidebarFeatureTests { } } - /// 대상 ID가 남으면 다음 삭제가 엉뚱한 프로젝트를 지울 수 있다. - @Test("삭제 alert를 확인 없이 닫으면 대상이 지워진다") - func dismissingDeleteAlertClearsTarget() async { - let item = ProjectItem(id: UUID(), name: "Backend") - var state = SidebarFeature.State() - state.projectsState = .loaded([item]) - - let store = TestStore(initialState: state) { SidebarFeature() } - - await store.send(.didTapDelete(id: item.id)) { - $0.deletingProjectID = item.id - $0.alert = AlertState { - TextState("'\(item.name)' 프로젝트를 삭제할까요?") - } actions: { - ButtonState(role: .destructive, action: .confirmDelete) { TextState("삭제") } - ButtonState(role: .cancel) { TextState("취소") } - } message: { - TextState("프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다.") - } - } - await store.send(.alert(.dismiss)) { - $0.deletingProjectID = nil - $0.alert = nil - } - } - @Test("삭제 확인 후 선택 중이던 프로젝트면 selection이 .all로 초기화되고 refetch한다") func deleteResetsSelectionAndRefetches() async { let item = ProjectItem(id: UUID(), name: "Backend") From 5764c80ccb318de067b497adeab13865a56e82fc Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:56:49 +0900 Subject: [PATCH 51/61] =?UTF-8?q?[#96]=20fix:=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=A4=91=20=EA=B3=A0=EB=A5=B8=20=ED=95=AD=EB=AA=A9=EC=9D=B4=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20=EC=A0=84=EC=97=90=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=EB=A5=BC=20=EC=98=AE=EA=B8=B0=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 6 ++ .../Features/Sidebar/SidebarFeature.swift | 11 ++-- .../Tests/Main/MainFeatureTests.swift | 55 ++++++++++++++++--- .../Tests/Sidebar/SidebarFeatureTests.swift | 15 +++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index d5476dc4..2b966486 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -217,6 +217,7 @@ public struct MainFeature { return .send(.sidebar(.setCreatingSecret(false))) // 목적지를 버리지 않으면 다음에 폼의 Cancel을 눌렀을 때 엉뚱하게 목록으로 나간다. + // 사이드바는 아직 움직이지 않았으므로 되돌릴 것이 없다. case .createSecret(.alert(.dismiss)): state.pendingSelection = nil return .none @@ -300,7 +301,12 @@ extension MainFeature { /// 사이드바에서 고른 곳으로 이동한다. 생성 중이었다면 그 플로우를 접는다. /// 고른 곳이 이미 보고 있던 곳일 수 있어(생성 중 같은 필터 재선택) `retarget`을 거친다. + /// + /// **사이드바를 옮기는 것도 여기서 한다.** `SidebarFeature.didSelect`는 생성 중일 때 + /// 알리기만 하므로(확인을 거쳐야 확정된다) 여기서 옮기지 않으면 이어지는 + /// `setCreatingSecret(false)`가 이전 자리를 강조해 목록과 어긋난다. private func applySelection(_ selection: SidebarSelection, _ state: inout State) { + state.sidebar.selection = selection exitCreating(&state) state.secretDetail = nil let target = makeCollection(selection: selection, projects: state.sidebar.projects) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index ee1b6c87..12051516 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -233,12 +233,13 @@ public struct SidebarFeature { case .didSelect(let selection) where state.highlighted == selection: return .none - // 생성 중이면 강조를 켜지 않고 목적지만 갈아둔다 — 확인을 취소했을 때 생성 화면인데 - // 항목이 선택된 것처럼 보이기 때문이다. 강조는 `setCreatingSecret(false)`가 켠다. + // 생성 중이면 `previous`를 건드리지 않고 알리기만 한다. 이 선택은 아직 확정이 아니라 + // 부모가 띄우는 "변경사항을 버릴까요?"를 거쳐야 하고, 계속 편집을 고르면 없던 일이 + // 돼야 한다. 여기서 갈아두면 되돌릴 방법이 없어 강조와 목록이 어긋난 채 남는다. + // 확정됐을 때 목적지를 옮기는 것은 부모의 몫이다(`MainFeature`). case .didSelect(let selection): - switch state.mode { - case .browsing: state.mode = .browsing(selection) - case .creating: state.mode = .creating(previous: selection) + if case .browsing = state.mode { + state.mode = .browsing(selection) } return .send(.delegate(.selectionChanged(selection))) diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 2be7c43f..1aa33c6b 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -168,10 +168,10 @@ struct MainFeatureTests { MainFeature() } - await store.send(.sidebar(.didSelect(.filter(.all)))) { - $0.sidebar.selection = .filter(.all) - } + // 생성 중에는 사이드바가 스스로 옮기지 않는다 — 확정한 부모가 옮긴다. + await store.send(.sidebar(.didSelect(.filter(.all)))) await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.all))))) { + $0.sidebar.selection = .filter(.all) $0.selectSecretType = nil } await store.receive(.sidebar(.setCreatingSecret(false))) { @@ -302,9 +302,8 @@ struct MainFeatureTests { let store = TestStore(initialState: initial) { MainFeature() } - await store.send(.sidebar(.didSelect(.filter(.starred)))) { - $0.sidebar.mode = .creating(previous: .filter(.starred)) - } + // 아직 확정이 아니므로 사이드바는 움직이지 않는다. + await store.send(.sidebar(.didSelect(.filter(.starred)))) await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.starred))))) { $0.pendingSelection = .filter(.starred) } @@ -328,13 +327,15 @@ struct MainFeatureTests { var initial = MainFeature.State() initial.selectSecretType = .init() initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) - initial.sidebar.mode = .creating(previous: .filter(.starred)) + // 들어오기 전 자리(All)와 다른 곳을 골라야 사이드바가 실제로 옮겨지는지 보인다. + initial.sidebar.mode = .creating(previous: .filter(.all)) initial.pendingSelection = .filter(.starred) let store = TestStore(initialState: initial) { MainFeature() } await store.send(.createSecret(.delegate(.cancelled))) { $0.pendingSelection = nil + $0.sidebar.selection = .filter(.starred) $0.createSecret = nil $0.selectSecretType = nil $0.secretList = SecretListFeature.State(collection: .liked) @@ -344,6 +345,46 @@ struct MainFeatureTests { } } + /// 계속 편집을 골랐는데 사이드바가 옮겨져 있으면, 생성을 마쳤을 때 강조는 새 항목인데 + /// 목록은 원래 것이 남아 둘이 영영 어긋난다. + @Test("확인 없이 alert를 닫으면 사이드바의 돌아갈 곳이 그대로다") + func dismissingConfirmationKeepsSidebarDestination() async { + var initial = MainFeature.State() + initial.selectSecretType = .init() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.sidebar.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.sidebar(.didSelect(.filter(.starred)))) + await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.starred))))) { + $0.pendingSelection = .filter(.starred) + } + await store.receive(.createSecret(.didTapCancel)) { + $0.createSecret?.alert = AlertState { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmCancel) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } + } + + await store.send(.createSecret(.alert(.dismiss))) { + $0.createSecret?.alert = nil + $0.pendingSelection = nil + } + #expect(store.state.sidebar.mode == .creating(previous: .filter(.all))) + + // 마치고 나면 들어오기 전 자리로 돌아가야 한다. + await store.send(.sidebar(.setCreatingSecret(false))) { + $0.sidebar.mode = .browsing(.filter(.all)) + } + } + /// 목적지를 버리지 않으면 나중에 폼의 Cancel이 엉뚱하게 목록으로 나간다. @Test("확인 없이 alert를 닫으면 기억해 둔 목적지를 버린다") func dismissingConfirmationDropsPendingSelection() async { diff --git a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift index d7a75977..6e5afc74 100644 --- a/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -179,6 +179,21 @@ struct SidebarFeatureTests { #expect(store.state.highlighted == nil) } + /// 여기서 갈아두면 되돌릴 방법이 없어, 부모의 확인에서 "계속 편집"을 골라도 + /// 생성을 마쳤을 때 강조는 새 항목인데 목록은 원래 것이 남는다. + @Test("생성 중의 선택은 돌아갈 곳을 바꾸지 않고 알리기만 한다") + func didSelectWhileCreatingKeepsReturnDestination() async { + var state = SidebarFeature.State() + state.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: state) { SidebarFeature() } + + await store.send(.didSelect(.filter(.starred))) + await store.receive(.delegate(.selectionChanged(.filter(.starred)))) + #expect(store.state.mode == .creating(previous: .filter(.all))) + #expect(store.state.highlighted == nil) + } + @Test("setCreatingSecret은 강조만 끄고 돌아갈 곳은 유지한다") func setCreatingSecretKeepsReturnDestination() async { var state = SidebarFeature.State() From bfab8e2b5df548f0dd95abc511356e41b40c814d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:57:12 +0900 Subject: [PATCH 52/61] =?UTF-8?q?[#96]=20fix:=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20+=20=EB=B2=84=ED=8A=BC=EC=9D=B4=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=97=86=EC=9D=B4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=8F=BC=EC=9D=84=20=EB=B2=84=EB=A6=AC=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 5 ++++ .../Tests/Main/MainFeatureTests.swift | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 2b966486..4ea4f3e1 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -278,7 +278,12 @@ extension MainFeature { applySelection(selection, &state) return .send(.sidebar(.setCreatingSecret(false))) + // 폼이 열려 있으면 사이드바 선택과 똑같이 확인을 거친다. `pendingSelection`을 두지 + // 않으므로 확인되면 목록이 아니라 타입 선택으로 돌아간다 — `+`가 뜻하는 바 그대로다. case .addButtonTapped: + guard state.createSecret == nil else { + return .send(.createSecret(.didTapCancel)) + } enterCreating(&state) return .send(.sidebar(.setCreatingSecret(true))) diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 1aa33c6b..0bad7d1d 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -225,6 +225,33 @@ struct MainFeatureTests { } } + /// 사이드바 선택은 확인을 거치는데 `+`만 조용히 버리면 같은 사이드바에서 결과가 갈린다. + @Test("addButtonTapped은 폼이 열려 있으면 확인을 거친다") + func addButtonTappedAsksBeforeDiscardingForm() async { + var initial = MainFeature.State() + initial.createSecret = CreateSecretFeature.State(secretType: .apiKeyToken) + initial.sidebar.mode = .creating(previous: .filter(.all)) + + let store = TestStore(initialState: initial) { MainFeature() } + + await store.send(.sidebar(.didTapAddButton)) + await store.receive(.sidebar(.delegate(.addButtonTapped))) + await store.receive(.createSecret(.didTapCancel)) { + $0.createSecret?.alert = AlertState { + TextState("Discard changes?", bundle: .module) + } actions: { + ButtonState(role: .destructive, action: .confirmCancel) { + TextState("Discard", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("Keep editing", bundle: .module) + } + } + } + // 목적지를 두지 않았으므로 확인하면 목록이 아니라 타입 선택으로 돌아간다. + #expect(store.state.pendingSelection == nil) + } + // MARK: - CreateSecret Routing @Test("typeSelected는 createSecret State를 세팅한다") From 3bb42035c7f11d935f3e1b84e708e7db54c4786d Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:57:36 +0900 Subject: [PATCH 53/61] =?UTF-8?q?[#96]=20fix:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=A5=BC=20=EB=A7=8C=EB=93=A4=EB=A9=B4=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A4=91=EC=9D=B4=EB=8D=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EA=B0=80=20=EB=82=A8=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 4 ++ .../Tests/Main/MainFeatureTests.swift | 48 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 4ea4f3e1..b27ba65e 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -230,6 +230,10 @@ public struct MainFeature { // 생성 중이면 목록을 옮기지 않는다 — 마치고 엉뚱한 목록으로 돌아오게 된다. if case .browsing = state.sidebar.mode { state.sidebar.selection = .project(id: item.id) + // 새 프로젝트는 아직 `sidebar.projects`에 없어 이름을 찾을 수 없으므로 + // `applySelection`을 쓰지 못한다. 상세를 놓는 것은 같은 이유로 필요하다 — + // 남겨두면 빈 목록 옆에 이전 시크릿이 그대로 떠 있고 Touch ID를 다시 요구한다. + state.secretDetail = nil state.secretList.retarget(to: .project(id: item.id), projectName: item.name) } return .send(.sidebar(.refresh)) diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 0bad7d1d..37a61376 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -487,6 +487,54 @@ struct MainFeatureTests { } } + /// 남겨두면 빈 새 프로젝트 목록 옆에 이전 시크릿이 그대로 떠 있고 Touch ID를 다시 요구한다. + @Test("projectCreated는 조회 중이던 secretDetail을 놓는다") + func projectCreatedClearsViewingSecret() async { + let item = ProjectItem(id: UUID(), name: "Backend") + let secret = Secret( + id: UUID(), + name: "Test Token", + secretType: .apiKeyToken, + createdAt: Date(), + updatedAt: Date(), + payload: SecretPayload(encryptedData: Data(), keyTag: "test", schemaVersion: 1) + ) + + var initial = MainFeature.State() + initial.createProject = .init() + initial.sidebar.mode = .browsing(initial.sidebar.selection) + initial.secretDetail = SecretDetailFeature.State(secret: secret) + initial.secretList.selectedSecretID = secret.id + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { [item] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.createProject(.presented(.delegate(.projectCreated(item))))) { + $0.createProject = nil + $0.secretDetail = nil + $0.sidebar.selection = .project(id: item.id) + $0.secretList = SecretListFeature.State( + collection: .project(id: item.id), + projectName: "Backend" + ) + } + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } + await store.receive(.sidebar(.projectsResponse(.success([item])))) { + $0.sidebar.isRefreshingProjects = false + $0.sidebar.projectsState = .loaded([item]) + } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } + } + @Test("projectCreated는 생성 중일 때 selection을 변경하지 않는다") func projectCreatedDoesNotSelectWhenCreatingSecret() async { let item = ProjectItem(id: UUID(), name: "Backend") From ce0da0aecf354e76bfc44dbe8d7eb37607e4eace Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:58:01 +0900 Subject: [PATCH 54/61] =?UTF-8?q?[#96]=20fix:=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=B3=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=EA=B2=83=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=8C=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Main/MainFeature.swift | 4 +- .../Tests/Main/MainFeatureTests.swift | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index b27ba65e..4ba4ffd3 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -300,8 +300,10 @@ extension MainFeature { return .none // State를 갈아끼우면 목록이 버려지는데 `collection`이 같아 다시 읽지도 않는다. + // 판정 기준은 사이드바가 아니라 목록이 지금 보고 있는 것이다 — 둘은 어긋날 수 있고 + // (생성 중의 "돌아갈 곳") 사이드바를 보면 A의 목록에 B의 이름이 붙는다. case .projectRenamed(let item): - if case .project(id: item.id) = state.sidebar.selection { + if case .project(id: item.id) = state.secretList.collection { state.secretList.projectName = item.name } return .none diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 37a61376..8e0b1452 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -901,6 +901,43 @@ struct MainFeatureTests { } } + /// 사이드바를 기준으로 삼으면 생성 중의 "돌아갈 곳"과 목록이 어긋난 사이에 + /// A의 목록에 B의 이름이 붙는다. + @Test("projectRenamed는 목록이 그 프로젝트를 보고 있지 않으면 타이틀을 건드리지 않는다") + func projectRenamedIgnoresWhenListTargetsElsewhere() async { + let item = ProjectItem(id: UUID(), name: "Backend") + let renamed = ProjectItem(id: item.id, name: "Backend V2") + + var initial = MainFeature.State() + initial.sidebar.projectsState = .loaded([item]) + // 사이드바의 "돌아갈 곳"은 프로젝트지만 목록은 아직 All을 보고 있다. + initial.sidebar.mode = .creating(previous: .project(id: item.id)) + initial.secretList = SecretListFeature.State(collection: .all) + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { [renamed] } + $0.sidebarClient.fetchCounts = { _, _ in SecretCounts() } + $0.date = .constant(Self.referenceDate) + } + + await store.send(.sidebar(.renameResponse(.success(renamed)))) + await store.receive(.sidebar(.delegate(.projectRenamed(renamed)))) + #expect(store.state.secretList.projectName == nil) + + await store.receive(.sidebar(.refresh)) { + $0.sidebar.isRefreshingProjects = true + } + await store.receive(.sidebar(.projectsResponse(.success([renamed])))) { + $0.sidebar.isRefreshingProjects = false + $0.sidebar.projectsState = .loaded([renamed]) + } + await store.receive(.sidebar(.countsResponse(.success(SecretCounts())))) { + $0.sidebar.countsState = .loaded(SecretCounts()) + } + } + // MARK: - Lock @Test("didTapLock은 lockRequested를 delegate로 알린다") From 2bf10154822a897c51d6a9197ba34bbd963bd646 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:58:24 +0900 Subject: [PATCH 55/61] =?UTF-8?q?[#96]=20fix:=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=A0=84=ED=99=98=C2=B7=EC=B4=88=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EB=92=A4=20=EA=B8=B0=EC=96=B5=ED=95=B4=20=EB=91=94=20?= =?UTF-8?q?=EB=AA=A9=EC=A0=81=EC=A7=80=EA=B0=80=20=EB=82=A8=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVPresentation/Sources/Features/Main/MainFeature.swift | 2 ++ Projects/DVPresentation/Tests/Main/MainFeatureTests.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 4ba4ffd3..2b9223c1 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -366,6 +366,8 @@ extension MainFeature { state.createProject = nil state.createSecret = nil state.secretDetail = nil + // 여기서 폼을 닫으면 `.alert(.dismiss)`가 다시 올 일이 없어 목적지가 영영 남는다. + state.pendingSelection = nil state.sidebar.mode = .browsing(.filter(.all)) state.secretList = .init(collection: .all) } diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift index 8e0b1452..d4007238 100644 --- a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -990,6 +990,8 @@ struct MainFeatureTests { initial.sidebar.selection = .project(id: projectID) initial.sidebar.mode = .creating(previous: initial.sidebar.selection) initial.secretList = .init(collection: .project(id: projectID), projectName: "Old") + // 여기서 폼을 닫으면 `.alert(.dismiss)`가 다시 올 일이 없어 목적지가 영영 남는다. + initial.pendingSelection = .filter(.starred) let store = TestStore(initialState: initial) { MainFeature() @@ -1005,6 +1007,7 @@ struct MainFeatureTests { $0.selectSecretType = nil $0.createProject = nil $0.createSecret = nil + $0.pendingSelection = nil $0.sidebar.selection = .filter(.all) $0.sidebar.mode = .browsing($0.sidebar.selection) $0.secretList = .init(collection: .all) From ddb9c338ec05160a2d05f954b1edfa338af38bc0 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:58:52 +0900 Subject: [PATCH 56/61] =?UTF-8?q?[#96]=20fix:=20Return=20=ED=82=A4?= =?UTF-8?q?=EA=B0=80=20=EA=B0=95=EC=A1=B0=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=80=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=EC=97=B4?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVPresentation/Sources/Features/Sidebar/SidebarView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 8a783e69..6a40bdfc 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -214,7 +214,9 @@ extension SidebarView { .padding(.horizontal, -12) .onKeyPress(.return) { guard store.renamingProjectID == nil else { return .ignored } - if case .project(id: let id) = store.selection { + // 강조된 것에만 반응한다. `selection`을 보면 생성 중(강조 없음)에도 이전 프로젝트의 + // 이름 변경이 열린다. + if case .project(id: let id) = store.highlighted { store.send(.didTapRename(id: id)) return .handled } From 77782d58069ccd4d2c82932c5a0b3d2dd1967cf7 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:59:15 +0900 Subject: [PATCH 57/61] =?UTF-8?q?[#76]=20fix:=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20=EB=8B=A4=EB=85=80=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20=EC=83=9D=EC=84=B1=20=ED=8F=BC=EC=9D=98=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EC=84=A0=ED=83=9D=EC=9D=B4=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateSecret/CreateSecretFeature.swift | 18 +++++++---- .../CreateSecretFeatureTests.swift | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift index 26e4b91c..1c833428 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/CreateSecretFeature.swift @@ -38,6 +38,10 @@ public struct CreateSecretFeature { /// 프로젝트 목록을 로드 중인 동안 true. Project picker spinner용. var isLoadingProjects = false + /// 설정의 기본 환경을 이미 얹었는지. `.task`는 화면이 다시 만들어질 때마다 실행되므로 + /// 이 표시가 없으면 사용자가 고른 환경을 덮어쓴다. + var didApplyDefaultEnvironment = false + @Presents var alert: AlertState? /// 프로젝트 생성 시트 State. `didTapCreateProject` 시 세팅되어 sheet 노출. @@ -122,14 +126,18 @@ public struct CreateSecretFeature { // MARK: View case .task: - // 설정의 기본 환경을 폼 초기값으로 얹는다. 이 화면은 열 때마다 State가 새로 - // 만들어지므로(`MainFeature`) 사용자가 고른 값을 덮어쓸 창이 없다. + // 설정의 기본 환경을 폼 초기값으로 **한 번만** 얹는다. `.task`는 화면이 다시 + // 만들어질 때마다 실행되는데(설정 화면을 다녀오면 State는 살아남은 채 뷰만 + // 다시 만들어진다) 매번 얹으면 사용자가 고른 환경이 조용히 되돌아간다. // // 저장된 문자열이 폼 enum에 없으면 `.dev`로 떨어뜨린다 — 설정 화면이 읽을 때와 // 같은 규칙이다(`GeneralSettingsFeature.task`). - state.meta.environment = SecretEnvironment( - rawValue: generalSettingsClient.defaultEnvironment() - ) ?? .dev + if !state.didApplyDefaultEnvironment { + state.didApplyDefaultEnvironment = true + state.meta.environment = SecretEnvironment( + rawValue: generalSettingsClient.defaultEnvironment() + ) ?? .dev + } state.isLoadingProjects = true return .run { send in do { diff --git a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift index 1e0623dd..6326964c 100644 --- a/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift +++ b/Projects/DVPresentation/Tests/Features/CreateSecret/CreateSecretFeatureTests.swift @@ -28,6 +28,7 @@ struct CreateSecretFeatureTests { } await store.send(.task) { + $0.didApplyDefaultEnvironment = true $0.isLoadingProjects = true } await store.receive(.projectsResponse(.success(projects))) { @@ -48,6 +49,7 @@ struct CreateSecretFeatureTests { } await store.send(.task) { + $0.didApplyDefaultEnvironment = true $0.isLoadingProjects = true } await store.receive(.projectsResponse(.failure(.repositoryFailure(.storageUnavailable)))) { @@ -69,6 +71,7 @@ struct CreateSecretFeatureTests { } await store.send(.task) { + $0.didApplyDefaultEnvironment = true $0.isLoadingProjects = true } await store.receive(.projectsResponse(.failure(.unexpected))) { @@ -90,6 +93,7 @@ struct CreateSecretFeatureTests { } await store.send(.task) { + $0.didApplyDefaultEnvironment = true $0.meta.environment = .prod $0.isLoadingProjects = true } @@ -114,6 +118,7 @@ struct CreateSecretFeatureTests { } await store.send(.task) { + $0.didApplyDefaultEnvironment = true $0.meta.environment = .dev $0.isLoadingProjects = true } @@ -122,6 +127,31 @@ struct CreateSecretFeatureTests { } } + /// `.task`는 최초 진입이 아니라 뷰가 다시 만들어질 때마다 실행된다 — 설정 화면을 다녀오면 + /// State는 살아남은 채 뷰만 다시 만들어진다. 매번 얹으면 고른 환경이 조용히 되돌아간다. + @Test("task: 다시 실행돼도 사용자가 고른 환경을 덮어쓰지 않는다") + func task_rerun_keepsUserChosenEnvironment() async { + var initial = CreateSecretFeature.State(secretType: .apiKeyToken) + // 기본 환경을 이미 얹은 뒤 사용자가 직접 prod로 바꾼 상태. + initial.didApplyDefaultEnvironment = true + initial.meta.environment = .prod + + let store = TestStore(initialState: initial) { + CreateSecretFeature() + } withDependencies: { + $0.generalSettingsClient.defaultEnvironment = { "dev" } + $0.projectClient.fetchProjects = { [] } + } + + await store.send(.task) { + $0.isLoadingProjects = true + } + #expect(store.state.meta.environment == .prod) + await store.receive(.projectsResponse(.success([]))) { + $0.isLoadingProjects = false + } + } + // MARK: - selectedSubType binding @Test("selectedSubType 변경: content 교체 + validationErrors / detection state 초기화") From d48184c834d8324b371f77e0f7c8d43660350f5e Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:59:52 +0900 Subject: [PATCH 58/61] =?UTF-8?q?[#96]=20fix:=20=EB=B7=B0=EA=B0=80=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=A7=8C=EB=93=A4=EC=96=B4=EC=A7=88=20?= =?UTF-8?q?=EB=95=8C=20=EC=9D=B4=EB=AF=B8=20=EB=B0=9B=EC=95=84=EB=91=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=B9=84=EC=9A=B0=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretList/SecretListFeature.swift | 12 +++++++++++- .../SecretList/SecretListFeatureTests.swift | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift index 4dd7f982..7293107c 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListFeature.swift @@ -131,7 +131,17 @@ public struct SecretListFeature { public var body: some ReducerOf { Reduce { state, action in switch action { - case .task, .didTapRetry: + // 이미 받아둔 목록이 있으면 비우지 않는다. `.task`는 최초 진입이 아니라 뷰가 다시 + // 만들어질 때마다 실행되므로(설정 화면 왕복 등) 비우면 목록이 사라졌다 나타난다. + case .task: + switch state.secretsState { + case .loaded: break + default: state.secretsState = .loading + } + return fetchSecretsEffect(query: state.query, debounced: false) + + // 재시도는 실패 화면에서만 눌리므로 되돌릴 목록이 없다. + case .didTapRetry: state.secretsState = .loading return fetchSecretsEffect(query: state.query, debounced: false) diff --git a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift index 69c5fa62..4bebe9f7 100644 --- a/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift +++ b/Projects/DVPresentation/Tests/SecretList/SecretListFeatureTests.swift @@ -44,6 +44,25 @@ struct SecretListFeatureTests { } } + /// `.task`는 최초 진입이 아니라 뷰가 다시 만들어질 때마다 실행된다(설정 화면 왕복 등). + /// 비우면 이미 보고 있던 목록이 사라졌다 나타난다. + @Test("이미 로드된 상태에서 task가 다시 실행돼도 목록을 비우지 않는다") + func taskAfterReloadKeepsLoadedSecrets() async { + let secret = makeSecret(name: "GitHub API Key") + var initialState = SecretListFeature.State() + initialState.secretsState = .loaded([secret]) + + let store = TestStore(initialState: initialState) { + SecretListFeature() + } withDependencies: { + $0.secretClient.fetchByQuery = { _ in [secret] } + } + + await store.send(.task) + #expect(store.state.secretsState == .loaded([secret])) + await store.receive(.secretsResponse(.success([secret]))) + } + @Test("didTapRetry는 failed 상태에서 다시 loading으로 전환하고 재조회한다") func retryRefetchesAfterFailure() async { let secret = makeSecret(name: "GitHub API Key") From 87e8bc205adc200f865ac803280387565d1d4c18 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:00:18 +0900 Subject: [PATCH 59/61] =?UTF-8?q?[#104]=20fix:=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=83=AD=20=EC=A0=9C=EC=8A=A4=EC=B2=98=EA=B0=80=20=ED=96=89=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=EA=B3=BC=20=EA=B2=BD=ED=95=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/SecretList/SecretListView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift index 5c9dc3d4..b0ebfc26 100644 --- a/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift +++ b/Projects/DVPresentation/Sources/Features/SecretList/SecretListView.swift @@ -74,9 +74,11 @@ extension SecretListView { .id(store.collection) .transition(.opacity) .animation(MotionMetrics.transition, value: store.collection) - // 빈 영역 클릭. 행 클릭은 선택 바인딩이 따로 처리한다. + // 어디를 누르든 검색 포커스를 놓는다. `onTapGesture`는 탭을 삼켜 NSTableView가 처리하는 + // 행 클릭과 경합하므로, 이벤트를 흘려보내는 `simultaneousGesture`를 쓴다. + // `contentShape`은 행이 없는 빈 영역도 눌리게 하려고 남긴다. .contentShape(Rectangle()) - .onTapGesture { isSearchFocused = false } + .simultaneousGesture(TapGesture().onEnded { isSearchFocused = false }) } /// "이미 지남"과 "N일 이내 예정"을 섹션으로 나눠 보여준다. 쿼리가 이미 `expiringSoon` 순으로 정렬해 와서 From a2947a478087f528c3effc2fdca2f4c45ac59fa3 Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:15 +0900 Subject: [PATCH 60/61] =?UTF-8?q?[#104]=20chore:=20=EC=96=B4=EB=94=94?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20=EC=93=B0=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20AddToProject=20=ED=99=94=EB=A9=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/Localizable.xcstrings | 6 - .../Sources/Dependencies/SecretClient.swift | 2 +- .../AddToProject/AddToProjectFeature.swift | 173 ------------------ .../AddToProject/AddToProjectView.swift | 154 ---------------- .../AddToProjectFeatureTests.swift | 150 --------------- 5 files changed, 1 insertion(+), 484 deletions(-) delete mode 100644 Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift delete mode 100644 Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift delete mode 100644 Projects/DVPresentation/Tests/AddToProject/AddToProjectFeatureTests.swift diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 5411a51a..c0dddf8a 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -334,9 +334,6 @@ }, "Discard changes?" : { - }, - "Done" : { - }, "e.g -----BEGIN CERTIFICATE-----" : { @@ -1026,9 +1023,6 @@ }, "프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다." : { - }, - "프로젝트에 추가하지 못했어요" : { - }, "확인" : { diff --git a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift index b4fb66b1..90b33b51 100644 --- a/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift +++ b/Projects/DVPresentation/Sources/Dependencies/SecretClient.swift @@ -5,7 +5,7 @@ import Foundation import ComposableArchitecture import DVDomain -/// SecretList / AddToProject / CreateProject Feature가 공유하는 Secret·Project Client. +/// SecretList / SecretDetail / CreateProject Feature가 공유하는 Secret·Project Client. /// Live 조립은 Devault(App 타겟)에서 각 UseCase를 wrap한다. @DependencyClient public struct SecretClient: Sendable { diff --git a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift deleted file mode 100644 index 520ff22e..00000000 --- a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift +++ /dev/null @@ -1,173 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -import Foundation - -import ComposableArchitecture -import DVDomain - -// MARK: - AddToProjectFeature - -@Reducer -public struct AddToProjectFeature { - - // MARK: - State - - @ObservableState - public struct State: Equatable { - public let secretID: Secret.ID - public internal(set) var projectsState: LoadingState, SidebarError> = .idle - public internal(set) var selectedProjectID: ProjectItem.ID? - @Presents var destination: Destination.State? - @Presents var alert: AlertState? - - public init(secretID: Secret.ID) { - self.secretID = secretID - } - } - - // MARK: - Action - - public enum Action: Equatable { - - // MARK: - View - - case task - case didSelectProject(id: ProjectItem.ID?) - case didTapCreateNewProject - case didTapDone - case didTapCancel - - // MARK: - Internal - - case projectsResponse(Result<[ProjectItem], SidebarError>) - case linkResponse(Result) - - // MARK: - Child - - case destination(PresentationAction) - case alert(PresentationAction) - - // MARK: - Delegate - - case delegate(Delegate) - - public enum Delegate: Equatable { - case projectLinked - } - - public enum Alert: Equatable {} - } - - // MARK: - Destination - - @Reducer - public enum Destination { - case createProject(CreateProjectFeature) - } - - // MARK: - Dependencies - - @Dependency(\.sidebarClient) var sidebarClient - @Dependency(\.secretClient) var secretClient - @Dependency(\.dismiss) var dismiss - - // MARK: - Init - - public init() {} - - // MARK: - Body - - public var body: some ReducerOf { - Reduce { state, action in - switch action { - case .task: - state.projectsState = .loading - return .run { send in - do { - let projects = try await sidebarClient.fetchProjects() - await send(.projectsResponse(.success(projects))) - } catch let error as SidebarError { - await send(.projectsResponse(.failure(error))) - } catch { - await send(.projectsResponse(.failure(.fetchFailed))) - } - } - - case .projectsResponse(.success(let projects)): - state.projectsState = .loaded(IdentifiedArray(uniqueElements: projects)) - return .none - - case .projectsResponse(.failure(let error)): - state.projectsState = .failed(error) - return .none - - case .didSelectProject(let id): - state.selectedProjectID = id - return .none - - case .didTapCreateNewProject: - state.destination = .createProject(CreateProjectFeature.State()) - return .none - - case .destination(.presented(.createProject(.delegate(.projectCreated(let item))))): - var projects: IdentifiedArrayOf = { - if case let .loaded(projects) = state.projectsState { return projects } - return [] - }() - projects.append(item) - state.projectsState = .loaded(projects) - state.selectedProjectID = item.id - return .none - - case .destination: - return .none - - case .didTapDone: - guard let projectID = state.selectedProjectID else { - return .none - } - return .run { [secretID = state.secretID] send in - do { - try await secretClient.linkProject(secretID, projectID) - await send(.linkResponse(.success(secretID))) - } catch { - await send(.linkResponse(.failure(SecretUseCaseError.map(error)))) - } - } - - case .linkResponse(.success): - return .run { send in - await send(.delegate(.projectLinked)) - await dismiss() - } - - case .linkResponse(.failure): - state.alert = AlertState { - TextState("프로젝트에 추가하지 못했어요") - } actions: { - ButtonState(role: .cancel) { TextState("확인") } - } message: { - TextState("잠시 후 다시 시도해주세요.") - } - return .none - - case .didTapCancel: - return .run { _ in await dismiss() } - - case .alert: - return .none - - case .delegate: - return .none - } - } - .ifLet(\.$destination, action: \.destination) - .ifLet(\.$alert, action: \.alert) - } -} - -// MARK: - Destination Equatable - -// swift-composable-architecture는 @Reducer enum의 State/Action에 Equatable을 자동으로 붙이지 않는다. -extension AddToProjectFeature.Destination.State: Equatable {} -extension AddToProjectFeature.Destination.Action: Equatable {} diff --git a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift deleted file mode 100644 index 8ba072cd..00000000 --- a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -import Foundation -import SwiftUI - -import ComposableArchitecture -import DVDesign - -// MARK: - AddToProjectView - -struct AddToProjectView: View { - - // MARK: - Properties - - @Bindable var store: StoreOf - @State private var isProjectMenuPresented = false - - // MARK: - Body - - var body: some View { - content - .task { store.send(.task) } - .sheet( - item: $store.scope(state: \.destination?.createProject, action: \.destination.createProject) - ) { createProjectStore in - CreateProjectView(store: createProjectStore) - } - .alert($store.scope(state: \.alert, action: \.alert)) - } -} - -// MARK: - Subviews - -extension AddToProjectView { - - private var content: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Select Project") - .dvFont(.bodyLG) - .foregroundStyle(Color.dv(.black)) - - projectPickerButton - .floatingPanel(isPresented: $isProjectMenuPresented) { - projectMenu - } - - HStack(spacing: 12) { - Spacer() - Button("Cancel") { - store.send(.didTapCancel) - } - .keyboardShortcut(.cancelAction) - - Button("Done") { - store.send(.didTapDone) - } - .buttonStyle(.borderedProminent) - .keyboardShortcut(.defaultAction) - .disabled(store.selectedProjectID == nil) - } - } - .padding(20) - .frame(width: 420) - } - - private var projectPickerButton: some View { - Button { - isProjectMenuPresented.toggle() - } label: { - HStack { - Text(selectedProjectName ?? "Select a project") - .dvFont(.bodyLG) - .foregroundStyle(selectedProjectName == nil ? Color.dv(.gray400) : Color.dv(.gray900)) - Spacer() - Image(systemName: "chevron.down") - .dvFont(.captionMDRegular) - .foregroundStyle(Color.dv(.gray500)) - } - .padding(.horizontal, 12) - .frame(width: DVComponentSize.md.width, height: 36) - .background(Color.dv(.gray200)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - private var projectMenu: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(projects) { project in - projectMenuRow(title: project.name) { - store.send(.didSelectProject(id: project.id)) - isProjectMenuPresented = false - } - } - - if !projects.isEmpty { - Divider() - .padding(.vertical, 2) - } - - projectMenuRow(title: "New Project...") { - isProjectMenuPresented = false - store.send(.didTapCreateNewProject) - } - } - .padding(6) - .frame(width: DVComponentSize.md.width) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .shadow(color: Color(nsColor: .shadowColor).opacity(0.15), radius: 16, y: 6) - } - - private func projectMenuRow(title: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(title) - .dvFont(.bodyLG) - .fontWeight(.regular) - .foregroundStyle(Color.dv(.gray900)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .padding(.vertical, 6) - } - - private var selectedProjectName: String? { - guard let id = store.selectedProjectID else { return nil } - return projects[id: id]?.name - } - - private var projects: IdentifiedArrayOf { - if case let .loaded(projects) = store.projectsState { - return projects - } - return [] - } -} - -// MARK: - Preview - -#if DEBUG - -#Preview { - AddToProjectView( - store: Store(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } withDependencies: { - $0.secretClient = .previewValue - } - ) -} - -#endif diff --git a/Projects/DVPresentation/Tests/AddToProject/AddToProjectFeatureTests.swift b/Projects/DVPresentation/Tests/AddToProject/AddToProjectFeatureTests.swift deleted file mode 100644 index 3f020940..00000000 --- a/Projects/DVPresentation/Tests/AddToProject/AddToProjectFeatureTests.swift +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -import ComposableArchitecture -import DVDomain -import Foundation -import Testing - -@testable import DVPresentation - -@MainActor -@Suite("AddToProjectFeature") -struct AddToProjectFeatureTests { - - @Test("task는 조회에 성공하면 projects를 채운다") - func taskSuccess() async { - let project = makeProject(name: "CheerLot") - let store = TestStore(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } withDependencies: { - $0.sidebarClient.fetchProjects = { [project] } - } - - await store.send(.task) { - $0.projectsState = .loading - } - await store.receive(.projectsResponse(.success([project]))) { - $0.projectsState = .loaded([project]) - } - } - - @Test("초기 State는 idle이며, 조회 후 결과가 0개인 loaded([])와 구분된다") - func initialStateIsIdleNotLoaded() { - let state = AddToProjectFeature.State(secretID: UUID()) - - #expect(state.projectsState == .idle) - #expect(state.projectsState != .loaded([])) - } - - @Test("didSelectProject는 선택만 반영하고 아직 연결하지 않는다") - func selectProjectOnlyUpdatesState() async { - let project = makeProject(name: "CheerLot") - let store = TestStore(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } - - await store.send(.didSelectProject(id: project.id)) { - $0.selectedProjectID = project.id - } - } - - @Test("didTapDone은 선택된 프로젝트가 없으면 아무 일도 안 한다") - func doneIgnoresWhenNothingSelected() async { - let store = TestStore(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } - - await store.send(.didTapDone) - } - - @Test("didTapDone은 선택된 프로젝트를 연결하고 성공하면 delegate로 알린 뒤 dismiss한다") - func doneLinksSelectedProjectAndDismisses() async { - let secretID = UUID() - let project = makeProject(name: "CheerLot") - var didDismiss = false - var initialState = AddToProjectFeature.State(secretID: secretID) - initialState.selectedProjectID = project.id - let store = TestStore(initialState: initialState) { - AddToProjectFeature() - } withDependencies: { - $0.secretClient.linkProject = { _, _ in } - $0.dismiss = DismissEffect { didDismiss = true } - } - - await store.send(.didTapDone) - await store.receive(.linkResponse(.success(secretID))) - await store.receive(.delegate(.projectLinked)) - - #expect(didDismiss) - } - - @Test("didTapDone은 연결에 실패하면 alert를 띄우고 dismiss하지 않는다") - func doneShowsAlertOnFailure() async { - let project = makeProject(name: "CheerLot") - var initialState = AddToProjectFeature.State(secretID: UUID()) - initialState.selectedProjectID = project.id - let store = TestStore(initialState: initialState) { - AddToProjectFeature() - } withDependencies: { - $0.secretClient.linkProject = { _, _ in throw SecretUseCaseError.unexpected } - } - - await store.send(.didTapDone) - await store.receive(.linkResponse(.failure(.unexpected))) { - $0.alert = AlertState { - TextState("프로젝트에 추가하지 못했어요") - } actions: { - ButtonState(role: .cancel) { TextState("확인") } - } message: { - TextState("잠시 후 다시 시도해주세요.") - } - } - } - - @Test("didTapCreateNewProject는 CreateProject destination을 연다") - func createNewProjectPresentsDestination() async { - let store = TestStore(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } - - await store.send(.didTapCreateNewProject) { - $0.destination = .createProject(CreateProjectFeature.State()) - } - } - - @Test("CreateProject에서 프로젝트가 만들어지면 목록에 추가하고 선택 상태로 만든다 (연결/dismiss는 안 함)") - func createdProjectIsAddedAndSelected() async { - let secretID = UUID() - let newProject = makeProject(name: "New Project") - var initialState = AddToProjectFeature.State(secretID: secretID) - initialState.destination = .createProject(CreateProjectFeature.State()) - let store = TestStore(initialState: initialState) { - AddToProjectFeature() - } - - await store.send(.destination(.presented(.createProject(.delegate(.projectCreated(newProject)))))) { - $0.projectsState = .loaded([newProject]) - $0.selectedProjectID = newProject.id - } - } - - @Test("didTapCancel은 dismiss한다") - func cancelDismisses() async { - var didDismiss = false - let store = TestStore(initialState: AddToProjectFeature.State(secretID: UUID())) { - AddToProjectFeature() - } withDependencies: { - $0.dismiss = DismissEffect { didDismiss = true } - } - - await store.send(.didTapCancel) - - #expect(didDismiss) - } - - // MARK: - Helpers - - private func makeProject(name: String) -> ProjectItem { - ProjectItem(id: UUID(), name: name) - } -} From 38e28948633db6c7cce19cca5a1726356fe3a28c Mon Sep 17 00:00:00 2001 From: doyeonk429 <80318425+doyeonk429@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:01:35 +0900 Subject: [PATCH 61/61] =?UTF-8?q?[#96]=20chore:=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EB=A1=9C=EB=94=A9=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=EC=99=80=20=EC=B2=AB=20=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?fade-in=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Features/Sidebar/SidebarFeature.swift | 7 +++++-- .../Sources/Features/Sidebar/SidebarView.swift | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 12051516..cfc2e030 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -189,8 +189,11 @@ public struct SidebarFeature { } else { state.projectsState = .loading } - if case .loaded = state.countsState {} else { - state.countsState = .loading + // 숫자에 `isRefreshing` 짝이 없는 것은 갱신 경로(`.countsRefreshRequested`)가 + // 아예 `.loading`으로 되돌리지 않기 때문이다 — 흐릴 것 없이 이전 값이 계속 보인다. + switch state.countsState { + case .loaded: break + default: state.countsState = .loading } return fetchProjectsEffect() diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 6a40bdfc..20dd2bdf 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -127,6 +127,9 @@ extension SidebarView { /// 스피너 대신 자리를 유지한 채 opacity만 움직인다. 갱신이 잦아 스피너로 갈아끼우면 /// 목록이 사라졌다 나타나는 것으로 보인다. + /// + /// 첫 로드 동안에는 **아무것도 그리지 않는다.** 아직 목록이 없어 흐릴 대상이 없고, + /// "없다"고 쓰면 로딩 중에 잘못 알린 셈이 된다. 도착하면 opacity로 나타난다. @ViewBuilder private var projectSectionBody: some View { if case .failed = store.projectsState { @@ -149,6 +152,7 @@ extension SidebarView { } else { projectList .opacity(store.isRefreshingProjects ? Metrics.refreshingOpacity : 1) + .transition(.opacity) } }