diff --git a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift index 0c91707..505123c 100644 --- a/Projects/DVDesign/Sources/Components/DVProjectContainer.swift +++ b/Projects/DVDesign/Sources/Components/DVProjectContainer.swift @@ -8,6 +8,8 @@ public struct DVProjectContainer: View { // MARK: - Properties + static let projectIconSystemName = "tray" + public let name: String public let count: Int @@ -37,7 +39,7 @@ public struct DVProjectContainer: View { extension DVProjectContainer { private var projectIcon: some View { - Image(systemName: "tray") + Image(systemName: DVProjectContainer.projectIconSystemName) .dvFont(.captionLG) .foregroundStyle(Color.dv(.gray900)) } diff --git a/Projects/DVDesign/Sources/Components/DVProjectRenameContainer.swift b/Projects/DVDesign/Sources/Components/DVProjectRenameContainer.swift new file mode 100644 index 0000000..bf831e1 --- /dev/null +++ b/Projects/DVDesign/Sources/Components/DVProjectRenameContainer.swift @@ -0,0 +1,58 @@ +// Copyright © 2026 Devault. All rights reserved + +import SwiftUI + +/// 프로젝트 행 인라인 리네임 컴포넌트. +/// DVProjectContainer와 동일한 leading icon + 레이아웃에서 이름 부분을 TextField로 교체한다. +public struct DVProjectRenameContainer: View { + + // MARK: - Properties + + @Binding public var text: String + public var onSubmit: () -> Void + public var onCancel: () -> Void + + @FocusState private var isFocused: Bool + @State private var isHandled = false + + // MARK: - Init + + public init( + text: Binding, + onSubmit: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self._text = text + self.onSubmit = onSubmit + self.onCancel = onCancel + } + + // MARK: - Body + + public var body: some View { + HStack(spacing: 4) { + Image(systemName: DVProjectContainer.projectIconSystemName) + .dvFont(.captionLG) + .foregroundStyle(Color.dv(.gray900)) + TextField("", text: $text) + .dvFont(.bodyMD) + .textFieldStyle(.plain) + .focused($isFocused) + .onSubmit { + isHandled = true + onSubmit() + } + .onExitCommand { + isHandled = true + onCancel() + } + } + .padding(2) + .frame(minWidth: 120, alignment: .leading) + .onAppear { isFocused = true } + .onChange(of: isFocused) { _, focused in + guard !focused, !isHandled else { return } + onSubmit() + } + } +} diff --git a/Projects/DVPresentation/Resources/Localizable.xcstrings b/Projects/DVPresentation/Resources/Localizable.xcstrings index 91840cf..4a7fd80 100644 --- a/Projects/DVPresentation/Resources/Localizable.xcstrings +++ b/Projects/DVPresentation/Resources/Localizable.xcstrings @@ -3,10 +3,6 @@ "strings" : { "Access your secrets on all your\nApple devices, securely encrypted." : { - }, - "Content" : { - "comment" : "The title of the main content section in the preview.", - "isCommentAutoGenerated" : true }, "Create Project" : { @@ -52,6 +48,9 @@ }, "목록을 불러오지 못했어요" : { + }, + "불러오지 못했어요" : { + }, "시크릿이 없어요" : { diff --git a/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift b/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift new file mode 100644 index 0000000..929f0ce --- /dev/null +++ b/Projects/DVPresentation/Sources/Dependencies/SidebarClient.swift @@ -0,0 +1,30 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import Foundation + +@DependencyClient +public struct SidebarClient: Sendable { + public var fetchProjects: @Sendable () async throws -> [ProjectItem] + public var createProject: @Sendable (_ name: String) async throws -> ProjectItem + public var renameProject: @Sendable (_ id: ProjectItem.ID, _ name: String) async throws -> ProjectItem + public var deleteProject: @Sendable (_ id: ProjectItem.ID) async throws -> Void +} + +extension SidebarClient: TestDependencyKey { + public static let testValue = SidebarClient() + + public static let previewValue = SidebarClient( + fetchProjects: { ProjectItem.previews }, + createProject: { name in ProjectItem(id: UUID(), name: name) }, + renameProject: { id, name in ProjectItem(id: id, name: name) }, + deleteProject: { _ in } + ) +} + +extension DependencyValues { + public var sidebarClient: SidebarClient { + get { self[SidebarClient.self] } + set { self[SidebarClient.self] = newValue } + } +} diff --git a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift index c566b58..520ff22 100644 --- a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift +++ b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectFeature.swift @@ -15,8 +15,8 @@ public struct AddToProjectFeature { @ObservableState public struct State: Equatable { public let secretID: Secret.ID - public internal(set) var projectsState: LoadingState, ProjectUseCaseError> = .idle - public internal(set) var selectedProjectID: Project.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? @@ -32,14 +32,14 @@ public struct AddToProjectFeature { // MARK: - View case task - case didSelectProject(id: Project.ID?) + case didSelectProject(id: ProjectItem.ID?) case didTapCreateNewProject case didTapDone case didTapCancel // MARK: - Internal - case projectsResponse(Result<[Project], ProjectUseCaseError>) + case projectsResponse(Result<[ProjectItem], SidebarError>) case linkResponse(Result) // MARK: - Child @@ -67,6 +67,7 @@ public struct AddToProjectFeature { // MARK: - Dependencies + @Dependency(\.sidebarClient) var sidebarClient @Dependency(\.secretClient) var secretClient @Dependency(\.dismiss) var dismiss @@ -83,10 +84,12 @@ public struct AddToProjectFeature { state.projectsState = .loading return .run { send in do { - let projects = try await secretClient.fetchProjects() + 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(ProjectUseCaseError.map(error)))) + await send(.projectsResponse(.failure(.fetchFailed))) } } @@ -106,14 +109,14 @@ public struct AddToProjectFeature { state.destination = .createProject(CreateProjectFeature.State()) return .none - case .destination(.presented(.createProject(.delegate(.projectCreated(let project))))): - var projects: IdentifiedArrayOf = { + case .destination(.presented(.createProject(.delegate(.projectCreated(let item))))): + var projects: IdentifiedArrayOf = { if case let .loaded(projects) = state.projectsState { return projects } return [] }() - projects.append(project) + projects.append(item) state.projectsState = .loaded(projects) - state.selectedProjectID = project.id + state.selectedProjectID = item.id return .none case .destination: diff --git a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift index b87d8e7..1edf0c2 100644 --- a/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift +++ b/Projects/DVPresentation/Sources/Features/AddToProject/AddToProjectView.swift @@ -5,7 +5,6 @@ import SwiftUI import ComposableArchitecture import DVDesign -import DVDomain // MARK: - AddToProjectView @@ -130,7 +129,7 @@ extension AddToProjectView { return projects[id: id]?.name } - private var projects: IdentifiedArrayOf { + private var projects: IdentifiedArrayOf { if case let .loaded(projects) = store.projectsState { return projects } diff --git a/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectFeature.swift b/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectFeature.swift index 09509aa..52451a6 100644 --- a/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectFeature.swift +++ b/Projects/DVPresentation/Sources/Features/CreateProject/CreateProjectFeature.swift @@ -3,7 +3,6 @@ import Foundation import ComposableArchitecture -import DVDomain // MARK: - CreateProjectFeature @@ -33,7 +32,7 @@ public struct CreateProjectFeature { // MARK: - Internal - case createResponse(Result) + case createResponse(Result) // MARK: - Child @@ -44,7 +43,7 @@ public struct CreateProjectFeature { case delegate(Delegate) public enum Delegate: Equatable { - case projectCreated(Project) + case projectCreated(ProjectItem) } public enum Alert: Equatable {} @@ -52,7 +51,7 @@ public struct CreateProjectFeature { // MARK: - Dependencies - @Dependency(\.secretClient) var secretClient + @Dependency(\.sidebarClient) var sidebarClient @Dependency(\.dismiss) var dismiss // MARK: - Init @@ -76,20 +75,33 @@ public struct CreateProjectFeature { state.isCreating = true return .run { send in do { - let project = try await secretClient.createProject(trimmedName) - await send(.createResponse(.success(project))) + let item = try await sidebarClient.createProject(trimmedName) + await send(.createResponse(.success(item))) + } catch let error as SidebarError { + await send(.createResponse(.failure(error))) } catch { - await send(.createResponse(.failure(ProjectUseCaseError.map(error)))) + await send(.createResponse(.failure(.createFailed))) } } - case .createResponse(.success(let project)): + case .createResponse(.success(let item)): state.isCreating = false return .run { send in - await send(.delegate(.projectCreated(project))) + await send(.delegate(.projectCreated(item))) await dismiss() } + case .createResponse(.failure(.nameTaken)): + state.isCreating = false + state.alert = AlertState { + TextState("이미 사용 중인 프로젝트 이름이에요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("다른 이름을 입력해주세요.") + } + return .none + case .createResponse(.failure): state.isCreating = false state.alert = AlertState { diff --git a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift index 4d35646..71e76d4 100644 --- a/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift +++ b/Projects/DVPresentation/Sources/Features/CreateSecret/Model/CreatableSecretType.swift @@ -4,7 +4,7 @@ import DVDomain import Foundation import SwiftUI -enum CreatableSecretType: String, CaseIterable, Hashable { +public enum CreatableSecretType: String, CaseIterable, Hashable { case apiKeyToken case oauth case database diff --git a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift index 446f49c..ad096e8 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainFeature.swift @@ -1,8 +1,7 @@ // Copyright © 2026 Devault. All rights reserved -import SwiftUI - import ComposableArchitecture +import SwiftUI // MARK: - MainFeature @@ -15,6 +14,10 @@ public struct MainFeature { public struct State: Equatable { public var columnVisibility: NavigationSplitViewVisibility = .all var sidebar: SidebarFeature.State = .init() + var secretList: SecretListFeature.State = .init(collection: .all) + /// sheet가 아닌 2-column NavigationSplitView 전환 용도이므로 @Presents 미사용 + var selectSecretType: SelectSecretTypeFeature.State? + @Presents var createProject: CreateProjectFeature.State? public init() {} } @@ -31,6 +34,9 @@ public struct MainFeature { // MARK: - Child case sidebar(SidebarFeature.Action) + case secretList(SecretListFeature.Action) + case selectSecretType(SelectSecretTypeFeature.Action) + case createProject(PresentationAction) // MARK: - Delegate @@ -50,6 +56,9 @@ public struct MainFeature { Scope(state: \.sidebar, action: \.sidebar) { SidebarFeature() } + Scope(state: \.secretList, action: \.secretList) { + SecretListFeature() + } Reduce { state, action in switch action { case .binding: @@ -64,10 +73,37 @@ public struct MainFeature { case .sidebar: return .none + case .secretList: + return .none + + case .selectSecretType(.delegate(.typeSelected)): + // TODO: createSecret 연결 + return .none + + case .selectSecretType: + return .none + + case .createProject(.presented(.delegate(.projectCreated(let item)))): + state.createProject = nil + if !state.sidebar.isCreatingSecret { + state.sidebar.selection = .project(id: item.id) + state.secretList = .init(collection: .project(id: item.id), projectName: item.name) + } + return .send(.sidebar(.task)) + + case .createProject: + return .none + case .delegate: return .none } } + .ifLet(\.selectSecretType, action: \.selectSecretType) { + SelectSecretTypeFeature() + } + .ifLet(\.$createProject, action: \.createProject) { + CreateProjectFeature() + } } } @@ -80,17 +116,46 @@ extension MainFeature { delegate: SidebarFeature.Action.Delegate ) -> Effect { switch delegate { - case .selectionChanged: - return .none + case .selectionChanged(let selection): + state.selectSecretType = nil + state.secretList = makeSecretListState(selection: selection, projects: state.sidebar.projects) + return .send(.sidebar(.setCreatingSecret(false))) case .addButtonTapped: - return .none + state.selectSecretType = .init() + return .send(.sidebar(.setCreatingSecret(true))) - case .settingsButtonTapped: + case .addProjectTapped: + state.createProject = .init() return .none - case .addProjectTapped: + case .projectRenamed(let item): + if case .project(id: item.id) = state.sidebar.selection { + state.secretList = .init(collection: .project(id: item.id), projectName: item.name) + } return .none } } + + private func makeSecretListState( + selection: SidebarSelection, + projects: IdentifiedArrayOf + ) -> SecretListFeature.State { + switch selection { + case .filter(.all): + return .init(collection: .all) + case .filter(.starred): + return .init(collection: .liked) + case .filter(.notice): + // TODO: 도메인 레이어에 .notice collection 추가 후 연결 + return .init(collection: .all) + case .filter(.expired): + return .init(collection: .expired(referenceDate: Date())) + case .filter(.deleted): + return .init(collection: .deleted) + case .project(id: let id): + let projectName = projects[id: id]?.name + return .init(collection: .project(id: id), projectName: projectName) + } + } } diff --git a/Projects/DVPresentation/Sources/Features/Main/MainView.swift b/Projects/DVPresentation/Sources/Features/Main/MainView.swift index 361e401..96463f5 100644 --- a/Projects/DVPresentation/Sources/Features/Main/MainView.swift +++ b/Projects/DVPresentation/Sources/Features/Main/MainView.swift @@ -19,6 +19,11 @@ struct MainView: View { content .dvScreenBackground() .task { store.send(.task) } + .sheet( + item: $store.scope(state: \.createProject, action: \.createProject) + ) { createProjectStore in + CreateProjectView(store: createProjectStore) + } } } @@ -26,25 +31,38 @@ struct MainView: View { extension MainView { + @ViewBuilder private var content: some View { - NavigationSplitView(columnVisibility: $store.columnVisibility) { - sidebar - } content: { - contentColumn - } detail: { - detailColumn + if let selectStore = store.scope(state: \.selectSecretType, action: \.selectSecretType) { + // 사이드바 + 타입 선택 그리드 (2컬럼) + NavigationSplitView { + sidebarColumn + } detail: { + SelectSecretTypeView(store: selectStore) + } + .navigationSplitViewStyle(.balanced) + .toolbarBackground(.hidden, for: .windowToolbar) + } else { + // 사이드바 + 시크릿 목록 + 상세 (3컬럼) + NavigationSplitView(columnVisibility: $store.columnVisibility) { + sidebarColumn + } content: { + contentColumn + } detail: { + detailColumn + } + .navigationSplitViewStyle(.balanced) + .toolbarBackground(.hidden, for: .windowToolbar) } - .navigationSplitViewStyle(.balanced) - .toolbarBackground(.hidden, for: .windowToolbar) } - private var sidebar: some View { + private var sidebarColumn: some View { SidebarView(store: store.scope(state: \.sidebar, action: \.sidebar)) .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 270) } private var contentColumn: some View { - Text("Content") + SecretListView(store: store.scope(state: \.secretList, action: \.secretList)) .navigationTitle("") } diff --git a/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeFeature.swift b/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeFeature.swift index d2aef13..8df08dc 100644 --- a/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeFeature.swift +++ b/Projects/DVPresentation/Sources/Features/SelectSecretType/SelectSecretTypeFeature.swift @@ -5,18 +5,18 @@ import ComposableArchitecture // MARK: - SelectSecretTypeFeature @Reducer -struct SelectSecretTypeFeature { +public struct SelectSecretTypeFeature { // MARK: - State @ObservableState - struct State: Equatable { - init() {} + public struct State: Equatable { + public init() {} } // MARK: - Action - enum Action: Equatable { + public enum Action: Equatable { // MARK: - View @@ -26,18 +26,18 @@ struct SelectSecretTypeFeature { case delegate(Delegate) - enum Delegate: Equatable { + public enum Delegate: Equatable { case typeSelected(CreatableSecretType) } } // MARK: - Init - init() {} + public init() {} // MARK: - Body - var body: some ReducerOf { + public var body: some ReducerOf { Reduce { state, action in switch action { case .didSelectType(let type): diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/Error/SidebarError.swift b/Projects/DVPresentation/Sources/Features/Sidebar/Error/SidebarError.swift new file mode 100644 index 0000000..8856e95 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/Error/SidebarError.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum SidebarError: Error, Equatable { + case fetchFailed + case createFailed + case renameFailed + case deleteFailed + case nameTaken +} diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem+Mapping.swift b/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem+Mapping.swift new file mode 100644 index 0000000..c00c207 --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem+Mapping.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain + +public extension ProjectItem { + init(_ project: Project) { + self.init(id: project.id, name: project.name) + } +} diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem.swift b/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem.swift new file mode 100644 index 0000000..b48aeee --- /dev/null +++ b/Projects/DVPresentation/Sources/Features/Sidebar/Model/ProjectItem.swift @@ -0,0 +1,23 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct ProjectItem: Equatable, Identifiable, Sendable { + public let id: UUID + public let name: String + + public init(id: UUID, name: String) { + self.id = id + self.name = name + } +} + +// MARK: - Preview Fixtures + +public extension ProjectItem { + static let previews: [ProjectItem] = [ + .init(id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, name: "Backend"), + .init(id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, name: "Infrastructure"), + .init(id: UUID(uuidString: "00000000-0000-0000-0000-000000000003")!, name: "Mobile"), + ] +} diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift index 06a4f94..512e756 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarFeature.swift @@ -32,14 +32,13 @@ public enum SidebarFilter: Equatable, CaseIterable, Hashable { case .deleted: "trash" } } - } // MARK: - SidebarSelection public enum SidebarSelection: Equatable { case filter(SidebarFilter) - case project(Int) + case project(id: ProjectItem.ID) } // MARK: - SidebarFeature @@ -53,6 +52,18 @@ public struct SidebarFeature { public struct State: Equatable { public internal(set) var selection: SidebarSelection = .filter(.all) public internal(set) var isProjectSectionExpanded: Bool = true + public internal(set) var isCreatingSecret: Bool = false + var projectsState: LoadingState, SidebarError> = .idle + var renamingProjectID: ProjectItem.ID? + var renameText: String = "" + var deletingProjectID: ProjectItem.ID? + @Presents var alert: AlertState? + + // computed property — LoadingState에서 추출. 외부 참조(MainFeature, View)에서 LoadingState를 몰라도 됨 + var projects: IdentifiedArrayOf { + if case .loaded(let projects) = projectsState { return projects } + return [] + } public init() {} } @@ -66,22 +77,49 @@ public struct SidebarFeature { case task case didSelect(SidebarSelection) case didTapAddButton - case didTapSettingsButton case didTapAddProject case didToggleProjectSection + case didTapRename(id: ProjectItem.ID) + case didChangeRenameText(String) + case didConfirmRename + case didCancelRename + case didTapDelete(id: ProjectItem.ID) + case setCreatingSecret(Bool) + + // MARK: - Internal + + case projectsResponse(Result<[ProjectItem], SidebarError>) + case renameResponse(Result) + case deleteResponse(Result) + + // MARK: - Child + + case alert(PresentationAction) // MARK: - Delegate case delegate(Delegate) + public enum Alert: Equatable { + case confirmDelete + } + public enum Delegate: Equatable { case selectionChanged(SidebarSelection) case addButtonTapped - case settingsButtonTapped case addProjectTapped + case projectRenamed(ProjectItem) } } + // MARK: - CancelID (E5) + + private enum CancelID { case fetch } + + // MARK: - Dependencies + + @Dependency(\.sidebarClient) var sidebarClient + // MARK: - Init public init() {} @@ -92,6 +130,26 @@ public struct SidebarFeature { 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))) + } + } + .cancellable(id: CancelID.fetch, cancelInFlight: true) + + case .projectsResponse(.success(let projects)): + let sorted = projects.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } + state.projectsState = .loaded(IdentifiedArray(uniqueElements: sorted)) + return .none + + case .projectsResponse(.failure(let error)): + state.projectsState = .failed(error) return .none case .didSelect(let selection): @@ -101,9 +159,6 @@ public struct SidebarFeature { case .didTapAddButton: return .send(.delegate(.addButtonTapped)) - case .didTapSettingsButton: - return .send(.delegate(.settingsButtonTapped)) - case .didTapAddProject: return .send(.delegate(.addProjectTapped)) @@ -111,9 +166,142 @@ public struct SidebarFeature { state.isProjectSectionExpanded.toggle() return .none + case .didTapRename(id: let id): + guard let project = state.projects[id: id] else { return .none } + state.renamingProjectID = id + state.renameText = project.name + return .none + + case .didChangeRenameText(let text): + state.renameText = text + return .none + + case .setCreatingSecret(let value): + state.isCreatingSecret = value + return .none + + case .didConfirmRename: + guard let id = state.renamingProjectID else { return .none } + let name = state.renameText + state.renamingProjectID = nil + state.renameText = "" + return .run { [id, name] send in // 캡처 리스트 명시 + do { + let updated = try await sidebarClient.renameProject(id, name) + await send(.renameResponse(.success(updated))) + } catch let error as SidebarError { + await send(.renameResponse(.failure(error))) + } catch { + await send(.renameResponse(.failure(.renameFailed))) + } + } + + case .didCancelRename: + state.renamingProjectID = nil + state.renameText = "" + return .none + + case .renameResponse(.success(let updated)): + return .concatenate( + .send(.delegate(.projectRenamed(updated))), + .send(.task) + ) + + case .renameResponse(.failure(.nameTaken)): + state.alert = makeRenameNameTakenAlert() + return .none + + case .renameResponse(.failure): + state.alert = makeRenameFailedAlert() + return .none + + case .didTapDelete(id: let id): + guard let project = state.projects[id: id] else { return .none } + state.deletingProjectID = id + state.alert = makeDeleteAlert(for: project) // helper로 분리 + return .none + + case .alert(.presented(.confirmDelete)): + guard let id = state.deletingProjectID else { return .none } + state.deletingProjectID = nil + return .run { [id] send in // 캡처 리스트 명시 + do { + try await sidebarClient.deleteProject(id) + await send(.deleteResponse(.success(id))) + } catch let error as SidebarError { + await send(.deleteResponse(.failure(error))) + } catch { + await send(.deleteResponse(.failure(.deleteFailed))) + } + } + + case .alert: + return .none + + case .deleteResponse(.success(let id)): + if state.selection == .project(id: id) { + state.selection = .filter(.all) + } + return .send(.task) + + case .deleteResponse(.failure): + state.alert = makeDeleteFailedAlert() + return .none + case .delegate: return .none } } + .ifLet(\.$alert, action: \.alert) + } +} + +// MARK: - Private + +private extension SidebarFeature { + + func makeDeleteAlert(for project: ProjectItem) -> AlertState { + AlertState { + TextState("'\(project.name)' 프로젝트를 삭제할까요?") + } actions: { + ButtonState(role: .destructive, action: .confirmDelete) { + TextState("삭제") + } + ButtonState(role: .cancel) { + TextState("취소") + } + } message: { + TextState("프로젝트에 속한 Secret과의 연결이 해제됩니다. Secret 자체는 삭제되지 않습니다.") + } + } + + func makeRenameNameTakenAlert() -> AlertState { + AlertState { + TextState("이미 사용 중인 이름이에요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("다른 프로젝트 이름을 입력해주세요.") + } + } + + func makeRenameFailedAlert() -> AlertState { + AlertState { + TextState("이름을 변경하지 못했어요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("잠시 후 다시 시도해주세요.") + } + } + + func makeDeleteFailedAlert() -> AlertState { + AlertState { + TextState("프로젝트를 삭제하지 못했어요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("잠시 후 다시 시도해주세요.") + } } } diff --git a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift index 610eb1f..dc5760e 100644 --- a/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift +++ b/Projects/DVPresentation/Sources/Features/Sidebar/SidebarView.swift @@ -12,12 +12,14 @@ struct SidebarView: View { // MARK: - Properties @Bindable var store: StoreOf + @Environment(\.openWindow) private var openWindow // MARK: - Body var body: some View { content .task { store.send(.task) } + .alert($store.scope(state: \.alert, action: \.alert)) } } @@ -60,12 +62,12 @@ extension SidebarView { title: SidebarFilter.all.title, count: 0, systemImage: SidebarFilter.all.icon, - isSelected: store.selection == .filter(.all) + isSelected: !store.isCreatingSecret && store.selection == .filter(.all) ) { store.send(.didSelect(.filter(.all))) } .frame(height: 72) - + LazyVGrid( columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12 @@ -76,7 +78,7 @@ extension SidebarView { count: 0, systemImage: filter.icon, iconColor: filter.iconColor, - isSelected: store.selection == .filter(filter) + isSelected: !store.isCreatingSecret && store.selection == .filter(filter) ) { store.send(.didSelect(.filter(filter))) } @@ -91,13 +93,33 @@ extension SidebarView { projectSectionHeader .padding(.horizontal, 8) if store.isProjectSectionExpanded { - projectList + projectSectionBody } else { Spacer(minLength: 0) } } } + @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: + Text("불러오지 못했어요") + .dvFont(.bodyMD) + .foregroundStyle(Color.dv(.danger)) + .frame(maxWidth: .infinity) + .padding(.top, 8) + Spacer(minLength: 0) + } + } + private var projectSectionHeader: some View { HStack(spacing: 11) { Text("Project") @@ -115,42 +137,68 @@ extension SidebarView { } } - // TODO: - contextMenu 확정 후 추후에 Action 추가 private var projectList: some View { - let projects = ["Project A", "Project B", "Project C"] - // List(selection:)은 Int?를 요구하지만 store.selection은 SidebarSelection(enum)이라 타입 불일치로 $store 직접 바인딩 불가 → 의도적 manual Binding 사용 - return List( - selection: Binding( + // List(selection:)은 ProjectItem.ID?를 요구하지만 store.selection은 SidebarSelection(enum)이라 타입 불일치로 $store 직접 바인딩 불가 → 의도적 manual Binding 사용 + List( + selection: Binding( get: { - if case .project(let index) = store.selection { return index } + guard !store.isCreatingSecret else { return nil } + if case .project(id: let id) = store.selection { return id } return nil }, - set: { index in - if let index { store.send(.didSelect(.project(index))) } - // TODO: nil(deselect) 처리 — content 컬럼 연결 시 결정 + set: { id in + if let id { store.send(.didSelect(.project(id: id))) } } ) ) { - ForEach(projects.indices, id: \.self) { index in - DVProjectContainer(name: projects[index], count: 0) - .tag(index) + ForEach(store.projects) { project in + projectRow(project) + .tag(project.id) .contextMenu { - Button("Rename") {} - Button("New Secret") {} + Button("Rename") { store.send(.didTapRename(id: project.id)) } + Button("New Secret") { store.send(.didTapAddButton) } Divider() - Button("Delete Project", role: .destructive) {} + Button("Delete Project", role: .destructive) { + store.send(.didTapDelete(id: project.id)) + } } } } .listStyle(.sidebar) .scrollContentBackground(.hidden) .padding(.horizontal, -12) + .onKeyPress(.return) { + guard store.renamingProjectID == nil else { return .ignored } + if case .project(id: let id) = store.selection { + store.send(.didTapRename(id: id)) + return .handled + } + return .ignored + } + } + + @ViewBuilder + private func projectRow(_ project: ProjectItem) -> some View { + if store.renamingProjectID == project.id { + DVProjectRenameContainer( + text: Binding( + get: { store.renameText }, + set: { store.send(.didChangeRenameText($0)) } + ), + onSubmit: { store.send(.didConfirmRename) }, + onCancel: { store.send(.didCancelRename) } + ) + } else { + DVProjectContainer(name: project.name, count: 0) + } } private var bottomBar: some View { HStack { - circleIconButton(icon: "gearshape") { store.send(.didTapSettingsButton) } - .accessibilityLabel("Settings") + circleIconButton(icon: "gearshape") { + openWindow(id: "settings") + } + .accessibilityLabel("Settings") Spacer() circleIconButton(icon: "plus") { store.send(.didTapAddButton) } .accessibilityLabel("Add Secret") diff --git a/Projects/DVPresentation/Tests/CreateProject/CreateProjectFeatureTests.swift b/Projects/DVPresentation/Tests/CreateProject/CreateProjectFeatureTests.swift index 9fbe207..e97c207 100644 --- a/Projects/DVPresentation/Tests/CreateProject/CreateProjectFeatureTests.swift +++ b/Projects/DVPresentation/Tests/CreateProject/CreateProjectFeatureTests.swift @@ -1,7 +1,6 @@ // Copyright © 2026 Devault. All rights reserved import ComposableArchitecture -import DVDomain import Foundation import Testing @@ -11,80 +10,92 @@ import Testing @Suite("CreateProjectFeature") struct CreateProjectFeatureTests { - @Test("didTapCreate는 이름이 비어 있으면 아무 일도 안 한다") - func createIgnoresBlankName() async { - let store = TestStore(initialState: CreateProjectFeature.State()) { - CreateProjectFeature() - } + @Test("didTapCreate는 이름이 비어 있으면 아무 일도 안 한다") + func createIgnoresBlankName() async { + let store = TestStore(initialState: CreateProjectFeature.State()) { + CreateProjectFeature() + } + + await store.send(.didChangeName(" ")) { + $0.name = " " + } + await store.send(.didTapCreate) + } - await store.send(.didChangeName(" ")) { - $0.name = " " - } - await store.send(.didTapCreate) + @Test("didTapCreate는 성공하면 delegate로 알리고 dismiss한다") + func createSucceedsAndDismisses() async { + let item = ProjectItem(id: UUID(), name: "DeVault") + var didDismiss = false + let store = TestStore(initialState: CreateProjectFeature.State()) { + CreateProjectFeature() + } withDependencies: { + $0.sidebarClient.createProject = { _ in item } + $0.dismiss = DismissEffect { didDismiss = true } } - @Test("didTapCreate는 성공하면 delegate로 알리고 dismiss한다") - func createSucceedsAndDismisses() async { - let project = Project(id: UUID(), name: "DeVault", createdAt: .now, updatedAt: .now) - var didDismiss = false - let store = TestStore(initialState: CreateProjectFeature.State()) { - CreateProjectFeature() - } withDependencies: { - $0.secretClient.createProject = { _ in project } - $0.dismiss = DismissEffect { didDismiss = true } - } + await store.send(.didChangeName("DeVault")) { $0.name = "DeVault" } + await store.send(.didTapCreate) { $0.isCreating = true } + await store.receive(.createResponse(.success(item))) { $0.isCreating = false } + await store.receive(.delegate(.projectCreated(item))) - await store.send(.didChangeName("DeVault")) { - $0.name = "DeVault" - } - await store.send(.didTapCreate) { - $0.isCreating = true - } - await store.receive(.createResponse(.success(project))) { - $0.isCreating = false - } - await store.receive(.delegate(.projectCreated(project))) + #expect(didDismiss) + } - #expect(didDismiss) + @Test("didTapCreate는 이름 중복 실패 시 nameTaken alert를 띄운다") + func createShowsNameTakenAlert() async { + let store = TestStore(initialState: CreateProjectFeature.State()) { + CreateProjectFeature() + } withDependencies: { + $0.sidebarClient.createProject = { _ in throw SidebarError.nameTaken } } - @Test("didTapCreate는 생성에 실패하면 alert를 띄우고 dismiss하지 않는다") - func createShowsAlertOnFailure() async { - let store = TestStore(initialState: CreateProjectFeature.State()) { - CreateProjectFeature() - } withDependencies: { - $0.secretClient.createProject = { _ in throw ProjectUseCaseError.unexpected } - } - - await store.send(.didChangeName("DeVault")) { - $0.name = "DeVault" - } - await store.send(.didTapCreate) { - $0.isCreating = true - } - await store.receive(.createResponse(.failure(.unexpected))) { - $0.isCreating = false - $0.alert = AlertState { - TextState("프로젝트를 만들지 못했어요") - } actions: { - ButtonState(role: .cancel) { TextState("확인") } - } message: { - TextState("잠시 후 다시 시도해주세요.") - } - } + await store.send(.didChangeName("DeVault")) { $0.name = "DeVault" } + await store.send(.didTapCreate) { $0.isCreating = true } + await store.receive(.createResponse(.failure(.nameTaken))) { + $0.isCreating = false + $0.alert = AlertState { + TextState("이미 사용 중인 프로젝트 이름이에요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("다른 이름을 입력해주세요.") + } } + } - @Test("didTapCancel은 dismiss한다") - func cancelDismisses() async { - var didDismiss = false - let store = TestStore(initialState: CreateProjectFeature.State()) { - CreateProjectFeature() - } withDependencies: { - $0.dismiss = DismissEffect { didDismiss = true } - } + @Test("didTapCreate는 일반 실패 시 generic alert를 띄운다") + func createShowsGenericAlertOnFailure() async { + let store = TestStore(initialState: CreateProjectFeature.State()) { + CreateProjectFeature() + } withDependencies: { + $0.sidebarClient.createProject = { _ in throw SidebarError.createFailed } + } - await store.send(.didTapCancel) + await store.send(.didChangeName("DeVault")) { $0.name = "DeVault" } + await store.send(.didTapCreate) { $0.isCreating = true } + await store.receive(.createResponse(.failure(.createFailed))) { + $0.isCreating = false + $0.alert = AlertState { + TextState("프로젝트를 만들지 못했어요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("잠시 후 다시 시도해주세요.") + } + } + } - #expect(didDismiss) + @Test("didTapCancel은 dismiss한다") + func cancelDismisses() async { + var didDismiss = false + let store = TestStore(initialState: CreateProjectFeature.State()) { + CreateProjectFeature() + } withDependencies: { + $0.dismiss = DismissEffect { didDismiss = true } } + + await store.send(.didTapCancel) + + #expect(didDismiss) + } } diff --git a/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift new file mode 100644 index 0000000..5f5c411 --- /dev/null +++ b/Projects/DVPresentation/Tests/Main/MainFeatureTests.swift @@ -0,0 +1,182 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import Foundation +import Testing + +@testable import DVPresentation + +@MainActor +@Suite("MainFeature") +struct MainFeatureTests { + + // MARK: - Sidebar Delegate + + @Test("selectionChanged(.project)는 secretList를 해당 프로젝트로 갱신한다") + func selectionChangedUpdatesSecretList() async { + let item = ProjectItem(id: UUID(), name: "Backend") + + var initial = MainFeature.State() + initial.sidebar.projectsState = .loaded([item]) + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.sidebar(.didSelect(.project(id: item.id)))) { + $0.sidebar.selection = .project(id: item.id) + } + await store.receive(.sidebar(.delegate(.selectionChanged(.project(id: item.id))))) { + $0.secretList = SecretListFeature.State( + collection: .project(id: item.id), + projectName: "Backend" + ) + } + await store.receive(.sidebar(.setCreatingSecret(false))) + } + + @Test("selectionChanged는 selectSecretType을 닫고 isCreatingSecret을 false로 만든다") + func selectionChangedClosesSecretTypeSelection() async { + var initial = MainFeature.State() + initial.selectSecretType = .init() + initial.sidebar.isCreatingSecret = true + + let store = TestStore(initialState: initial) { + MainFeature() + } + + await store.send(.sidebar(.didSelect(.filter(.all)))) { + $0.sidebar.selection = .filter(.all) + } + await store.receive(.sidebar(.delegate(.selectionChanged(.filter(.all))))) { + $0.selectSecretType = nil + $0.secretList = SecretListFeature.State(collection: .all) + } + await store.receive(.sidebar(.setCreatingSecret(false))) { + $0.sidebar.isCreatingSecret = false + } + } + + @Test("addButtonTapped은 selectSecretType을 열고 isCreatingSecret을 true로 만든다") + func addButtonTappedOpensSecretTypeSelection() async { + let store = TestStore(initialState: MainFeature.State()) { + MainFeature() + } + + await store.send(.sidebar(.didTapAddButton)) + await store.receive(.sidebar(.delegate(.addButtonTapped))) { + $0.selectSecretType = .init() + } + await store.receive(.sidebar(.setCreatingSecret(true))) { + $0.sidebar.isCreatingSecret = true + } + } + + @Test("addProjectTapped은 createProject sheet를 연다") + func addProjectTappedOpensSheet() async { + let store = TestStore(initialState: MainFeature.State()) { + MainFeature() + } + + await store.send(.sidebar(.didTapAddProject)) + await store.receive(.sidebar(.delegate(.addProjectTapped))) { + $0.createProject = CreateProjectFeature.State() + } + } + + // MARK: - CreateProject Delegate + + @Test("projectCreated는 isCreatingSecret이 false일 때 selection을 새 프로젝트로 설정하고 refetch한다") + func projectCreatedSelectsNewProject() async { + let item = ProjectItem(id: UUID(), name: "Backend") + + var initial = MainFeature.State() + initial.createProject = .init() + initial.sidebar.isCreatingSecret = false + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { [item] } + } + + await store.send(.createProject(.presented(.delegate(.projectCreated(item))))) { + $0.createProject = nil + $0.sidebar.selection = .project(id: item.id) + $0.secretList = SecretListFeature.State( + collection: .project(id: item.id), + projectName: "Backend" + ) + } + await store.receive(.sidebar(.task)) { + $0.sidebar.projectsState = .loading + } + await store.receive(.sidebar(.projectsResponse(.success([item])))) { + $0.sidebar.projectsState = .loaded([item]) + } + } + + @Test("projectCreated는 isCreatingSecret이 true일 때 selection을 변경하지 않는다") + func projectCreatedDoesNotSelectWhenCreatingSecret() async { + let item = ProjectItem(id: UUID(), name: "Backend") + + var initial = MainFeature.State() + initial.createProject = .init() + initial.sidebar.isCreatingSecret = true + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { [item] } + } + + await store.send(.createProject(.presented(.delegate(.projectCreated(item))))) { + $0.createProject = nil + // selection 및 secretList 변경 없음 + } + await store.receive(.sidebar(.task)) { + $0.sidebar.projectsState = .loading + } + await store.receive(.sidebar(.projectsResponse(.success([item])))) { + $0.sidebar.projectsState = .loaded([item]) + } + } + + // MARK: - Rename Delegate + + @Test("projectRenamed는 현재 선택된 프로젝트의 이름이면 secretList 타이틀을 갱신한다") + func projectRenamedUpdatesSecretListTitle() 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]) + initial.sidebar.selection = .project(id: item.id) + initial.secretList = SecretListFeature.State( + collection: .project(id: item.id), + projectName: "Backend" + ) + + let store = TestStore(initialState: initial) { + MainFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { [renamed] } + } + + await store.send(.sidebar(.renameResponse(.success(renamed)))) + // delegate payload의 이름을 직접 사용해 즉시 갱신 + await store.receive(.sidebar(.delegate(.projectRenamed(renamed)))) { + $0.secretList = SecretListFeature.State( + collection: .project(id: item.id), + projectName: "Backend V2" + ) + } + // 이후 refetch로 목록도 동기화 + await store.receive(.sidebar(.task)) { + $0.sidebar.projectsState = .loading + } + 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 new file mode 100644 index 0000000..702ab60 --- /dev/null +++ b/Projects/DVPresentation/Tests/Sidebar/SidebarFeatureTests.swift @@ -0,0 +1,219 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import Foundation +import Testing + +@testable import DVPresentation + +@MainActor +@Suite("SidebarFeature") +struct SidebarFeatureTests { + + // MARK: - Fetch + + @Test("task는 프로젝트 목록을 fetch해 이름 순으로 정렬한다") + func taskFetchesSortedProjects() async { + let projects = [ + ProjectItem(id: UUID(), name: "Mobile"), + ProjectItem(id: UUID(), name: "Backend"), + ] + let store = TestStore(initialState: SidebarFeature.State()) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { projects } + } + + await store.send(.task) { + $0.projectsState = .loading + } + await store.receive(.projectsResponse(.success(projects))) { + $0.projectsState = .loaded(IdentifiedArray(uniqueElements: [ + ProjectItem(id: projects[1].id, name: "Backend"), + ProjectItem(id: projects[0].id, name: "Mobile"), + ])) + } + } + + @Test("task fetch 실패 시 projectsState가 .failed로 전환된다") + func taskFetchFailureSetsFailedState() async { + let store = TestStore(initialState: SidebarFeature.State()) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.fetchProjects = { throw SidebarError.fetchFailed } + } + + await store.send(.task) { + $0.projectsState = .loading + } + await store.receive(.projectsResponse(.failure(.fetchFailed))) { + $0.projectsState = .failed(.fetchFailed) + } + } + + // MARK: - Selection + + @Test("didSelect는 selection을 바꾸고 delegate를 발송한다") + func didSelectChangesSelectionAndSendsDelegate() async { + let id = UUID() + let store = TestStore( + initialState: SidebarFeature.State() + ) { + SidebarFeature() + } + + await store.send(.didSelect(.project(id: id))) { + $0.selection = .project(id: id) + } + await store.receive(.delegate(.selectionChanged(.project(id: id)))) + } + + // MARK: - Rename + + @Test("didTapRename은 renameText를 프로젝트 이름으로 세팅한다") + func didTapRenameSetRenameText() 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(.didTapRename(id: item.id)) { + $0.renamingProjectID = item.id + $0.renameText = "Backend" + } + } + + @Test("didConfirmRename 성공 시 delegate를 발송하고 refetch한다") + func didConfirmRenameSuccessDelegate() async { + let item = ProjectItem(id: UUID(), name: "Backend") + let renamed = ProjectItem(id: item.id, name: "Backend V2") + + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + state.renamingProjectID = item.id + state.renameText = "Backend V2" + + let store = TestStore(initialState: state) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.renameProject = { _, _ in renamed } + $0.sidebarClient.fetchProjects = { [renamed] } + } + + await store.send(.didConfirmRename) { + $0.renamingProjectID = nil + $0.renameText = "" + } + await store.receive(.renameResponse(.success(renamed))) + await store.receive(.delegate(.projectRenamed(renamed))) + await store.receive(.task) { + $0.projectsState = .loading + } + await store.receive(.projectsResponse(.success([renamed]))) { + $0.projectsState = .loaded([renamed]) + } + } + + @Test("didConfirmRename에서 nameTaken 오류 시 alert가 표시된다") + func didConfirmRenameNameTakenShowsAlert() async { + let item = ProjectItem(id: UUID(), name: "Backend") + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + state.renamingProjectID = item.id + state.renameText = "Mobile" + + let store = TestStore(initialState: state) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.renameProject = { _, _ in throw SidebarError.nameTaken } + } + + await store.send(.didConfirmRename) { + $0.renamingProjectID = nil + $0.renameText = "" + } + await store.receive(.renameResponse(.failure(.nameTaken))) { + $0.alert = AlertState { + TextState("이미 사용 중인 이름이에요") + } actions: { + ButtonState(role: .cancel) { TextState("확인") } + } message: { + TextState("다른 프로젝트 이름을 입력해주세요.") + } + } + } + + @Test("didCancelRename은 rename 상태를 초기화한다") + func didCancelRenameResetsState() async { + var state = SidebarFeature.State() + state.renamingProjectID = UUID() + state.renameText = "Some Name" + + let store = TestStore(initialState: state) { + SidebarFeature() + } + + await store.send(.didCancelRename) { + $0.renamingProjectID = nil + $0.renameText = "" + } + } + + // MARK: - Delete + + @Test("didTapDelete는 alert를 표시한다") + func didTapDeleteShowsAlert() 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 자체는 삭제되지 않습니다.") + } + } + } + + @Test("삭제 확인 후 선택 중이던 프로젝트면 selection이 .all로 초기화되고 refetch한다") + func deleteResetsSelectionAndRefetches() async { + let item = ProjectItem(id: UUID(), name: "Backend") + + var state = SidebarFeature.State() + state.projectsState = .loaded([item]) + state.selection = .project(id: item.id) + state.deletingProjectID = item.id + + let store = TestStore(initialState: state) { + SidebarFeature() + } withDependencies: { + $0.sidebarClient.deleteProject = { _ in } + $0.sidebarClient.fetchProjects = { [] } + } + + await store.send(.alert(.presented(.confirmDelete))) { + $0.deletingProjectID = nil + } + await store.receive(.deleteResponse(.success(item.id))) { + $0.selection = .filter(.all) + } + await store.receive(.task) { + $0.projectsState = .loading + } + await store.receive(.projectsResponse(.success([]))) { + $0.projectsState = .loaded([]) + } + } +} diff --git a/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift new file mode 100644 index 0000000..bb31a94 --- /dev/null +++ b/Projects/Devault/Sources/Composition/Dependencies/SidebarClient+Live.swift @@ -0,0 +1,55 @@ +// Copyright © 2026 Devault. All rights reserved + +import ComposableArchitecture +import DVData +import DVDomain +import DVPresentation + +extension SidebarClient: @retroactive DependencyKey { + public static let liveValue: SidebarClient = { + let container = LiveStorage.shared.modelContainer + let repo: any ProjectRepository = ProjectRepositoryImpl(modelContainer: container) + + let fetchUseCase: any FetchProjectUseCase = FetchProjectUseCaseImpl(repository: repo) + let createUseCase: any CreateProjectUseCase = CreateProjectUseCaseImpl(repository: repo) + let renameUseCase: any RenameProjectUseCase = RenameProjectUseCaseImpl(repository: repo) + let deleteUseCase: any DeleteProjectUseCase = DeleteProjectUseCaseImpl(repository: repo) + + return SidebarClient( + fetchProjects: { + do { + return try await fetchUseCase.fetchAll().map(ProjectItem.init) + } catch { + throw SidebarError.fetchFailed + } + }, + createProject: { name in + do { + return try await ProjectItem(createUseCase.execute(name: name)) + } catch let error as ProjectUseCaseError { + if case .repositoryFailure(.duplicateName) = error { throw SidebarError.nameTaken } + throw SidebarError.createFailed + } catch { + throw SidebarError.createFailed + } + }, + renameProject: { id, name in + do { + return try await ProjectItem(renameUseCase.rename(id: id, name: name)) + } catch let error as ProjectUseCaseError { + if case .repositoryFailure(.duplicateName) = error { throw SidebarError.nameTaken } + throw SidebarError.renameFailed + } catch { + throw SidebarError.renameFailed + } + }, + deleteProject: { id in + do { + try await deleteUseCase.delete(id: id) + } catch { + throw SidebarError.deleteFailed + } + } + ) + }() +} diff --git a/Projects/Devault/Sources/Composition/LiveStorage.swift b/Projects/Devault/Sources/Composition/LiveStorage.swift new file mode 100644 index 0000000..87f09cd --- /dev/null +++ b/Projects/Devault/Sources/Composition/LiveStorage.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +import OSLog +import DVData + +/// Composition Root에서 공유하는 ModelContainer 단일 인스턴스. +/// SecretRepositoryImpl, ProjectRepositoryImpl이 동일 컨테이너를 참조해야 한다. +enum LiveStorage { + private static let logger = Logger(subsystem: "com.devault", category: "LiveStorage") + + static let shared: LocalStorage = { + do { + return try LocalStorage.makeDefault() + } catch { + logger.critical("LocalStorage 초기화 실패: \(error, privacy: .public)") + fatalError("LocalStorage 초기화 실패 — 앱을 재시작하거나 데이터를 복원하세요.\n\(error)") + } + }() +} diff --git a/Projects/Devault/Sources/DevaultApp.swift b/Projects/Devault/Sources/DevaultApp.swift index 8106549..76e676a 100644 --- a/Projects/Devault/Sources/DevaultApp.swift +++ b/Projects/Devault/Sources/DevaultApp.swift @@ -16,5 +16,11 @@ struct DevaultApp: App { } .windowStyle(.hiddenTitleBar) .defaultSize(width: 800, height: 600) + + WindowGroup("Settings", id: "settings") { + Text("Settings") + .frame(width: 500, height: 400) + } + .defaultSize(width: 500, height: 400) } }