Commit 0cc97ef4f1
Unsigned
Layout: unified · split
Hutch/Networking/ProjectService.swift +469
| @@ -198,6 +198,170 @@ private struct ProjectTrackerPayload: Decodable, Sendable { | |||
| 198 | } | 198 | } |
| 199 | } | 199 | } |
| 200 | 200 | ||
| 201 | /// A public project surfaced by discovery, carrying its owner for display. | ||
| 202 | struct DiscoveredProject: Identifiable, Hashable, Sendable { | ||
| 203 | let project: Project | ||
| 204 | let ownerCanonicalName: String | ||
| 205 | |||
| 206 | var id: String { project.id } | ||
| 207 | } | ||
| 208 | |||
| 209 | struct DiscoveredProjectsPage: Sendable { | ||
| 210 | let projects: [DiscoveredProject] | ||
| 211 | let cursor: String? | ||
| 212 | } | ||
| 213 | |||
| 214 | private struct PublicProjectsResponse: Decodable, Sendable { | ||
| 215 | let projects: PublicProjectPage | ||
| 216 | } | ||
| 217 | |||
| 218 | private struct PublicProjectPage: Decodable, Sendable { | ||
| 219 | let results: [PublicProjectPayload] | ||
| 220 | let cursor: String? | ||
| 221 | |||
| 222 | init(from decoder: any Decoder) throws { | ||
| 223 | enum CodingKeys: String, CodingKey { case results, cursor } | ||
| 224 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 225 | results = try container.decodeIfPresent([PublicProjectPayload].self, forKey: .results) ?? [] | ||
| 226 | cursor = try container.decodeIfPresent(String.self, forKey: .cursor) | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | private struct PublicProjectPayload: Decodable, Sendable { | ||
| 231 | let rid: String | ||
| 232 | let name: String | ||
| 233 | let description: String? | ||
| 234 | let website: String? | ||
| 235 | let visibility: Visibility | ||
| 236 | let tags: [String] | ||
| 237 | let updated: Date | ||
| 238 | let owner: Entity | ||
| 239 | |||
| 240 | init(from decoder: any Decoder) throws { | ||
| 241 | enum CodingKeys: String, CodingKey { | ||
| 242 | case rid, name, description, website, visibility, tags, updated, owner | ||
| 243 | } | ||
| 244 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 245 | rid = try container.decode(String.self, forKey: .rid) | ||
| 246 | name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" | ||
| 247 | description = try container.decodeIfPresent(String.self, forKey: .description) | ||
| 248 | website = try container.decodeIfPresent(String.self, forKey: .website) | ||
| 249 | visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility | ||
| 250 | tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] | ||
| 251 | updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast | ||
| 252 | owner = try container.decodeIfPresent(Entity.self, forKey: .owner) ?? Entity(canonicalName: "~unknown") | ||
| 253 | } | ||
| 254 | } | ||
| 255 | |||
| 256 | /// A resource the current user can link to a project (#15 add flow). | ||
| 257 | struct LinkableResource: Identifiable, Hashable, Sendable { | ||
| 258 | enum Kind: Sendable { case source, tracker, mailingList } | ||
| 259 | |||
| 260 | let rid: String | ||
| 261 | let name: String | ||
| 262 | let ownerCanonicalName: String | ||
| 263 | let kind: Kind | ||
| 264 | |||
| 265 | var id: String { rid } | ||
| 266 | var displayName: String { "\(ownerCanonicalName)/\(name)" } | ||
| 267 | } | ||
| 268 | |||
| 269 | private struct CandidatePayload: Decodable, Sendable { | ||
| 270 | let rid: String | ||
| 271 | let name: String | ||
| 272 | let owner: Entity? | ||
| 273 | } | ||
| 274 | |||
| 275 | private struct CandidatePage: Decodable, Sendable { | ||
| 276 | let results: [CandidatePayload] | ||
| 277 | let cursor: String? | ||
| 278 | |||
| 279 | init(from decoder: any Decoder) throws { | ||
| 280 | enum CodingKeys: String, CodingKey { case results, cursor } | ||
| 281 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 282 | results = try container.decodeIfPresent([CandidatePayload].self, forKey: .results) ?? [] | ||
| 283 | cursor = try container.decodeIfPresent(String.self, forKey: .cursor) | ||
| 284 | } | ||
| 285 | } | ||
| 286 | |||
| 287 | private struct RepoCandidatesResponse: Decodable, Sendable { | ||
| 288 | let repositories: CandidatePage | ||
| 289 | } | ||
| 290 | |||
| 291 | private struct TrackerCandidatesResponse: Decodable, Sendable { | ||
| 292 | let trackers: CandidatePage | ||
| 293 | } | ||
| 294 | |||
| 295 | private struct ListSubscriptionPayload: Decodable, Sendable { | ||
| 296 | let list: CandidatePayload? | ||
| 297 | } | ||
| 298 | |||
| 299 | private struct ListSubscriptionPage: Decodable, Sendable { | ||
| 300 | let results: [ListSubscriptionPayload] | ||
| 301 | let cursor: String? | ||
| 302 | |||
| 303 | init(from decoder: any Decoder) throws { | ||
| 304 | enum CodingKeys: String, CodingKey { case results, cursor } | ||
| 305 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 306 | results = try container.decodeIfPresent([ListSubscriptionPayload].self, forKey: .results) ?? [] | ||
| 307 | cursor = try container.decodeIfPresent(String.self, forKey: .cursor) | ||
| 308 | } | ||
| 309 | } | ||
| 310 | |||
| 311 | private struct ListCandidatesResponse: Decodable, Sendable { | ||
| 312 | let subscriptions: ListSubscriptionPage | ||
| 313 | } | ||
| 314 | |||
| 315 | /// Link/unlink mutations only need success; the returned resource is ignored. | ||
| 316 | private struct LinkMutationResponse: Decodable, Sendable {} | ||
| 317 | |||
| 318 | private struct CreateProjectResponse: Decodable, Sendable { | ||
| 319 | let createProject: MutatedProjectPayload? | ||
| 320 | } | ||
| 321 | |||
| 322 | private struct UpdateProjectResponse: Decodable, Sendable { | ||
| 323 | let updateProject: MutatedProjectPayload? | ||
| 324 | } | ||
| 325 | |||
| 326 | private struct MutatedProjectPayload: Decodable, Sendable { | ||
| 327 | let rid: String | ||
| 328 | let name: String | ||
| 329 | let description: String? | ||
| 330 | let website: String? | ||
| 331 | let visibility: Visibility | ||
| 332 | let tags: [String] | ||
| 333 | let updated: Date | ||
| 334 | |||
| 335 | init(from decoder: any Decoder) throws { | ||
| 336 | enum CodingKeys: String, CodingKey { | ||
| 337 | case rid, name, description, website, visibility, tags, updated | ||
| 338 | } | ||
| 339 | let container = try decoder.container(keyedBy: CodingKeys.self) | ||
| 340 | rid = try container.decode(String.self, forKey: .rid) | ||
| 341 | name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" | ||
| 342 | description = try container.decodeIfPresent(String.self, forKey: .description) | ||
| 343 | website = try container.decodeIfPresent(String.self, forKey: .website) | ||
| 344 | visibility = try container.decodeIfPresent(Visibility.self, forKey: .visibility) ?? .publicVisibility | ||
| 345 | tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] | ||
| 346 | updated = try container.decodeIfPresent(Date.self, forKey: .updated) ?? .distantPast | ||
| 347 | } | ||
| 348 | |||
| 349 | var project: Project { | ||
| 350 | Project( | ||
| 351 | metadata: .init( | ||
| 352 | id: rid, | ||
| 353 | name: name, | ||
| 354 | description: description, | ||
| 355 | website: website, | ||
| 356 | visibility: visibility, | ||
| 357 | tags: tags, | ||
| 358 | updated: updated | ||
| 359 | ), | ||
| 360 | resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) | ||
| 361 | ) | ||
| 362 | } | ||
| 363 | } | ||
| 364 | |||
| 201 | struct ProjectService: Sendable { | 365 | struct ProjectService: Sendable { |
| 202 | private let client: SRHTClient | 366 | private let client: SRHTClient |
| 203 | 367 | ||
| @@ -265,6 +429,91 @@ struct ProjectService: Sendable { | |||
| 265 | } | 429 | } |
| 266 | """ | 430 | """ |
| 267 | 431 | ||
| 432 | private static let publicProjectsQuery = """ | ||
| 433 | query publicProjects($cursor: Cursor) { | ||
| 434 | projects(cursor: $cursor) { | ||
| 435 | results { | ||
| 436 | rid | ||
| 437 | name | ||
| 438 | description | ||
| 439 | website | ||
| 440 | visibility | ||
| 441 | tags | ||
| 442 | updated | ||
| 443 | owner { canonicalName } | ||
| 444 | } | ||
| 445 | cursor | ||
| 446 | } | ||
| 447 | } | ||
| 448 | """ | ||
| 449 | |||
| 450 | private static let createProjectMutation = """ | ||
| 451 | mutation createProject($name: String!, $visibility: Visibility!, $description: String, $tags: [String!]) { | ||
| 452 | createProject(name: $name, visibility: $visibility, description: $description, tags: $tags) { | ||
| 453 | rid | ||
| 454 | name | ||
| 455 | description | ||
| 456 | website | ||
| 457 | visibility | ||
| 458 | tags | ||
| 459 | updated | ||
| 460 | } | ||
| 461 | } | ||
| 462 | """ | ||
| 463 | |||
| 464 | private static let updateProjectMutation = """ | ||
| 465 | mutation updateProject($rid: ID!, $input: ProjectInput!) { | ||
| 466 | updateProject(rid: $rid, input: $input) { | ||
| 467 | rid | ||
| 468 | name | ||
| 469 | description | ||
| 470 | website | ||
| 471 | visibility | ||
| 472 | tags | ||
| 473 | updated | ||
| 474 | } | ||
| 475 | } | ||
| 476 | """ | ||
| 477 | |||
| 478 | private static func linkMutation(field: String, resourceParam: String) -> String { | ||
| 479 | """ | ||
| 480 | mutation link($projectID: ID!, $resourceID: ID!) { | ||
| 481 | \(field)(projectID: $projectID, \(resourceParam): $resourceID) { rid } | ||
| 482 | } | ||
| 483 | """ | ||
| 484 | } | ||
| 485 | |||
| 486 | private static let repositoriesCandidatesQuery = """ | ||
| 487 | query repositories($cursor: Cursor) { | ||
| 488 | repositories(cursor: $cursor) { | ||
| 489 | results { rid name owner { canonicalName } } | ||
| 490 | cursor | ||
| 491 | } | ||
| 492 | } | ||
| 493 | """ | ||
| 494 | |||
| 495 | private static let trackersCandidatesQuery = """ | ||
| 496 | query trackers($cursor: Cursor) { | ||
| 497 | trackers(cursor: $cursor) { | ||
| 498 | results { rid name owner { canonicalName } } | ||
| 499 | cursor | ||
| 500 | } | ||
| 501 | } | ||
| 502 | """ | ||
| 503 | |||
| 504 | private static let listCandidatesQuery = """ | ||
| 505 | query subscriptions($cursor: Cursor) { | ||
| 506 | subscriptions(cursor: $cursor) { | ||
| 507 | results { | ||
| 508 | ... on MailingListSubscription { | ||
| 509 | list { rid name owner { canonicalName } } | ||
| 510 | } | ||
| 511 | } | ||
| 512 | cursor | ||
| 513 | } | ||
| 514 | } | ||
| 515 | """ | ||
| 516 | |||
| 268 | init(client: SRHTClient) { | 517 | init(client: SRHTClient) { |
| 269 | self.client = client | 518 | self.client = client |
| 270 | } | 519 | } |
| @@ -277,6 +526,226 @@ struct ProjectService: Sendable { | |||
| 277 | try await fetchProjectDetailPayload(rid: rid) | 526 | try await fetchProjectDetailPayload(rid: rid) |
| 278 | } | 527 | } |
| 279 | 528 | ||
| 529 | // MARK: - Discovery (#12) | ||
| 530 | |||
| 531 | /// Lists public projects across all users. Not cached — discovery is | ||
| 532 | /// browsed live and paginated by the caller. | ||
| 533 | func fetchPublicProjects(cursor: String? = nil) async throws -> DiscoveredProjectsPage { | ||
| 534 | var variables: [String: any Sendable] = [:] | ||
| 535 | if let cursor { | ||
| 536 | variables["cursor"] = cursor | ||
| 537 | } | ||
| 538 | |||
| 539 | let response = try await client.execute( | ||
| 540 | service: .hub, | ||
| 541 | query: Self.publicProjectsQuery, | ||
| 542 | variables: variables.isEmpty ? nil : variables, | ||
| 543 | responseType: PublicProjectsResponse.self | ||
| 544 | ) | ||
| 545 | |||
| 546 | let projects = response.projects.results.map { payload in | ||
| 547 | DiscoveredProject( | ||
| 548 | project: Project( | ||
| 549 | metadata: .init( | ||
| 550 | id: payload.rid, | ||
| 551 | name: payload.name, | ||
| 552 | description: payload.description, | ||
| 553 | website: payload.website, | ||
| 554 | visibility: payload.visibility, | ||
| 555 | tags: payload.tags, | ||
| 556 | updated: payload.updated | ||
| 557 | ), | ||
| 558 | resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) | ||
| 559 | ), | ||
| 560 | ownerCanonicalName: payload.owner.canonicalName | ||
| 561 | ) | ||
| 562 | } | ||
| 563 | return DiscoveredProjectsPage(projects: projects, cursor: response.projects.cursor) | ||
| 564 | } | ||
| 565 | |||
| 566 | // MARK: - Mutations (#13, #14, #15) | ||
| 567 | |||
| 568 | func createProject( | ||
| 569 | name: String, | ||
| 570 | visibility: Visibility, | ||
| 571 | description: String?, | ||
| 572 | tags: [String] | ||
| 573 | ) async throws -> Project { | ||
| 574 | var variables: [String: any Sendable] = ["name": name, "visibility": visibility.rawValue] | ||
| 575 | if let description, !description.isEmpty { | ||
| 576 | variables["description"] = description | ||
| 577 | } | ||
| 578 | if !tags.isEmpty { | ||
| 579 | variables["tags"] = tags | ||
| 580 | } | ||
| 581 | |||
| 582 | let response = try await client.execute( | ||
| 583 | service: .hub, | ||
| 584 | query: Self.createProjectMutation, | ||
| 585 | variables: variables, | ||
| 586 | responseType: CreateProjectResponse.self | ||
| 587 | ) | ||
| 588 | await invalidateProjectCaches() | ||
| 589 | |||
| 590 | guard let payload = response.createProject else { | ||
| 591 | throw SRHTError.decodingError( | ||
| 592 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing createProject payload")) | ||
| 593 | ) | ||
| 594 | } | ||
| 595 | return payload.project | ||
| 596 | } | ||
| 597 | |||
| 598 | /// Updates a project. Only non-nil fields are sent; `description`/`website` | ||
| 599 | /// pass an empty string to clear the field. | ||
| 600 | func updateProject( | ||
| 601 | rid: String, | ||
| 602 | name: String? = nil, | ||
| 603 | description: String? = nil, | ||
| 604 | website: String? = nil, | ||
| 605 | visibility: Visibility? = nil, | ||
| 606 | tags: [String]? = nil | ||
| 607 | ) async throws -> Project { | ||
| 608 | var input: [String: any Sendable] = [:] | ||
| 609 | if let name { | ||
| 610 | input["name"] = name | ||
| 611 | } | ||
| 612 | if let description { | ||
| 613 | input["description"] = description | ||
| 614 | } | ||
| 615 | if let website { | ||
| 616 | input["website"] = website | ||
| 617 | } | ||
| 618 | if let visibility { | ||
| 619 | input["visibility"] = visibility.rawValue | ||
| 620 | } | ||
| 621 | if let tags { | ||
| 622 | input["tags"] = tags | ||
| 623 | } | ||
| 624 | |||
| 625 | let response = try await client.execute( | ||
| 626 | service: .hub, | ||
| 627 | query: Self.updateProjectMutation, | ||
| 628 | variables: ["rid": rid, "input": input], | ||
| 629 | responseType: UpdateProjectResponse.self | ||
| 630 | ) | ||
| 631 | await invalidateProjectCaches() | ||
| 632 | |||
| 633 | guard let payload = response.updateProject else { | ||
| 634 | throw SRHTError.decodingError( | ||
| 635 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing updateProject payload")) | ||
| 636 | ) | ||
| 637 | } | ||
| 638 | return payload.project | ||
| 639 | } | ||
| 640 | |||
| 641 | func linkSource(projectID: String, sourceRepoID: String) async throws { | ||
| 642 | try await runLink(field: "linkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID) | ||
| 643 | } | ||
| 644 | |||
| 645 | func unlinkSource(projectID: String, sourceRepoID: String) async throws { | ||
| 646 | try await runLink(field: "unlinkSource", resourceParam: "sourceRepoID", projectID: projectID, resourceID: sourceRepoID) | ||
| 647 | } | ||
| 648 | |||
| 649 | func linkTracker(projectID: String, trackerID: String) async throws { | ||
| 650 | try await runLink(field: "linkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID) | ||
| 651 | } | ||
| 652 | |||
| 653 | func unlinkTracker(projectID: String, trackerID: String) async throws { | ||
| 654 | try await runLink(field: "unlinkTracker", resourceParam: "trackerID", projectID: projectID, resourceID: trackerID) | ||
| 655 | } | ||
| 656 | |||
| 657 | func linkMailingList(projectID: String, listID: String) async throws { | ||
| 658 | try await runLink(field: "linkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID) | ||
| 659 | } | ||
| 660 | |||
| 661 | func unlinkMailingList(projectID: String, listID: String) async throws { | ||
| 662 | try await runLink(field: "unlinkMailingList", resourceParam: "listID", projectID: projectID, resourceID: listID) | ||
| 663 | } | ||
| 664 | |||
| 665 | private func runLink(field: String, resourceParam: String, projectID: String, resourceID: String) async throws { | ||
| 666 | _ = try await client.execute( | ||
| 667 | service: .hub, | ||
| 668 | query: Self.linkMutation(field: field, resourceParam: resourceParam), | ||
| 669 | variables: ["projectID": projectID, "resourceID": resourceID], | ||
| 670 | responseType: LinkMutationResponse.self | ||
| 671 | ) | ||
| 672 | await invalidateProjectCaches() | ||
| 673 | } | ||
| 674 | |||
| 675 | private func invalidateProjectCaches() async { | ||
| 676 | await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.hub.rawValue)) | ||
| 677 | } | ||
| 678 | |||
| 679 | // MARK: - Linkable resource candidates (#15 add flow) | ||
| 680 | |||
| 681 | /// Repositories the user can link (git and hg), sorted by display name. | ||
| 682 | func fetchLinkableSources() async throws -> [LinkableResource] { | ||
| 683 | async let git = fetchRepoCandidates(service: .git) | ||
| 684 | async let hg = fetchRepoCandidates(service: .hg) | ||
| 685 | return dedupeSorted(try await git + (try await hg)) | ||
| 686 | } | ||
| 687 | |||
| 688 | func fetchLinkableTrackers() async throws -> [LinkableResource] { | ||
| 689 | var results: [LinkableResource] = [] | ||
| 690 | var cursor: String? | ||
| 691 | repeat { | ||
| 692 | let response = try await client.execute( | ||
| 693 | service: .todo, | ||
| 694 | query: Self.trackersCandidatesQuery, | ||
| 695 | variables: cursor.map { ["cursor": $0] }, | ||
| 696 | responseType: TrackerCandidatesResponse.self | ||
| 697 | ) | ||
| 698 | results.append(contentsOf: response.trackers.results.map { | ||
| 699 | LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .tracker) | ||
| 700 | }) | ||
| 701 | cursor = response.trackers.cursor | ||
| 702 | } while cursor != nil | ||
| 703 | return dedupeSorted(results) | ||
| 704 | } | ||
| 705 | |||
| 706 | func fetchLinkableMailingLists() async throws -> [LinkableResource] { | ||
| 707 | var results: [LinkableResource] = [] | ||
| 708 | var cursor: String? | ||
| 709 | repeat { | ||
| 710 | let response = try await client.execute( | ||
| 711 | service: .lists, | ||
| 712 | query: Self.listCandidatesQuery, | ||
| 713 | variables: cursor.map { ["cursor": $0] }, | ||
| 714 | responseType: ListCandidatesResponse.self | ||
| 715 | ) | ||
| 716 | results.append(contentsOf: response.subscriptions.results.compactMap(\.list).map { | ||
| 717 | LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .mailingList) | ||
| 718 | }) | ||
| 719 | cursor = response.subscriptions.cursor | ||
| 720 | } while cursor != nil | ||
| 721 | return dedupeSorted(results) | ||
| 722 | } | ||
| 723 | |||
| 724 | private func fetchRepoCandidates(service: SRHTService) async throws -> [LinkableResource] { | ||
| 725 | var results: [LinkableResource] = [] | ||
| 726 | var cursor: String? | ||
| 727 | repeat { | ||
| 728 | let response = try await client.execute( | ||
| 729 | service: service, | ||
| 730 | query: Self.repositoriesCandidatesQuery, | ||
| 731 | variables: cursor.map { ["cursor": $0] }, | ||
| 732 | responseType: RepoCandidatesResponse.self | ||
| 733 | ) | ||
| 734 | results.append(contentsOf: response.repositories.results.map { | ||
| 735 | LinkableResource(rid: $0.rid, name: $0.name, ownerCanonicalName: $0.owner?.canonicalName ?? "", kind: .source) | ||
| 736 | }) | ||
| 737 | cursor = response.repositories.cursor | ||
| 738 | } while cursor != nil | ||
| 739 | return results | ||
| 740 | } | ||
| 741 | |||
| 742 | private func dedupeSorted(_ items: [LinkableResource]) -> [LinkableResource] { | ||
| 743 | var seen = Set<String>() | ||
| 744 | return items | ||
| 745 | .filter { seen.insert($0.rid).inserted } | ||
| 746 | .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } | ||
| 747 | } | ||
| 748 | |||
| 280 | private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] { | 749 | private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] { |
| 281 | var results: [ProjectSummaryPayload] = [] | 750 | var results: [ProjectSummaryPayload] = [] |
| 282 | var cursor: String? | 751 | var cursor: String? |
Hutch/Views/Projects/DiscoverProjectsView.swift added +166
| @@ -0,0 +1,166 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class DiscoverProjectsViewModel { | ||
| 6 | private(set) var projects: [DiscoveredProject] = [] | ||
| 7 | private(set) var isLoading = false | ||
| 8 | private(set) var isLoadingMore = false | ||
| 9 | var error: String? | ||
| 10 | |||
| 11 | private var cursor: String? | ||
| 12 | private var canLoadMore = true | ||
| 13 | private let service: ProjectService | ||
| 14 | |||
| 15 | init(service: ProjectService) { | ||
| 16 | self.service = service | ||
| 17 | } | ||
| 18 | |||
| 19 | func loadInitial() async { | ||
| 20 | guard projects.isEmpty, !isLoading else { return } | ||
| 21 | isLoading = true | ||
| 22 | error = nil | ||
| 23 | defer { isLoading = false } | ||
| 24 | |||
| 25 | do { | ||
| 26 | let page = try await service.fetchPublicProjects(cursor: nil) | ||
| 27 | projects = page.projects | ||
| 28 | cursor = page.cursor | ||
| 29 | canLoadMore = page.cursor != nil | ||
| 30 | } catch { | ||
| 31 | self.error = error.userFacingMessage | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | func reload() async { | ||
| 36 | cursor = nil | ||
| 37 | canLoadMore = true | ||
| 38 | projects = [] | ||
| 39 | await loadInitial() | ||
| 40 | } | ||
| 41 | |||
| 42 | func loadMoreIfNeeded(current item: DiscoveredProject) async { | ||
| 43 | guard canLoadMore, !isLoadingMore, !isLoading else { return } | ||
| 44 | guard let index = projects.firstIndex(of: item), index >= projects.count - 3 else { return } | ||
| 45 | |||
| 46 | isLoadingMore = true | ||
| 47 | defer { isLoadingMore = false } | ||
| 48 | |||
| 49 | do { | ||
| 50 | let page = try await service.fetchPublicProjects(cursor: cursor) | ||
| 51 | let existing = Set(projects.map(\.id)) | ||
| 52 | projects.append(contentsOf: page.projects.filter { !existing.contains($0.id) }) | ||
| 53 | cursor = page.cursor | ||
| 54 | canLoadMore = page.cursor != nil | ||
| 55 | } catch { | ||
| 56 | self.error = error.userFacingMessage | ||
| 57 | } | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | struct DiscoverProjectsView: View { | ||
| 62 | @Environment(AppState.self) private var appState | ||
| 63 | @State private var viewModel: DiscoverProjectsViewModel? | ||
| 64 | |||
| 65 | var body: some View { | ||
| 66 | Group { | ||
| 67 | if let viewModel { | ||
| 68 | content(viewModel) | ||
| 69 | } else { | ||
| 70 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 71 | } | ||
| 72 | } | ||
| 73 | .navigationTitle("Discover") | ||
| 74 | .navigationBarTitleDisplayMode(.inline) | ||
| 75 | .task { | ||
| 76 | if viewModel == nil { | ||
| 77 | let vm = DiscoverProjectsViewModel(service: ProjectService(client: appState.client)) | ||
| 78 | viewModel = vm | ||
| 79 | await vm.loadInitial() | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | @ViewBuilder | ||
| 85 | private func content(_ viewModel: DiscoverProjectsViewModel) -> some View { | ||
| 86 | List { | ||
| 87 | ForEach(viewModel.projects) { discovered in | ||
| 88 | NavigationLink { | ||
| 89 | ProjectDetailView( | ||
| 90 | project: discovered.project, | ||
| 91 | canManage: discovered.ownerCanonicalName == appState.currentUser?.canonicalName | ||
| 92 | ) | ||
| 93 | } label: { | ||
| 94 | DiscoveredProjectRow(discovered: discovered) | ||
| 95 | } | ||
| 96 | .buttonStyle(.plain) | ||
| 97 | .task { | ||
| 98 | await viewModel.loadMoreIfNeeded(current: discovered) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | .themedRow() | ||
| 102 | |||
| 103 | if viewModel.isLoadingMore { | ||
| 104 | HStack { | ||
| 105 | Spacer() | ||
| 106 | ProgressView() | ||
| 107 | Spacer() | ||
| 108 | } | ||
| 109 | .listRowSeparator(.hidden) | ||
| 110 | .themedRow() | ||
| 111 | } | ||
| 112 | } | ||
| 113 | .themedList() | ||
| 114 | .listStyle(.plain) | ||
| 115 | .overlay { | ||
| 116 | if viewModel.isLoading, viewModel.projects.isEmpty { | ||
| 117 | SRHTLoadingStateView(message: "Loading projects…") | ||
| 118 | } else if let error = viewModel.error, viewModel.projects.isEmpty { | ||
| 119 | SRHTErrorStateView( | ||
| 120 | title: "Couldn't Load Projects", | ||
| 121 | message: error, | ||
| 122 | retryAction: { await viewModel.reload() } | ||
| 123 | ) | ||
| 124 | } else if viewModel.projects.isEmpty { | ||
| 125 | ContentUnavailableView( | ||
| 126 | "No Public Projects", | ||
| 127 | systemImage: "sparkle.magnifyingglass", | ||
| 128 | description: Text("Public projects on SourceHut will appear here.") | ||
| 129 | ) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | .srhtErrorBanner( | ||
| 133 | error: Binding( | ||
| 134 | get: { viewModel.error }, | ||
| 135 | set: { viewModel.error = $0 } | ||
| 136 | ) | ||
| 137 | ) | ||
| 138 | .refreshable { | ||
| 139 | await viewModel.reload() | ||
| 140 | } | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | private struct DiscoveredProjectRow: View { | ||
| 145 | let discovered: DiscoveredProject | ||
| 146 | |||
| 147 | private var project: Project { discovered.project } | ||
| 148 | |||
| 149 | var body: some View { | ||
| 150 | VStack(alignment: .leading, spacing: 4) { | ||
| 151 | Text(project.displayName) | ||
| 152 | .font(.headline) | ||
| 153 | .lineLimit(1) | ||
| 154 | Text(discovered.ownerCanonicalName) | ||
| 155 | .font(.caption) | ||
| 156 | .foregroundStyle(.secondary) | ||
| 157 | if let description = project.displayDescription { | ||
| 158 | Text(description) | ||
| 159 | .font(.subheadline) | ||
| 160 | .foregroundStyle(.secondary) | ||
| 161 | .lineLimit(2) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | .padding(.vertical, 2) | ||
| 165 | } | ||
| 166 | } | ||
Hutch/Views/Projects/ManageProjectResourcesView.swift added +303
| @@ -0,0 +1,303 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | @Observable | ||
| 4 | @MainActor | ||
| 5 | final class ManageProjectResourcesViewModel { | ||
| 6 | private(set) var sources: [Project.SourceRepo] | ||
| 7 | private(set) var trackers: [Project.Tracker] | ||
| 8 | private(set) var mailingLists: [Project.MailingList] | ||
| 9 | private(set) var busyID: String? | ||
| 10 | var error: String? | ||
| 11 | |||
| 12 | let projectID: String | ||
| 13 | let projectName: String | ||
| 14 | private let service: ProjectService | ||
| 15 | private let onChange: () async -> Void | ||
| 16 | |||
| 17 | init(project: Project, service: ProjectService, onChange: @escaping () async -> Void) { | ||
| 18 | projectID = project.id | ||
| 19 | projectName = project.displayName | ||
| 20 | sources = project.sources | ||
| 21 | trackers = project.trackers | ||
| 22 | mailingLists = project.mailingLists | ||
| 23 | self.service = service | ||
| 24 | self.onChange = onChange | ||
| 25 | } | ||
| 26 | |||
| 27 | /// rids already linked, so the candidate pickers can hide them. | ||
| 28 | var linkedRIDs: Set<String> { | ||
| 29 | Set(sources.map(\.id) + trackers.map(\.id) + mailingLists.map(\.id)) | ||
| 30 | } | ||
| 31 | |||
| 32 | func unlink(source: Project.SourceRepo) async { | ||
| 33 | await mutate(id: source.id) { | ||
| 34 | try await self.service.unlinkSource(projectID: self.projectID, sourceRepoID: source.id) | ||
| 35 | self.sources.removeAll { $0.id == source.id } | ||
| 36 | } | ||
| 37 | } | ||
| 38 | |||
| 39 | func unlink(tracker: Project.Tracker) async { | ||
| 40 | await mutate(id: tracker.id) { | ||
| 41 | try await self.service.unlinkTracker(projectID: self.projectID, trackerID: tracker.id) | ||
| 42 | self.trackers.removeAll { $0.id == tracker.id } | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | func unlink(mailingList: Project.MailingList) async { | ||
| 47 | await mutate(id: mailingList.id) { | ||
| 48 | try await self.service.unlinkMailingList(projectID: self.projectID, listID: mailingList.id) | ||
| 49 | self.mailingLists.removeAll { $0.id == mailingList.id } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | func link(_ resource: LinkableResource) async { | ||
| 54 | await mutate(id: resource.rid) { | ||
| 55 | switch resource.kind { | ||
| 56 | case .source: | ||
| 57 | try await self.service.linkSource(projectID: self.projectID, sourceRepoID: resource.rid) | ||
| 58 | case .tracker: | ||
| 59 | try await self.service.linkTracker(projectID: self.projectID, trackerID: resource.rid) | ||
| 60 | case .mailingList: | ||
| 61 | try await self.service.linkMailingList(projectID: self.projectID, listID: resource.rid) | ||
| 62 | } | ||
| 63 | try await self.reload() | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | func candidates(for kind: LinkableResource.Kind) async throws -> [LinkableResource] { | ||
| 68 | let all: [LinkableResource] | ||
| 69 | switch kind { | ||
| 70 | case .source: all = try await service.fetchLinkableSources() | ||
| 71 | case .tracker: all = try await service.fetchLinkableTrackers() | ||
| 72 | case .mailingList: all = try await service.fetchLinkableMailingLists() | ||
| 73 | } | ||
| 74 | let linked = linkedRIDs | ||
| 75 | return all.filter { !linked.contains($0.rid) } | ||
| 76 | } | ||
| 77 | |||
| 78 | private func reload() async throws { | ||
| 79 | let project = try await service.fetchProjectDetail(rid: projectID) | ||
| 80 | sources = project.sources | ||
| 81 | trackers = project.trackers | ||
| 82 | mailingLists = project.mailingLists | ||
| 83 | } | ||
| 84 | |||
| 85 | private func mutate(id: String, _ work: @escaping () async throws -> Void) async { | ||
| 86 | guard busyID == nil else { return } | ||
| 87 | busyID = id | ||
| 88 | error = nil | ||
| 89 | defer { busyID = nil } | ||
| 90 | do { | ||
| 91 | try await work() | ||
| 92 | await onChange() | ||
| 93 | } catch { | ||
| 94 | self.error = error.userFacingMessage | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | struct ManageProjectResourcesView: View { | ||
| 100 | let project: Project | ||
| 101 | let onChange: () async -> Void | ||
| 102 | |||
| 103 | @Environment(AppState.self) private var appState | ||
| 104 | @Environment(\.dismiss) private var dismiss | ||
| 105 | @State private var viewModel: ManageProjectResourcesViewModel? | ||
| 106 | @State private var addKind: LinkableResource.Kind? | ||
| 107 | |||
| 108 | var body: some View { | ||
| 109 | NavigationStack { | ||
| 110 | Group { | ||
| 111 | if let viewModel { | ||
| 112 | content(viewModel) | ||
| 113 | } else { | ||
| 114 | SRHTLoadingStateView(message: "Loading…") | ||
| 115 | } | ||
| 116 | } | ||
| 117 | .navigationTitle("Linked Resources") | ||
| 118 | .navigationBarTitleDisplayMode(.inline) | ||
| 119 | .toolbar { | ||
| 120 | ToolbarItem(placement: .confirmationAction) { | ||
| 121 | Button("Done") { dismiss() } | ||
| 122 | } | ||
| 123 | ToolbarItem(placement: .topBarLeading) { | ||
| 124 | Menu { | ||
| 125 | Button("Add Repository") { addKind = .source } | ||
| 126 | Button("Add Tracker") { addKind = .tracker } | ||
| 127 | Button("Add Mailing List") { addKind = .mailingList } | ||
| 128 | } label: { | ||
| 129 | Image(systemName: "plus") | ||
| 130 | } | ||
| 131 | .accessibilityLabel("Add linked resource") | ||
| 132 | } | ||
| 133 | } | ||
| 134 | .sheet(item: Binding(get: { addKind.map { AddKind(kind: $0) } }, set: { addKind = $0?.kind })) { wrapper in | ||
| 135 | if let viewModel { | ||
| 136 | LinkableResourcePicker(kind: wrapper.kind, viewModel: viewModel) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 140 | .task { | ||
| 141 | if viewModel == nil { | ||
| 142 | viewModel = ManageProjectResourcesViewModel( | ||
| 143 | project: project, | ||
| 144 | service: ProjectService(client: appState.client), | ||
| 145 | onChange: onChange | ||
| 146 | ) | ||
| 147 | } | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | @ViewBuilder | ||
| 152 | private func content(_ viewModel: ManageProjectResourcesViewModel) -> some View { | ||
| 153 | List { | ||
| 154 | resourceSection( | ||
| 155 | title: "Repositories", | ||
| 156 | items: viewModel.sources, | ||
| 157 | busyID: viewModel.busyID, | ||
| 158 | label: { "\($0.ownerUsername)/\($0.displayName)" }, | ||
| 159 | onDelete: { await viewModel.unlink(source: $0) } | ||
| 160 | ) | ||
| 161 | resourceSection( | ||
| 162 | title: "Trackers", | ||
| 163 | items: viewModel.trackers, | ||
| 164 | busyID: viewModel.busyID, | ||
| 165 | label: { "\($0.ownerUsername)/\($0.displayName)" }, | ||
| 166 | onDelete: { await viewModel.unlink(tracker: $0) } | ||
| 167 | ) | ||
| 168 | resourceSection( | ||
| 169 | title: "Mailing Lists", | ||
| 170 | items: viewModel.mailingLists, | ||
| 171 | busyID: viewModel.busyID, | ||
| 172 | label: { "\($0.ownerUsername)/\($0.displayName)" }, | ||
| 173 | onDelete: { await viewModel.unlink(mailingList: $0) } | ||
| 174 | ) | ||
| 175 | |||
| 176 | if viewModel.sources.isEmpty, viewModel.trackers.isEmpty, viewModel.mailingLists.isEmpty { | ||
| 177 | Section { | ||
| 178 | Text("No linked resources. Use + to add repositories, trackers, or mailing lists.") | ||
| 179 | .foregroundStyle(.secondary) | ||
| 180 | .themedRow() | ||
| 181 | } | ||
| 182 | } | ||
| 183 | } | ||
| 184 | .themedList() | ||
| 185 | .srhtErrorBanner( | ||
| 186 | error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }) | ||
| 187 | ) | ||
| 188 | } | ||
| 189 | |||
| 190 | @ViewBuilder | ||
| 191 | private func resourceSection<Item: Identifiable>( | ||
| 192 | title: String, | ||
| 193 | items: [Item], | ||
| 194 | busyID: String?, | ||
| 195 | label: @escaping (Item) -> String, | ||
| 196 | onDelete: @escaping (Item) async -> Void | ||
| 197 | ) -> some View where Item.ID == String { | ||
| 198 | if !items.isEmpty { | ||
| 199 | Section(title) { | ||
| 200 | ForEach(items) { item in | ||
| 201 | HStack { | ||
| 202 | Text(label(item)) | ||
| 203 | .font(.body.monospaced()) | ||
| 204 | .lineLimit(1) | ||
| 205 | Spacer() | ||
| 206 | if busyID == item.id { | ||
| 207 | ProgressView().controlSize(.small) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | .themedRow() | ||
| 211 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | ||
| 212 | Button(role: .destructive) { | ||
| 213 | Task { await onDelete(item) } | ||
| 214 | } label: { | ||
| 215 | Label("Unlink", systemImage: "link.badge.minus") | ||
| 216 | } | ||
| 217 | } | ||
| 218 | } | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | } | ||
| 223 | |||
| 224 | private struct AddKind: Identifiable { | ||
| 225 | let kind: LinkableResource.Kind | ||
| 226 | var id: String { | ||
| 227 | switch kind { | ||
| 228 | case .source: "source" | ||
| 229 | case .tracker: "tracker" | ||
| 230 | case .mailingList: "mailingList" | ||
| 231 | } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | private struct LinkableResourcePicker: View { | ||
| 236 | let kind: LinkableResource.Kind | ||
| 237 | let viewModel: ManageProjectResourcesViewModel | ||
| 238 | |||
| 239 | @Environment(\.dismiss) private var dismiss | ||
| 240 | @State private var candidates: [LinkableResource] = [] | ||
| 241 | @State private var isLoading = true | ||
| 242 | @State private var error: String? | ||
| 243 | |||
| 244 | private var title: String { | ||
| 245 | switch kind { | ||
| 246 | case .source: "Add Repository" | ||
| 247 | case .tracker: "Add Tracker" | ||
| 248 | case .mailingList: "Add Mailing List" | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | var body: some View { | ||
| 253 | NavigationStack { | ||
| 254 | Group { | ||
| 255 | if isLoading { | ||
| 256 | SRHTLoadingStateView(message: "Loading…") | ||
| 257 | } else if let error { | ||
| 258 | SRHTErrorStateView(title: "Couldn't Load", message: error, retryAction: { await load() }) | ||
| 259 | } else if candidates.isEmpty { | ||
| 260 | ContentUnavailableView( | ||
| 261 | "Nothing to Add", | ||
| 262 | systemImage: "checkmark.circle", | ||
| 263 | description: Text("There are no more resources of this type to link.") | ||
| 264 | ) | ||
| 265 | } else { | ||
| 266 | List(candidates) { candidate in | ||
| 267 | Button { | ||
| 268 | Task { | ||
| 269 | await viewModel.link(candidate) | ||
| 270 | dismiss() | ||
| 271 | } | ||
| 272 | } label: { | ||
| 273 | Text(candidate.displayName) | ||
| 274 | .font(.body.monospaced()) | ||
| 275 | .lineLimit(1) | ||
| 276 | } | ||
| 277 | .themedRow() | ||
| 278 | } | ||
| 279 | .themedList() | ||
| 280 | } | ||
| 281 | } | ||
| 282 | .navigationTitle(title) | ||
| 283 | .navigationBarTitleDisplayMode(.inline) | ||
| 284 | .toolbar { | ||
| 285 | ToolbarItem(placement: .cancellationAction) { | ||
| 286 | Button("Cancel") { dismiss() } | ||
| 287 | } | ||
| 288 | } | ||
| 289 | } | ||
| 290 | .task { await load() } | ||
| 291 | } | ||
| 292 | |||
| 293 | private func load() async { | ||
| 294 | isLoading = true | ||
| 295 | error = nil | ||
| 296 | do { | ||
| 297 | candidates = try await viewModel.candidates(for: kind) | ||
| 298 | } catch { | ||
| 299 | self.error = error.userFacingMessage | ||
| 300 | } | ||
| 301 | isLoading = false | ||
| 302 | } | ||
| 303 | } | ||
Hutch/Views/Projects/ProjectDetailView.swift +90
| @@ -2,6 +2,7 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct ProjectDetailView: View { | 3 | struct ProjectDetailView: View { |
| 4 | let project: Project | 4 | let project: Project |
| 5 | var canManage: Bool = false | ||
| 5 | 6 | ||
| 6 | @Environment(AppState.self) private var appState | 7 | @Environment(AppState.self) private var appState |
| 7 | @Environment(\.dismiss) private var dismiss | 8 | @Environment(\.dismiss) private var dismiss |
| @@ -10,6 +11,14 @@ struct ProjectDetailView: View { | |||
| 10 | @State private var isLoading = false | 11 | @State private var isLoading = false |
| 11 | @State private var error: String? | 12 | @State private var error: String? |
| 12 | @State private var pinChangeCount = 0 | 13 | @State private var pinChangeCount = 0 |
| 14 | @State private var isPresentingEdit = false | ||
| 15 | @State private var isPresentingManage = false | ||
| 16 | @State private var isSavingEdit = false | ||
| 17 | @State private var editError: String? | ||
| 18 | |||
| 19 | private var projectService: ProjectService { | ||
| 20 | ProjectService(client: appState.client) | ||
| 21 | } | ||
| 13 | 22 | ||
| 14 | private var displayedProject: Project { | 23 | private var displayedProject: Project { |
| 15 | detailProject ?? project | 24 | detailProject ?? project |
| @@ -59,6 +68,46 @@ struct ProjectDetailView: View { | |||
| 59 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") | 68 | .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") |
| 60 | } | 69 | } |
| 61 | } | 70 | } |
| 71 | if canManage { | ||
| 72 | ToolbarItem(placement: .topBarTrailing) { | ||
| 73 | Menu { | ||
| 74 | Button { | ||
| 75 | editError = nil | ||
| 76 | isPresentingEdit = true | ||
| 77 | } label: { | ||
| 78 | Label("Edit Project", systemImage: "pencil") | ||
| 79 | } | ||
| 80 | Button { | ||
| 81 | isPresentingManage = true | ||
| 82 | } label: { | ||
| 83 | Label("Manage Resources", systemImage: "link") | ||
| 84 | } | ||
| 85 | } label: { | ||
| 86 | Image(systemName: "ellipsis.circle") | ||
| 87 | } | ||
| 88 | .accessibilityLabel("Manage project") | ||
| 89 | } | ||
| 90 | } | ||
| 91 | } | ||
| 92 | .sheet(isPresented: $isPresentingEdit) { | ||
| 93 | ProjectFormSheet( | ||
| 94 | title: "Edit Project", | ||
| 95 | confirmationTitle: "Save", | ||
| 96 | isSaving: isSavingEdit, | ||
| 97 | error: editError, | ||
| 98 | includeWebsite: true, | ||
| 99 | initialName: displayedProject.name, | ||
| 100 | initialDescription: displayedProject.description ?? "", | ||
| 101 | initialWebsite: displayedProject.website ?? "", | ||
| 102 | initialTags: displayedProject.tags, | ||
| 103 | initialVisibility: displayedProject.visibility, | ||
| 104 | onSave: { await saveEdits($0) } | ||
| 105 | ) | ||
| 106 | } | ||
| 107 | .sheet(isPresented: $isPresentingManage) { | ||
| 108 | ManageProjectResourcesView(project: displayedProject) { | ||
| 109 | await loadProjectIfNeeded(forceRefresh: true) | ||
| 110 | } | ||
| 62 | } | 111 | } |
| 63 | .task { | 112 | .task { |
| 64 | await loadProjectIfNeeded() | 113 | await loadProjectIfNeeded() |
| @@ -69,6 +118,47 @@ struct ProjectDetailView: View { | |||
| 69 | .srhtErrorBanner(error: $error) | 118 | .srhtErrorBanner(error: $error) |
| 70 | } | 119 | } |
| 71 | 120 | ||
| 121 | private func saveEdits(_ values: ProjectFormValues) async -> Bool { | ||
| 122 | guard !isSavingEdit else { return false } | ||
| 123 | isSavingEdit = true | ||
| 124 | editError = nil | ||
| 125 | defer { isSavingEdit = false } | ||
| 126 | |||
| 127 | do { | ||
| 128 | let updated = try await projectService.updateProject( | ||
| 129 | rid: displayedProject.id, | ||
| 130 | name: values.name, | ||
| 131 | description: values.description, | ||
| 132 | website: values.website, | ||
| 133 | visibility: values.visibility, | ||
| 134 | tags: values.tags | ||
| 135 | ) | ||
| 136 | // Preserve already-loaded linked resources; the mutation returns metadata only. | ||
| 137 | detailProject = Project( | ||
| 138 | metadata: .init( | ||
| 139 | id: updated.id, | ||
| 140 | name: updated.name, | ||
| 141 | description: updated.description, | ||
| 142 | website: updated.website, | ||
| 143 | visibility: updated.visibility, | ||
| 144 | tags: updated.tags, | ||
| 145 | updated: updated.updated | ||
| 146 | ), | ||
| 147 | resources: .init( | ||
| 148 | mailingLists: displayedProject.mailingLists, | ||
| 149 | sources: displayedProject.sources, | ||
| 150 | trackers: displayedProject.trackers, | ||
| 151 | isFullyLoaded: displayedProject.isFullyLoaded | ||
| 152 | ) | ||
| 153 | ) | ||
| 154 | await loadProjectIfNeeded(forceRefresh: true) | ||
| 155 | return true | ||
| 156 | } catch { | ||
| 157 | editError = error.userFacingMessage | ||
| 158 | return false | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 72 | @ViewBuilder | 162 | @ViewBuilder |
| 73 | private var headerSection: some View { | 163 | private var headerSection: some View { |
| 74 | Section { | 164 | Section { |
Hutch/Views/Projects/ProjectFormSheet.swift added +144
| @@ -0,0 +1,144 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Shared create/edit form for hub.sr.ht projects. The parent owns the save | ||
| 4 | /// state and performs the mutation via `onSave`, dismissing on success. | ||
| 5 | struct ProjectFormSheet: View { | ||
| 6 | let title: String | ||
| 7 | let confirmationTitle: String | ||
| 8 | let isSaving: Bool | ||
| 9 | let error: String? | ||
| 10 | /// `createProject` takes no website; only the edit flow shows the field. | ||
| 11 | let includeWebsite: Bool | ||
| 12 | let onSave: (ProjectFormValues) async -> Bool | ||
| 13 | |||
| 14 | @Environment(\.dismiss) private var dismiss | ||
| 15 | @State private var name: String | ||
| 16 | @State private var description: String | ||
| 17 | @State private var website: String | ||
| 18 | @State private var tags: String | ||
| 19 | @State private var visibility: Visibility | ||
| 20 | |||
| 21 | init( | ||
| 22 | title: String, | ||
| 23 | confirmationTitle: String, | ||
| 24 | isSaving: Bool, | ||
| 25 | error: String?, | ||
| 26 | includeWebsite: Bool, | ||
| 27 | initialName: String = "", | ||
| 28 | initialDescription: String = "", | ||
| 29 | initialWebsite: String = "", | ||
| 30 | initialTags: [String] = [], | ||
| 31 | initialVisibility: Visibility = .publicVisibility, | ||
| 32 | onSave: @escaping (ProjectFormValues) async -> Bool | ||
| 33 | ) { | ||
| 34 | self.title = title | ||
| 35 | self.confirmationTitle = confirmationTitle | ||
| 36 | self.isSaving = isSaving | ||
| 37 | self.error = error | ||
| 38 | self.includeWebsite = includeWebsite | ||
| 39 | self.onSave = onSave | ||
| 40 | _name = State(initialValue: initialName) | ||
| 41 | _description = State(initialValue: initialDescription) | ||
| 42 | _website = State(initialValue: initialWebsite) | ||
| 43 | _tags = State(initialValue: initialTags.joined(separator: ", ")) | ||
| 44 | _visibility = State(initialValue: initialVisibility) | ||
| 45 | } | ||
| 46 | |||
| 47 | private var trimmedName: String { | ||
| 48 | name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 49 | } | ||
| 50 | |||
| 51 | var body: some View { | ||
| 52 | NavigationStack { | ||
| 53 | Form { | ||
| 54 | Section("Project Details") { | ||
| 55 | TextField("Project name", text: $name) | ||
| 56 | .textInputAutocapitalization(.never) | ||
| 57 | .autocorrectionDisabled() | ||
| 58 | .themedRow() | ||
| 59 | TextField("Description (optional)", text: $description, axis: .vertical) | ||
| 60 | .lineLimit(2...4) | ||
| 61 | .themedRow() | ||
| 62 | if includeWebsite { | ||
| 63 | TextField("Website (optional)", text: $website) | ||
| 64 | .textInputAutocapitalization(.never) | ||
| 65 | .autocorrectionDisabled() | ||
| 66 | .keyboardType(.URL) | ||
| 67 | .themedRow() | ||
| 68 | } | ||
| 69 | Picker("Visibility", selection: $visibility) { | ||
| 70 | Text("Public").tag(Visibility.publicVisibility) | ||
| 71 | Text("Unlisted").tag(Visibility.unlisted) | ||
| 72 | Text("Private").tag(Visibility.privateVisibility) | ||
| 73 | } | ||
| 74 | .themedRow() | ||
| 75 | } | ||
| 76 | |||
| 77 | Section { | ||
| 78 | TextField("Tags (comma separated)", text: $tags) | ||
| 79 | .textInputAutocapitalization(.never) | ||
| 80 | .autocorrectionDisabled() | ||
| 81 | .themedRow() | ||
| 82 | } footer: { | ||
| 83 | Text("Separate tags with commas.") | ||
| 84 | } | ||
| 85 | |||
| 86 | if let error, !error.isEmpty { | ||
| 87 | Section { | ||
| 88 | Text(error) | ||
| 89 | .foregroundStyle(.red) | ||
| 90 | .themedRow() | ||
| 91 | } | ||
| 92 | } | ||
| 93 | } | ||
| 94 | .themedList() | ||
| 95 | .navigationTitle(title) | ||
| 96 | .navigationBarTitleDisplayMode(.inline) | ||
| 97 | .toolbar { | ||
| 98 | ToolbarItem(placement: .cancellationAction) { | ||
| 99 | Button("Cancel") { dismiss() } | ||
| 100 | } | ||
| 101 | ToolbarItem(placement: .confirmationAction) { | ||
| 102 | Button { | ||
| 103 | Task { | ||
| 104 | let values = ProjectFormValues( | ||
| 105 | name: trimmedName, | ||
| 106 | description: description.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| 107 | website: website.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| 108 | visibility: visibility, | ||
| 109 | tags: Self.parseTags(tags) | ||
| 110 | ) | ||
| 111 | if await onSave(values) { | ||
| 112 | dismiss() | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } label: { | ||
| 116 | if isSaving { | ||
| 117 | ProgressView().controlSize(.small) | ||
| 118 | } else { | ||
| 119 | Text(confirmationTitle) | ||
| 120 | } | ||
| 121 | } | ||
| 122 | .disabled(trimmedName.isEmpty || isSaving) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | static func parseTags(_ raw: String) -> [String] { | ||
| 129 | var seen = Set<String>() | ||
| 130 | return raw | ||
| 131 | .split(whereSeparator: { $0 == "," || $0.isNewline }) | ||
| 132 | .map { $0.trimmingCharacters(in: .whitespaces) } | ||
| 133 | .filter { !$0.isEmpty } | ||
| 134 | .filter { seen.insert($0.lowercased()).inserted } | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | struct ProjectFormValues: Sendable { | ||
| 139 | let name: String | ||
| 140 | let description: String | ||
| 141 | let website: String | ||
| 142 | let visibility: Visibility | ||
| 143 | let tags: [String] | ||
| 144 | } | ||
Hutch/Views/Projects/ProjectsListView.swift +56 −2
| @@ -5,15 +5,38 @@ import SwiftUI | |||
| 5 | final class ProjectsListViewModel { | 5 | final class ProjectsListViewModel { |
| 6 | private(set) var projects: [Project] = [] | 6 | private(set) var projects: [Project] = [] |
| 7 | private(set) var isLoading = false | 7 | private(set) var isLoading = false |
| 8 | private(set) var isSaving = false | ||
| 8 | var error: String? | 9 | var error: String? |
| 10 | var saveError: String? | ||
| 9 | var searchText = "" | 11 | var searchText = "" |
| 10 | 12 | ||
| 11 | private let service: ProjectService | 13 | let service: ProjectService |
| 12 | 14 | ||
| 13 | init(service: ProjectService) { | 15 | init(service: ProjectService) { |
| 14 | self.service = service | 16 | self.service = service |
| 15 | } | 17 | } |
| 16 | 18 | ||
| 19 | func createProject(_ values: ProjectFormValues) async -> Bool { | ||
| 20 | guard !isSaving else { return false } | ||
| 21 | isSaving = true | ||
| 22 | saveError = nil | ||
| 23 | defer { isSaving = false } | ||
| 24 | |||
| 25 | do { | ||
| 26 | _ = try await service.createProject( | ||
| 27 | name: values.name, | ||
| 28 | visibility: values.visibility, | ||
| 29 | description: values.description.isEmpty ? nil : values.description, | ||
| 30 | tags: values.tags | ||
| 31 | ) | ||
| 32 | await loadProjects(forceRefresh: true) | ||
| 33 | return true | ||
| 34 | } catch { | ||
| 35 | saveError = error.userFacingMessage | ||
| 36 | return false | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 17 | var filteredProjects: [Project] { | 40 | var filteredProjects: [Project] { |
| 18 | let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | 41 | let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() |
| 19 | guard !query.isEmpty else { return projects } | 42 | guard !query.isEmpty else { return projects } |
| @@ -46,11 +69,42 @@ final class ProjectsListViewModel { | |||
| 46 | struct ProjectsListView: View { | 69 | struct ProjectsListView: View { |
| 47 | @Environment(AppState.self) private var appState | 70 | @Environment(AppState.self) private var appState |
| 48 | @State private var viewModel: ProjectsListViewModel? | 71 | @State private var viewModel: ProjectsListViewModel? |
| 72 | @State private var isPresentingCreate = false | ||
| 49 | 73 | ||
| 50 | var body: some View { | 74 | var body: some View { |
| 51 | Group { | 75 | Group { |
| 52 | if let viewModel { | 76 | if let viewModel { |
| 53 | content(viewModel) | 77 | content(viewModel) |
| 78 | .toolbar { | ||
| 79 | ToolbarItem(placement: .topBarTrailing) { | ||
| 80 | NavigationLink { | ||
| 81 | DiscoverProjectsView() | ||
| 82 | } label: { | ||
| 83 | Image(systemName: "sparkle.magnifyingglass") | ||
| 84 | } | ||
| 85 | .accessibilityLabel("Discover public projects") | ||
| 86 | } | ||
| 87 | if appState.currentUser != nil { | ||
| 88 | ToolbarItem(placement: .topBarTrailing) { | ||
| 89 | Button { | ||
| 90 | isPresentingCreate = true | ||
| 91 | } label: { | ||
| 92 | Image(systemName: "plus") | ||
| 93 | } | ||
| 94 | .accessibilityLabel("Create project") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | .sheet(isPresented: $isPresentingCreate) { | ||
| 99 | ProjectFormSheet( | ||
| 100 | title: "New Project", | ||
| 101 | confirmationTitle: "Create", | ||
| 102 | isSaving: viewModel.isSaving, | ||
| 103 | error: viewModel.saveError, | ||
| 104 | includeWebsite: false, | ||
| 105 | onSave: { await viewModel.createProject($0) } | ||
| 106 | ) | ||
| 107 | } | ||
| 54 | } else { | 108 | } else { |
| 55 | SRHTLoadingStateView(message: "Loading projects…") | 109 | SRHTLoadingStateView(message: "Loading projects…") |
| 56 | } | 110 | } |
| @@ -70,7 +124,7 @@ struct ProjectsListView: View { | |||
| 70 | List { | 124 | List { |
| 71 | ForEach(viewModel.filteredProjects) { project in | 125 | ForEach(viewModel.filteredProjects) { project in |
| 72 | NavigationLink { | 126 | NavigationLink { |
| 73 | ProjectDetailView(project: project) | 127 | ProjectDetailView(project: project, canManage: true) |
| 74 | } label: { | 128 | } label: { |
| 75 | ProjectListRow(project: project) | 129 | ProjectListRow(project: project) |
| 76 | } | 130 | } |
HutchTests/ProjectFormTests.swift added +39
| @@ -0,0 +1,39 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct ProjectFormTests { | ||
| 6 | @Test | ||
| 7 | func parsesTagsSplittingAndTrimming() { | ||
| 8 | #expect(ProjectFormSheet.parseTags("swift, ios , ") == ["swift", "ios"]) | ||
| 9 | #expect(ProjectFormSheet.parseTags("a,b,c") == ["a", "b", "c"]) | ||
| 10 | } | ||
| 11 | |||
| 12 | @Test | ||
| 13 | func parseTagsDeduplicatesCaseInsensitively() { | ||
| 14 | #expect(ProjectFormSheet.parseTags("Swift, swift, SWIFT") == ["Swift"]) | ||
| 15 | } | ||
| 16 | |||
| 17 | @Test | ||
| 18 | func parseTagsEmptyInputYieldsEmpty() { | ||
| 19 | #expect(ProjectFormSheet.parseTags(" ").isEmpty) | ||
| 20 | #expect(ProjectFormSheet.parseTags("").isEmpty) | ||
| 21 | } | ||
| 22 | |||
| 23 | @Test | ||
| 24 | func linkableResourceDisplayNameCombinesOwnerAndName() { | ||
| 25 | let resource = LinkableResource(rid: "r1", name: "hutch", ownerCanonicalName: "~alice", kind: .source) | ||
| 26 | #expect(resource.displayName == "~alice/hutch") | ||
| 27 | #expect(resource.id == "r1") | ||
| 28 | } | ||
| 29 | |||
| 30 | @Test | ||
| 31 | func discoveredProjectIDMatchesProject() { | ||
| 32 | let project = Project( | ||
| 33 | metadata: .init(id: "p1", name: "Hutch", description: nil, website: nil, visibility: .publicVisibility, tags: [], updated: .now), | ||
| 34 | resources: .init(mailingLists: [], sources: [], trackers: [], isFullyLoaded: false) | ||
| 35 | ) | ||
| 36 | let discovered = DiscoveredProject(project: project, ownerCanonicalName: "~alice") | ||
| 37 | #expect(discovered.id == "p1") | ||
| 38 | } | ||
| 39 | } | ||