diff --git a/.agents/roles.md b/.agents/roles.md index 211094a3..d1a64935 100644 --- a/.agents/roles.md +++ b/.agents/roles.md @@ -27,7 +27,7 @@ Use these model tiers when assigning work to another LLM. | Tier | Use | Default model | | --- | --- | --- | | `Primary` | Planning, implementation, architecture decisions, final integration, failed-check triage | Strongest available Codex/GPT coding model | -| `Lightweight` | Read-only review, checklist validation, log summarization, documentation draft, first-pass architecture preflight | Pinned non-Primary model from the configured custom agent TOML | +| `Lightweight` | Read-only review, checklist validation, log summarization, documentation draft, first-pass architecture preflight | `gpt-5.3-codex-spark`, unavailable 시 `gpt-5.6-luna`와 `high` 추론 | | `Fast` | Low-risk text cleanup, simple file presence checks, short summaries | Pinned fast model from the configured custom agent TOML when a Fast role is defined | Default role-to-model and execution assignment: @@ -55,7 +55,8 @@ Do not assign `Lightweight` as the only model for production Swift implementatio - A sub-agent that inherits the active `Primary` model does not satisfy a `Lightweight` or `Fast` assignment. - Do not satisfy a `Lightweight` or `Fast` role by completing the role directly in `Primary` and describing it as delegated work. - A generic sub-agent spawn that does not load the configured custom agent TOML does not satisfy the role assignment. -- If the custom agent cannot be loaded, its pinned model is unavailable, or the dispatch surface cannot select that custom agent, stop before dispatch and report which role cannot run. +- If the custom agent cannot be loaded or the dispatch surface cannot select that custom agent, stop before dispatch and report which role cannot run. +- A configured `gpt-5.3-codex-spark` model is unavailable only when the connected side-task surface cannot select it after an exact `task_name` retry. In that case, dispatch the matching `*_luna` custom role with `gpt-5.6-luna` and `high` reasoning effort. Do not select another fallback model. - If the assigned model is available but current tool policy requires explicit user permission before dispatch, missing permission is not fallback. Stop and ask for permission before continuing the required role. - `Primary` must integrate and verify delegated output, but must not skip the delegated role when the workflow requires it and the assigned model is available. @@ -81,10 +82,13 @@ Use these exact role identifiers: | GitHub/CI Analyst | `github_ci_analyst` | `.codex/agents/github_ci_analyst.toml` | | Documentation Writer | `documentation_writer` | `.codex/agents/documentation_writer.toml` | +Spark fallback custom agents use the same role suffix with `_luna`: `architecture_watcher_luna`, `code_reviewer_luna`, `verification_runner_luna`, `github_ci_analyst_luna`, and `documentation_writer_luna`. + ### Fallback policy - The configured custom agent TOML is the source of truth for the non-Primary role model and sandbox. -- If a required custom agent or its pinned non-Primary model is unavailable, do not fall back to another model; stop and report the unavailable role. +- The configured custom agent TOML keeps `gpt-5.3-codex-spark` as the default non-Primary model. If Spark is unavailable, use only the matching `*_luna` custom agent with `gpt-5.6-luna` and `high` reasoning effort, preserving the same sandbox and developer instructions. +- If `gpt-5.6-luna` with `high` reasoning effort is also unavailable, do not fall back to another model; stop and report the unavailable role. - If `Primary` is unavailable, do not perform implementation, architecture verdict, final integration, git write actions, or GitHub write actions. - Do not downgrade `Primary` roles to `Lightweight` or `Fast` only because a cheaper model is available. - For user-facing summaries, a lower tier may draft text, but `Primary` must check it when the text depends on architecture decisions, release risk, CI root cause, or exact diff behavior. diff --git a/.agents/workflows.md b/.agents/workflows.md index 786f8268..6263f1a5 100644 --- a/.agents/workflows.md +++ b/.agents/workflows.md @@ -40,7 +40,7 @@ Stop and ask the user before editing when: - The task packet conflicts with `AGENTS.md`. - The requested fix requires relaxing a layer boundary. - A role needs to run, launch, install, boot, or open the app or Simulator. -- A required `Lightweight` or `Fast` custom agent cannot be loaded or selected through the connected side-task surface with its exact `task_name`, its pinned model is unavailable, or current tool policy requires user permission that has not been granted. +- A required `Lightweight` or `Fast` custom agent cannot be loaded or selected through the connected side-task surface with its exact `task_name`, the matching `*_luna` fallback cannot be selected after Spark is unavailable, or current tool policy requires user permission that has not been granted. - The current issue or PR scope is unclear after live GitHub inspection. - Two editing roles would touch the same file. - A read-only role reports `Block` or `Needs Owner Decision`. diff --git a/.codex/agents/architecture_watcher_luna.toml b/.codex/agents/architecture_watcher_luna.toml new file mode 100644 index 00000000..1c363d97 --- /dev/null +++ b/.codex/agents/architecture_watcher_luna.toml @@ -0,0 +1,12 @@ +name = "architecture_watcher_luna" +description = "Fallback DevLog architecture boundary reviewer when gpt-5.3-codex-spark is unavailable." +model = "gpt-5.6-luna" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Read AGENTS.md and .agents/roles.md before reviewing. +Act only as the Architecture Watcher defined in .agents/roles.md. +Inspect the requested architecture scope and relevant manifests without editing files or changing GitHub state. +Do not run, launch, install, boot, or open the app or Simulator. +Return exactly the Architecture Watch Result format from .agents/roles.md. +""" diff --git a/.codex/agents/code_reviewer_luna.toml b/.codex/agents/code_reviewer_luna.toml new file mode 100644 index 00000000..9f64418f --- /dev/null +++ b/.codex/agents/code_reviewer_luna.toml @@ -0,0 +1,12 @@ +name = "code_reviewer_luna" +description = "Fallback DevLog code reviewer when gpt-5.3-codex-spark is unavailable." +model = "gpt-5.6-luna" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Read AGENTS.md and .agents/roles.md before reviewing. +Act only as the Code Reviewer defined in .agents/roles.md. +Review the assigned diff without editing files, staging changes, committing, pushing, or changing GitHub state. +Prioritize correctness, regressions, architecture drift, scope drift, and missing tests over style preferences. +Return exactly the Code Review Result format from .agents/roles.md with file and line references when possible. +""" diff --git a/.codex/agents/documentation_writer_luna.toml b/.codex/agents/documentation_writer_luna.toml new file mode 100644 index 00000000..6e6a77dd --- /dev/null +++ b/.codex/agents/documentation_writer_luna.toml @@ -0,0 +1,12 @@ +name = "documentation_writer_luna" +description = "Fallback DevLog documentation writer when gpt-5.3-codex-spark is unavailable." +model = "gpt-5.6-luna" +model_reasoning_effort = "high" +sandbox_mode = "workspace-write" +developer_instructions = """ +Read AGENTS.md and .agents/roles.md before drafting. +Act only as the Documentation Writer defined in .agents/roles.md. +Match repository templates, actual diffs, live issue or PR state, and the requested Korean wording rules. +Edit only documentation files named in the task packet. Do not edit app code or create GitHub content unless the task packet explicitly authorizes that write action. +Return exactly the Documentation Result format from .agents/roles.md unless the task packet requests direct Markdown draft output. +""" diff --git a/.codex/agents/github_ci_analyst_luna.toml b/.codex/agents/github_ci_analyst_luna.toml new file mode 100644 index 00000000..35ee22f1 --- /dev/null +++ b/.codex/agents/github_ci_analyst_luna.toml @@ -0,0 +1,12 @@ +name = "github_ci_analyst_luna" +description = "Fallback DevLog GitHub and CI analyst when gpt-5.3-codex-spark is unavailable." +model = "gpt-5.6-luna" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Read AGENTS.md and .agents/roles.md before analysis. +Act only as the GitHub/CI Analyst defined in .agents/roles.md. +Use live GitHub state as the source of truth and use thread-aware review inspection when resolution state matters. +Do not edit files, reply, resolve threads, submit reviews, push, or change GitHub state unless the task packet explicitly authorizes that write action. +Return exactly the GitHub CI Result format from .agents/roles.md. +""" diff --git a/.codex/agents/verification_runner_luna.toml b/.codex/agents/verification_runner_luna.toml new file mode 100644 index 00000000..7dba28fd --- /dev/null +++ b/.codex/agents/verification_runner_luna.toml @@ -0,0 +1,13 @@ +name = "verification_runner_luna" +description = "Fallback DevLog verification runner when gpt-5.3-codex-spark is unavailable." +model = "gpt-5.6-luna" +model_reasoning_effort = "high" +sandbox_mode = "workspace-write" +developer_instructions = """ +Read AGENTS.md and .agents/roles.md before verification. +Act only as the Verification Runner defined in .agents/roles.md. +Do not edit source or documentation files, except through an explicitly assigned formatting command. +Do not run, launch, install, boot, or open the app or Simulator. +Run only the checks assigned in the task packet and record exact commands, exit status, and evidence. +Return exactly the Verification Result format from .agents/roles.md. +""" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 763397b6..bf694333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,7 @@ jobs: - name: Persistence schemes: "Persistence" - name: Libraries - schemes: "MarkdownRenderer" + schemes: "MarkdownRenderer CollectionUITests" - name: Presentation schemes: "Entry PresentationShared HomeTab" - name: Widget @@ -429,6 +429,8 @@ jobs: LOG_PATH="$RESULT_DIR/${TEST_SCHEME}.log" if [ "$TEST_SCHEME" = "WidgetCore" ]; then TEST_SOURCE_DIR="Widget/WidgetCore/Tests" + elif [ "$TEST_SCHEME" = "CollectionUITests" ]; then + TEST_SOURCE_DIR="Libraries/CollectionUI/Tests" elif [ -d "Libraries/${TEST_SCHEME}/Tests" ]; then TEST_SOURCE_DIR="Libraries/${TEST_SCHEME}/Tests" elif [ -d "Application/Presentation/${TEST_SCHEME}/Tests" ]; then diff --git a/Application/App/Sources/App/TempView.swift b/Application/App/Sources/App/TempView.swift deleted file mode 100644 index e2e3005d..00000000 --- a/Application/App/Sources/App/TempView.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// TempView.swift -// DevLog -// -// Created by 최윤진 on 1/31/26. -// - -import SwiftUI - -struct TempView: View { - let text: String - - var body: some View { - Text(text) - } -} diff --git a/Libraries/CollectionUI/Project.swift b/Libraries/CollectionUI/Project.swift new file mode 100644 index 00000000..b5e74463 --- /dev/null +++ b/Libraries/CollectionUI/Project.swift @@ -0,0 +1,69 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let deploymentSettings: SettingsDictionary = [ + "IPHONEOS_DEPLOYMENT_TARGET": "17.0", + "MARKETING_VERSION": "1.0.0", +] + +let project = Project( + name: "CollectionUI", + packages: [], + settings: .devlogProject(additionalBase: deploymentSettings), + targets: [ + .target( + name: "CollectionUI", + destinations: .iOS, + product: .framework, + bundleId: "com.opfic.DevLog.CollectionUI", + infoPlist: .extendingDefault( + with: [ + "CFBundlePackageType": "FMWK", + ] + ), + sources: [ + "Sources/**/*.swift", + ], + scripts: [ + DevLogScripts.swiftLint( + sourcePath: "Sources", + configPath: "Sources/.swiftlint.yml" + ), + ], + dependencies: [], + settings: .devlog( + base: deploymentSettings + ) + ), + .target( + name: "CollectionUITests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.opfic.DevLog.CollectionUITests", + infoPlist: .extendingDefault( + with: [ + "CFBundlePackageType": "BNDL", + ] + ), + sources: [ + "Tests/**/*.swift", + ], + scripts: [ + DevLogScripts.swiftLint( + sourcePath: "Tests", + configPath: "Tests/.swiftlint.yml" + ), + ], + dependencies: [ + .target(name: "CollectionUI"), + ], + settings: .devlog( + base: deploymentSettings.merging( + [ + "TEST_TARGET_NAME": "CollectionUI", + ] + ) { _, new in new } + ) + ), + ] +) diff --git a/Libraries/CollectionUI/README.md b/Libraries/CollectionUI/README.md new file mode 100644 index 00000000..4d919e76 --- /dev/null +++ b/Libraries/CollectionUI/README.md @@ -0,0 +1,30 @@ +# CollectionUI + +`CollectionUI`는 SwiftUI 화면에서 UIKit `UICollectionView`를 연결하는 내부 공용 Library임. + +```swift +CollectionView( + snapshot: .init(sections: [ + .init(identifier: .default, itemIdentifiers: todos.map(\.id)) + ]), + layoutProvider: { _, _ in makeTodoLayout() }, + cellProvider: { collectionView, indexPath, identifier in + collectionView.dequeueConfiguredReusableCell( + using: todoCellRegistration, + for: indexPath, + item: identifier + ) + } +) +.onSelect { identifier in + store.send(.view(.select(identifier))) +} +.swipeActions(edge: .trailing) { identifier in + [deleteAction(identifier)] +} +.refreshable { + await store.send(.view(.refresh)).finish() +} +``` + +Cell은 `UICollectionViewCell`과 UIKit `UIView`로만 구성하며, `UIHostingController`, `UIHostingConfiguration`, `AnyView`는 사용하지 않음. diff --git a/Libraries/CollectionUI/Sources/.swiftlint.yml b/Libraries/CollectionUI/Sources/.swiftlint.yml new file mode 100644 index 00000000..1242ffca --- /dev/null +++ b/Libraries/CollectionUI/Sources/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint.yml diff --git a/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift b/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift new file mode 100644 index 00000000..2312c914 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionRenderingSnapshot.swift @@ -0,0 +1,47 @@ +// +// CollectionRenderingSnapshot.swift +// CollectionUI +// +// Created by opfic on 8/20/26. +// + +import Foundation + +// Collection 화면에 적용할 section과 item 식별자 상태 +public struct CollectionRenderingSnapshot: Equatable, Sendable +where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { + // 하나의 section과 그 안의 item 식별자 순서 + public struct Section: Equatable, Sendable { + // section을 구분하는 고유 식별자 + public let identifier: SectionIdentifier + // section 안에 표시할 item의 순서가 있는 식별자 목록 + public let itemIdentifiers: [ItemIdentifier] + + // section 식별자와 표시 순서를 지정한 section 값 생성 + public init( + identifier: SectionIdentifier, + itemIdentifiers: [ItemIdentifier] + ) { + self.identifier = identifier + self.itemIdentifiers = itemIdentifiers + } + } + + // 표시할 section과 item 식별자의 전체 순서 + public let sections: [Section] + // 같은 식별자를 유지한 채 다시 구성할 item 식별자 집합 + public let reconfiguredItemIdentifiers: Set + // 같은 식별자를 유지한 채 다시 구성할 section 식별자 집합 + public let reconfiguredSectionIdentifiers: Set + + // section 순서와 다시 구성할 item 식별자를 지정한 rendering snapshot 생성 + public init( + sections: [Section], + reconfiguredItemIdentifiers: Set = [], + reconfiguredSectionIdentifiers: Set = [] + ) { + self.sections = sections + self.reconfiguredItemIdentifiers = reconfiguredItemIdentifiers + self.reconfiguredSectionIdentifiers = reconfiguredSectionIdentifiers + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift b/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift new file mode 100644 index 00000000..563b0ee7 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionSwipeActions.swift @@ -0,0 +1,15 @@ +import UIKit + +// UIKit swipe action을 적용할 방향 +public enum CollectionSwipeActionsEdge: Equatable, Sendable { + // cell의 좌측에서 우측 방향 swipe action + case leading + // cell의 우측에서 좌측 방향 swipe action + case trailing +} + +struct CollectionSwipeActions where ItemIdentifier: Hashable & Sendable { + let edge: CollectionSwipeActionsEdge + let allowsFullSwipe: Bool + let actions: (ItemIdentifier) -> [UIContextualAction] +} diff --git a/Libraries/CollectionUI/Sources/CollectionView+Events.swift b/Libraries/CollectionUI/Sources/CollectionView+Events.swift new file mode 100644 index 00000000..27b4fb6f --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionView+Events.swift @@ -0,0 +1,39 @@ +import SwiftUI +import UIKit + +public extension CollectionView { + // item 선택 시 전달할 동작 지정 + func onSelect(_ action: @escaping (ItemIdentifier) -> Void) -> Self { + var view = self + view.configuration.onSelect = action + return view + } + + // item이 표시되기 시작할 때 전달할 동작 지정 + func onWillDisplay(_ action: @escaping (ItemIdentifier) -> Void) -> Self { + var view = self + view.configuration.onWillDisplay = action + return view + } + + // item 선준비를 시작할 때 전달할 동작 지정 + func onPrefetch(_ action: @escaping ([ItemIdentifier]) -> Void) -> Self { + var view = self + view.configuration.onPrefetch = action + return view + } + + // item 선준비를 취소할 때 전달할 동작 지정 + func onCancelPrefetch(_ action: @escaping ([ItemIdentifier]) -> Void) -> Self { + var view = self + view.configuration.onCancelPrefetch = action + return view + } + + // scroll 위치가 바뀔 때 전달할 동작 지정 + func onScroll(_ action: @escaping (CGPoint) -> Void) -> Self { + var view = self + view.configuration.onScroll = action + return view + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift b/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift new file mode 100644 index 00000000..ea8387e1 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionView+Refresh.swift @@ -0,0 +1,10 @@ +import SwiftUI + +public extension CollectionView { + // UIKit 새로고침 완료를 기다릴 비동기 동작 지정 + func refreshable(_ action: @escaping () async -> Void) -> Self { + var view = self + view.configuration.refreshAction = action + return view + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift b/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift new file mode 100644 index 00000000..f50470d2 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionView+SupplementaryViews.swift @@ -0,0 +1,13 @@ +import SwiftUI +import UIKit + +public extension CollectionView { + // section 식별자로 supplementary view를 만들 동작 지정 + func supplementaryViews( + _ provider: @escaping (UICollectionView, String, IndexPath, SectionIdentifier) -> UICollectionReusableView? + ) -> Self { + var view = self + view.configuration.supplementaryViewProvider = provider + return view + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift b/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift new file mode 100644 index 00000000..d05839a3 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionView+SwipeActions.swift @@ -0,0 +1,19 @@ +import SwiftUI +import UIKit + +public extension CollectionView { + // item 식별자로 UIKit swipe action을 만들 동작 추가 + func swipeActions( + edge: CollectionSwipeActionsEdge = .trailing, + allowsFullSwipe: Bool = true, + actions: @escaping (ItemIdentifier) -> [UIContextualAction] + ) -> Self { + var view = self + view.configuration.swipeActions.append(.init( + edge: edge, + allowsFullSwipe: allowsFullSwipe, + actions: actions + )) + return view + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionView.swift b/Libraries/CollectionUI/Sources/CollectionView.swift new file mode 100644 index 00000000..3aa71a7c --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionView.swift @@ -0,0 +1,43 @@ +// +// CollectionView.swift +// CollectionUI +// +// Created by opfic on 8/20/26. +// + +import SwiftUI + +// SwiftUI 화면과 UIKit collection controller 연결 +@MainActor +public struct CollectionView: UIViewControllerRepresentable +where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { + var configuration: CollectionViewConfiguration + + // 표시할 식별자 snapshot과 UIKit layout·cell provider 지정 + public init( + snapshot: CollectionRenderingSnapshot, + layoutProvider: @escaping (SectionIdentifier, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection, + cellProvider: @escaping (UICollectionView, IndexPath, ItemIdentifier) -> UICollectionViewCell? + ) { + configuration = CollectionViewConfiguration( + snapshot: snapshot, + layoutProvider: layoutProvider, + cellProvider: cellProvider + ) + } + + // UIKit collection controller 생성 + public func makeUIViewController(context: Context) -> UIViewController { + CollectionViewController(configuration: configuration) + } + + // 최신 rendering 입력과 provider의 UIKit controller 반영 + public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + (uiViewController as? CollectionViewController)?.update(configuration: configuration) + } + + // UIKit controller의 delegate와 진행 중인 작업 정리 + public static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: ()) { + (uiViewController as? CollectionViewController)?.dismantle() + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift b/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift new file mode 100644 index 00000000..60bc9bf2 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionViewConfiguration.swift @@ -0,0 +1,24 @@ +// +// CollectionViewConfiguration.swift +// CollectionUI +// +// Created by opfic on 8/20/26. +// + +import UIKit + +struct CollectionViewConfiguration +where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { + let snapshot: CollectionRenderingSnapshot + let layoutProvider: (SectionIdentifier, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection + let cellProvider: (UICollectionView, IndexPath, ItemIdentifier) -> UICollectionViewCell? + var onSelect: ((ItemIdentifier) -> Void)? + var onWillDisplay: ((ItemIdentifier) -> Void)? + var onPrefetch: (([ItemIdentifier]) -> Void)? + var onCancelPrefetch: (([ItemIdentifier]) -> Void)? + var onScroll: ((CGPoint) -> Void)? + var swipeActions: [CollectionSwipeActions] = [] + var refreshAction: (() async -> Void)? + var supplementaryViewProvider: + ((UICollectionView, String, IndexPath, SectionIdentifier) -> UICollectionReusableView?)? +} diff --git a/Libraries/CollectionUI/Sources/CollectionViewController.swift b/Libraries/CollectionUI/Sources/CollectionViewController.swift new file mode 100644 index 00000000..8a92ffcf --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionViewController.swift @@ -0,0 +1,413 @@ +// +// CollectionViewController.swift +// CollectionUI +// +// Created by opfic on 8/20/26. +// + +import UIKit + +@MainActor +final class CollectionViewController: UIViewController, + UICollectionViewDelegate, + UICollectionViewDataSourcePrefetching +where SectionIdentifier: Hashable & Sendable, ItemIdentifier: Hashable & Sendable { + private static var emptyCellReuseIdentifier: String { "CollectionViewEmptyCell" } + + private let collectionView: UICollectionView + private var configuration: CollectionViewConfiguration + private var dataSource: UICollectionViewDiffableDataSource! + private var appliedSnapshot: CollectionRenderingSnapshot? + private var prefetchedItemIdentifiers = Set() + private var refreshTask: Task? + + private struct ScrollAnchor { + let itemIdentifier: ItemIdentifier + let relativeOffset: CGPoint + } + + private struct PreservedState { + let selectedItemIdentifiers: [ItemIdentifier] + let scrollAnchor: ScrollAnchor? + let contentOffset: CGPoint + } + + init(configuration: CollectionViewConfiguration) { + self.configuration = configuration + collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewCompositionalLayout { _, _ in nil } + ) + super.init(nibName: nil, bundle: nil) + configureCollectionView() + configureDataSource() + configureLayout() + collectionView.delegate = self + collectionView.prefetchDataSource = self + updateRefreshControl() + applySnapshotIfNeeded() + } + + required init?(coder: NSCoder) { + nil + } + + override func loadView() { + view = collectionView + } + + func update(configuration: CollectionViewConfiguration) { + guard validates(configuration.snapshot) else { + return + } + self.configuration = configuration + collectionView.collectionViewLayout.invalidateLayout() + updateRefreshControl() + applySnapshotIfNeeded() + } + + func dismantle() { + refreshTask?.cancel() + refreshTask = nil + collectionView.delegate = nil + collectionView.prefetchDataSource = nil + collectionView.refreshControl = nil + dataSource.supplementaryViewProvider = nil + } + + func snapshot() -> NSDiffableDataSourceSnapshot { + dataSource.snapshot() + } + + private func configureCollectionView() { + collectionView.register( + CollectionViewEmptyCell.self, + forCellWithReuseIdentifier: Self.emptyCellReuseIdentifier + ) + } + + private func configureDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, itemIdentifier in + guard let cell = self?.configuration.cellProvider(collectionView, indexPath, itemIdentifier) else { + return self?.emptyCell(in: collectionView, for: indexPath, itemIdentifier: itemIdentifier) + } + return cell + } + dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in + guard + let self, + let sectionIdentifier = self.sectionIdentifier(at: indexPath.section) + else { return nil } + return self.configuration.supplementaryViewProvider?( + collectionView, + kind, + indexPath, + sectionIdentifier + ) + } + } + + private func configureLayout() { + collectionView.setCollectionViewLayout( + UICollectionViewCompositionalLayout { [weak self] sectionIndex, environment in + guard let sectionIdentifier = self?.sectionIdentifier(at: sectionIndex) else { + return nil + } + return self?.configuration.layoutProvider(sectionIdentifier, environment) + }, + animated: false + ) + } + + private func updateRefreshControl() { + guard configuration.refreshAction != nil else { + collectionView.refreshControl = nil + return + } + + guard collectionView.refreshControl == nil else { + return + } + let refreshControl = UIRefreshControl() + refreshControl.addAction(UIAction { [weak self] _ in + self?.refresh() + }, for: .valueChanged) + collectionView.refreshControl = refreshControl + } + + private func refresh() { + guard let action = configuration.refreshAction else { + collectionView.refreshControl?.endRefreshing() + return + } + refreshTask?.cancel() + refreshTask = Task { [weak self] in + await action() + guard !Task.isCancelled else { return } + self?.collectionView.refreshControl?.endRefreshing() + } + } + + private func applySnapshotIfNeeded() { + let snapshot = configuration.snapshot + let requiresStructuralUpdate = !hasSameStructure(snapshot, appliedSnapshot) + let hasReconfiguredItems = !snapshot.reconfiguredItemIdentifiers.isEmpty + let hasReconfiguredSections = !snapshot.reconfiguredSectionIdentifiers.isEmpty + guard requiresStructuralUpdate || hasReconfiguredItems || hasReconfiguredSections else { + return + } + + guard validates(snapshot) else { + return + } + + let diffableSnapshot = makeDiffableSnapshot(from: snapshot) + if appliedSnapshot == nil { + dataSource.applySnapshotUsingReloadData(diffableSnapshot) + } else { + let preservedState = makePreservedState() + dataSource.apply(diffableSnapshot, animatingDifferences: true) { [weak self] in + self?.restore(preservedState) + } + } + appliedSnapshot = CollectionRenderingSnapshot(sections: snapshot.sections) + } + + private func hasSameStructure( + _ lhs: CollectionRenderingSnapshot, + _ rhs: CollectionRenderingSnapshot? + ) -> Bool { + lhs.sections == rhs?.sections + } + + func makeDiffableSnapshot( + from renderingSnapshot: CollectionRenderingSnapshot + ) -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + + for section in renderingSnapshot.sections { + snapshot.appendSections([section.identifier]) + snapshot.appendItems(section.itemIdentifiers, toSection: section.identifier) + } + + let identifiers = renderingSnapshot.reconfiguredItemIdentifiers.filter { + snapshot.indexOfItem($0) != nil + } + snapshot.reconfigureItems(Array(identifiers)) + let sectionIdentifiers = renderingSnapshot.reconfiguredSectionIdentifiers.filter { + snapshot.indexOfSection($0) != nil + } + snapshot.reloadSections(Array(sectionIdentifiers)) + return snapshot + } + + private func validates( + _ snapshot: CollectionRenderingSnapshot + ) -> Bool { + var sectionIdentifiers = Set() + var itemIdentifiers = Set() + + for section in snapshot.sections { + guard sectionIdentifiers.insert(section.identifier).inserted else { + assertionFailure("CollectionRenderingSnapshot contains a duplicated section identifier.") + return false + } + + for itemIdentifier in section.itemIdentifiers { + guard itemIdentifiers.insert(itemIdentifier).inserted else { + assertionFailure("CollectionRenderingSnapshot contains a duplicated item identifier.") + return false + } + } + } + return true + } + + private func sectionIdentifier(at index: Int) -> SectionIdentifier? { + let sections = appliedSnapshot?.sections ?? configuration.snapshot.sections + guard sections.indices.contains(index) else { + return nil + } + return sections[index].identifier + } + + private func makePreservedState() -> PreservedState { + let selectedItemIdentifiers = (collectionView.indexPathsForSelectedItems ?? []).compactMap { + dataSource.itemIdentifier(for: $0) + } + return PreservedState( + selectedItemIdentifiers: selectedItemIdentifiers, + scrollAnchor: makeScrollAnchor(), + contentOffset: collectionView.contentOffset + ) + } + + private func makeScrollAnchor() -> ScrollAnchor? { + collectionView.layoutIfNeeded() + let indexPaths = collectionView.indexPathsForVisibleItems + let indexPath = indexPaths.min { lhs, rhs in + let lhsOrigin = collectionView.layoutAttributesForItem(at: lhs)?.frame.origin ?? .zero + let rhsOrigin = collectionView.layoutAttributesForItem(at: rhs)?.frame.origin ?? .zero + return lhsOrigin.y == rhsOrigin.y ? lhsOrigin.x < rhsOrigin.x : lhsOrigin.y < rhsOrigin.y + } + guard + let indexPath, + let itemIdentifier = dataSource.itemIdentifier(for: indexPath), + let attributes = collectionView.layoutAttributesForItem(at: indexPath) + else { return nil } + return ScrollAnchor( + itemIdentifier: itemIdentifier, + relativeOffset: CGPoint( + x: attributes.frame.minX - collectionView.contentOffset.x, + y: attributes.frame.minY - collectionView.contentOffset.y + ) + ) + } + + private func restore(_ state: PreservedState) { + guard hasSameUserState(as: state) else { + return + } + collectionView.layoutIfNeeded() + restoreSelection(state.selectedItemIdentifiers) + restoreScrollPosition(state.scrollAnchor) + } + + private func hasSameUserState(as state: PreservedState) -> Bool { + let selectedItemIdentifiers = (collectionView.indexPathsForSelectedItems ?? []).compactMap { + dataSource.itemIdentifier(for: $0) + } + return selectedItemIdentifiers == state.selectedItemIdentifiers + && collectionView.contentOffset == state.contentOffset + } + + private func restoreSelection(_ itemIdentifiers: [ItemIdentifier]) { + for itemIdentifier in itemIdentifiers { + guard let indexPath = dataSource.indexPath(for: itemIdentifier) else { continue } + collectionView.selectItem(at: indexPath, animated: false, scrollPosition: []) + } + } + + private func restoreScrollPosition(_ anchor: ScrollAnchor?) { + guard + let anchor, + let indexPath = dataSource.indexPath(for: anchor.itemIdentifier), + let attributes = collectionView.layoutAttributesForItem(at: indexPath) + else { + clampContentOffset() + return + } + let adjustedInset = collectionView.adjustedContentInset + let maximumOffset = CGPoint( + x: max( + -adjustedInset.left, + collectionView.contentSize.width - collectionView.bounds.width + adjustedInset.right + ), + y: max( + -adjustedInset.top, + collectionView.contentSize.height - collectionView.bounds.height + adjustedInset.bottom + ) + ) + let offset = CGPoint( + x: min(maximumOffset.x, max(-adjustedInset.left, attributes.frame.minX - anchor.relativeOffset.x)), + y: min(maximumOffset.y, max(-adjustedInset.top, attributes.frame.minY - anchor.relativeOffset.y)) + ) + collectionView.setContentOffset(offset, animated: false) + } + + private func clampContentOffset() { + let adjustedInset = collectionView.adjustedContentInset + let maximumOffset = CGPoint( + x: max( + -adjustedInset.left, + collectionView.contentSize.width - collectionView.bounds.width + adjustedInset.right + ), + y: max( + -adjustedInset.top, + collectionView.contentSize.height - collectionView.bounds.height + adjustedInset.bottom + ) + ) + let offset = CGPoint( + x: min(maximumOffset.x, max(-adjustedInset.left, collectionView.contentOffset.x)), + y: min(maximumOffset.y, max(-adjustedInset.top, collectionView.contentOffset.y)) + ) + collectionView.setContentOffset(offset, animated: false) + } + + private func emptyCell( + in collectionView: UICollectionView, + for indexPath: IndexPath, + itemIdentifier: ItemIdentifier + ) -> UICollectionViewCell { + #if DEBUG + assertionFailure("CollectionUI cell provider did not return a cell for \(itemIdentifier).") + #endif + return collectionView.dequeueReusableCell( + withReuseIdentifier: Self.emptyCellReuseIdentifier, + for: indexPath + ) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return } + configuration.onSelect?(itemIdentifier) + } + + func collectionView( + _ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath + ) { + guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return } + prefetchedItemIdentifiers.remove(itemIdentifier) + configuration.onWillDisplay?(itemIdentifier) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + configuration.onScroll?(scrollView.contentOffset) + } + + func collectionView( + _ collectionView: UICollectionView, + leadingSwipeActionsConfigurationForItemAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + swipeActionsConfiguration(for: indexPath, edge: .leading) + } + + func collectionView( + _ collectionView: UICollectionView, + trailingSwipeActionsConfigurationForItemAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + swipeActionsConfiguration(for: indexPath, edge: .trailing) + } + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + let identifiers = indexPaths.compactMap(dataSource.itemIdentifier).filter { + prefetchedItemIdentifiers.insert($0).inserted + } + guard !identifiers.isEmpty else { return } + configuration.onPrefetch?(identifiers) + } + + func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + let identifiers = indexPaths.compactMap(dataSource.itemIdentifier).filter { + prefetchedItemIdentifiers.remove($0) != nil + } + guard !identifiers.isEmpty else { return } + configuration.onCancelPrefetch?(identifiers) + } + + private func swipeActionsConfiguration( + for indexPath: IndexPath, + edge: CollectionSwipeActionsEdge + ) -> UISwipeActionsConfiguration? { + guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else { return nil } + let configurations = configuration.swipeActions.filter { $0.edge == edge } + let actions = configurations.flatMap { $0.actions(itemIdentifier) } + guard !actions.isEmpty else { return nil } + let configuration = UISwipeActionsConfiguration(actions: actions) + configuration.performsFirstActionWithFullSwipe = configurations.allSatisfy(\.allowsFullSwipe) + return configuration + } +} diff --git a/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift b/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift new file mode 100644 index 00000000..3e064aa1 --- /dev/null +++ b/Libraries/CollectionUI/Sources/CollectionViewEmptyCell.swift @@ -0,0 +1,15 @@ +// +// CollectionViewEmptyCell.swift +// CollectionUI +// +// Created by opfic on 8/20/26. +// + +import UIKit + +final class CollectionViewEmptyCell: UICollectionViewCell { + override func prepareForReuse() { + super.prepareForReuse() + isUserInteractionEnabled = false + } +} diff --git a/Libraries/CollectionUI/Tests/.swiftlint.yml b/Libraries/CollectionUI/Tests/.swiftlint.yml new file mode 100644 index 00000000..b2c5c38d --- /dev/null +++ b/Libraries/CollectionUI/Tests/.swiftlint.yml @@ -0,0 +1 @@ +parent_config: ../../../.swiftlint-tests.yml diff --git a/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift b/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift new file mode 100644 index 00000000..6eeafae0 --- /dev/null +++ b/Libraries/CollectionUI/Tests/CollectionRenderingSnapshotTests.swift @@ -0,0 +1,59 @@ +// +// CollectionRenderingSnapshotTests.swift +// CollectionUITests +// +// Created by opfic on 8/20/26. +// + +import Testing +@testable import CollectionUI + +struct CollectionRenderingSnapshotTests { + @Test("section과 item 식별자의 표시 순서를 유지한다") + func preservesSectionAndItemIdentifierOrder() { + let snapshot = CollectionRenderingSnapshot( + sections: [ + .init(identifier: "pinned", itemIdentifiers: ["todo-1", "todo-2"]), + .init(identifier: "default", itemIdentifiers: ["todo-3"]) + ] + ) + + #expect(snapshot.sections.map(\.identifier) == ["pinned", "default"]) + #expect(snapshot.sections[0].itemIdentifiers == ["todo-1", "todo-2"]) + #expect(snapshot.sections[1].itemIdentifiers == ["todo-3"]) + } + + @Test("내용이 변경된 item 식별자를 별도로 보관한다") + func storesReconfiguredItemIdentifiers() { + let snapshot = CollectionRenderingSnapshot( + sections: [ + .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) + ], + reconfiguredItemIdentifiers: ["todo-2"] + ) + + #expect(snapshot.reconfiguredItemIdentifiers == ["todo-2"]) + } + + @Test("동일한 rendering snapshot은 같은 값으로 비교한다") + func comparesEqualRenderingSnapshots() { + let section = CollectionRenderingSnapshot.Section( + identifier: "default", + itemIdentifiers: ["todo-1"] + ) + let lhs = CollectionRenderingSnapshot(sections: [section]) + let rhs = CollectionRenderingSnapshot(sections: [section]) + + #expect(lhs == rhs) + } + + @Test("내용이 변경된 section 식별자를 별도로 보관한다") + func storesReconfiguredSectionIdentifiers() { + let snapshot = CollectionRenderingSnapshot( + sections: [.init(identifier: "default", itemIdentifiers: [])], + reconfiguredSectionIdentifiers: ["default"] + ) + + #expect(snapshot.reconfiguredSectionIdentifiers == ["default"]) + } +} diff --git a/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift b/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift new file mode 100644 index 00000000..bcc78b69 --- /dev/null +++ b/Libraries/CollectionUI/Tests/CollectionViewControllerInteractionTests.swift @@ -0,0 +1,77 @@ +// +// CollectionViewControllerInteractionTests.swift +// CollectionUITests +// +// Created by opfic on 8/20/26. +// + +import Testing +import UIKit +@testable import CollectionUI + +@MainActor +struct CollectionViewControllerInteractionTests { + @Test("선택과 표시 callback은 item 식별자를 전달한다") + func forwardsSelectionAndDisplayIdentifiers() { + var selectedIdentifier: String? + var displayedIdentifier: String? + let controller = controller( + onSelect: { selectedIdentifier = $0 }, + onWillDisplay: { displayedIdentifier = $0 } + ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + let indexPath = IndexPath(item: 0, section: 0) + + controller.collectionView(collectionView, didSelectItemAt: indexPath) + controller.collectionView(collectionView, willDisplay: UICollectionViewCell(), forItemAt: indexPath) + + #expect(selectedIdentifier == "todo-1") + #expect(displayedIdentifier == "todo-1") + } + + @Test("prefetch와 취소 callback은 중복 item을 한 번만 전달한다") + func deduplicatesPrefetchAndCancellationIdentifiers() { + var prefetchedIdentifiers = [[String]]() + var cancelledIdentifiers = [[String]]() + let controller = controller( + onPrefetch: { prefetchedIdentifiers.append($0) }, + onCancelPrefetch: { cancelledIdentifiers.append($0) } + ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + let indexPath = IndexPath(item: 0, section: 0) + + controller.collectionView(collectionView, prefetchItemsAt: [indexPath, indexPath]) + controller.collectionView(collectionView, cancelPrefetchingForItemsAt: [indexPath, indexPath]) + + #expect(prefetchedIdentifiers == [["todo-1"]]) + #expect(cancelledIdentifiers == [["todo-1"]]) + } + + private func controller( + onSelect: ((String) -> Void)? = nil, + onWillDisplay: ((String) -> Void)? = nil, + onPrefetch: (([String]) -> Void)? = nil, + onCancelPrefetch: (([String]) -> Void)? = nil + ) -> CollectionViewController { + var configuration = CollectionViewConfiguration( + snapshot: CollectionRenderingSnapshot(sections: [ + .init(identifier: "default", itemIdentifiers: ["todo-1"]) + ]), + layoutProvider: { _, _ in + NSCollectionLayoutSection(group: .horizontal( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44) + ), + subitems: [] + )) + }, + cellProvider: { _, _, _ in UICollectionViewCell() } + ) + configuration.onSelect = onSelect + configuration.onWillDisplay = onWillDisplay + configuration.onPrefetch = onPrefetch + configuration.onCancelPrefetch = onCancelPrefetch + return CollectionViewController(configuration: configuration) + } +} diff --git a/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift b/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift new file mode 100644 index 00000000..850dda04 --- /dev/null +++ b/Libraries/CollectionUI/Tests/CollectionViewControllerSnapshotTests.swift @@ -0,0 +1,63 @@ +// +// CollectionViewControllerSnapshotTests.swift +// CollectionUITests +// +// Created by opfic on 8/20/26. +// + +import Testing +import UIKit +@testable import CollectionUI + +@MainActor +struct CollectionViewControllerSnapshotTests { + @Test("controller는 section과 item을 diffable snapshot으로 적용한다") + func appliesRenderingSnapshot() { + let controller = CollectionViewController(configuration: configuration( + snapshot: .init(sections: [ + .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) + ]) + )) + + let snapshot = controller.snapshot() + + #expect(snapshot.sectionIdentifiers == ["default"]) + #expect(snapshot.itemIdentifiers(inSection: "default") == ["todo-1", "todo-2"]) + } + + @Test("다시 구성할 item만 diffable snapshot에 표시한다") + func reconfiguresSpecifiedItems() { + let renderingSnapshot = CollectionRenderingSnapshot( + sections: [ + .init(identifier: "default", itemIdentifiers: ["todo-1", "todo-2"]) + ], + reconfiguredItemIdentifiers: ["todo-2"] + ) + let controller = CollectionViewController(configuration: configuration(snapshot: renderingSnapshot)) + + #expect(controller.makeDiffableSnapshot(from: renderingSnapshot).reconfiguredItemIdentifiers == ["todo-2"]) + } + + private func configuration( + snapshot: CollectionRenderingSnapshot + ) -> CollectionViewConfiguration { + CollectionViewConfiguration( + snapshot: snapshot, + layoutProvider: { _, _ in + NSCollectionLayoutSection(group: .horizontal( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44) + ), + subitems: [] + )) + }, + cellProvider: { collectionView, indexPath, _ in + collectionView.dequeueReusableCell( + withReuseIdentifier: "cell", + for: indexPath + ) + } + ) + } +} diff --git a/README.md b/README.md index f29faf78..ada7acc5 100644 --- a/README.md +++ b/README.md @@ -304,8 +304,8 @@ flowchart LR | --- | --- | --- | --- | --- | | Planner | active main agent | Primary | 이슈, 요청, 변경 범위, role routing 정리 | Implementer / Architecture Watcher | | Implementer | active main agent | Primary | task packet 기준 코드 또는 문서 수정 | Code Reviewer | -| Architecture Watcher | `architecture_watcher` | `gpt-5.3-codex-spark` (`Lightweight`) -> Primary | layer, target, dependency, SDK placement, Widget/StorePattern 경계 감시 | Implementer / Final Integration | -| Code Reviewer | `code_reviewer` | `gpt-5.3-codex-spark` (`Lightweight`) -> Primary | diff 기준 버그, 회귀, 테스트 누락, scope drift 검토 | Verification Runner | -| Verification Runner | `verification_runner` | `gpt-5.3-codex-spark` (`Lightweight`) | SwiftLint, test, build-only, docs check 결과 기록 | Final Integration | -| GitHub/CI Analyst | `github_ci_analyst` | `gpt-5.3-codex-spark` (`Lightweight`) | issue, PR thread, review comment, workflow run, CI log 분석 | Planner | -| Documentation Writer | `documentation_writer` | `gpt-5.3-codex-spark` (`Lightweight`) | PR 본문, release note, README, issue/comment 문안 작성 | Final Integration | +| Architecture Watcher | `architecture_watcher` | `gpt-5.3-codex-spark` (`Lightweight`), 불가 시 `gpt-5.6-luna` (`high`) -> Primary | layer, target, dependency, SDK placement, Widget/StorePattern 경계 감시 | Implementer / Final Integration | +| Code Reviewer | `code_reviewer` | `gpt-5.3-codex-spark` (`Lightweight`), 불가 시 `gpt-5.6-luna` (`high`) -> Primary | diff 기준 버그, 회귀, 테스트 누락, scope drift 검토 | Verification Runner | +| Verification Runner | `verification_runner` | `gpt-5.3-codex-spark` (`Lightweight`), 불가 시 `gpt-5.6-luna` (`high`) | SwiftLint, test, build-only, docs check 결과 기록 | Final Integration | +| GitHub/CI Analyst | `github_ci_analyst` | `gpt-5.3-codex-spark` (`Lightweight`), 불가 시 `gpt-5.6-luna` (`high`) | issue, PR thread, review comment, workflow run, CI log 분석 | Planner | +| Documentation Writer | `documentation_writer` | `gpt-5.3-codex-spark` (`Lightweight`), 불가 시 `gpt-5.6-luna` (`high`) | PR 본문, release note, README, issue/comment 문안 작성 | Final Integration | diff --git a/Workspace.swift b/Workspace.swift index 142fdad1..912ef51f 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -11,6 +11,7 @@ let workspace = Workspace( "Application/Persistence", "Application/Presentation", "Application/Widget", + "Libraries/CollectionUI", "Libraries/MarkdownRenderer", "Widget/WidgetCore", "Widget/WidgetExtension", diff --git a/docs/graph.png b/docs/graph.png index 385f0968..d98b1b78 100644 Binary files a/docs/graph.png and b/docs/graph.png differ