Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bfd6eb0
implement File versioning UI + logic (wip)
jullianm Nov 28, 2025
573f74d
update cells SDK version, implement download and restore and asset pr…
jullianm Dec 2, 2025
87fe2b0
adjust asset caching system mechanism for asset version, delete local…
jullianm Dec 3, 2025
e12e01f
add UTs, code doc, lint and format
jullianm Dec 3, 2025
2dbada0
use ByteCountFormatter, update snapshots
jullianm Dec 3, 2025
fc029e8
lint and format
jullianm Dec 3, 2025
9f79b58
Merge branch 'develop' into feat/files-versioning
jullianm Dec 3, 2025
eb63eea
finish merge
jullianm Dec 3, 2025
d4d5823
lint and format
jullianm Dec 3, 2025
7f82e13
lint and format
jullianm Dec 3, 2025
b317af3
Merge branch 'develop' into feat/files-versioning
jullianm Dec 8, 2025
d6afb0f
fix polling timer value, don't allow version history action on files …
jullianm Dec 8, 2025
8d9256a
revert changes related to downloading/opening a node version asset
jullianm Dec 11, 2025
eadb684
Merge branch 'develop' into feat/files-versioning
jullianm Dec 11, 2025
4780b42
lint and format
jullianm Dec 11, 2025
57c5809
add missing @Test prefix
jullianm Dec 11, 2025
a223d57
fix PR comments
jullianm Dec 15, 2025
2ff5a5a
Merge branch 'develop' into feat/files-versioning
jullianm Dec 15, 2025
f0dd813
fix PR comments
jullianm Dec 15, 2025
daacd84
Merge branch 'develop' into feat/files-versioning
jullianm Dec 15, 2025
06854cb
attempt to fix CI snapshots failure
jullianm Dec 15, 2025
27130b0
attempt to fix CI snapshots issue
jullianm Dec 15, 2025
47dde9c
Merge branch 'develop' into feat/files-versioning
jullianm Dec 15, 2025
63ad731
Merge branch 'develop' into feat/files-versioning
jullianm Dec 15, 2025
452ebf2
inject date formatting context
jullianm Dec 15, 2025
78ddd1c
clean up: use injected calendar value
jullianm Dec 15, 2025
0af26ac
Merge branch 'develop' into feat/files-versioning
jullianm Dec 15, 2025
0f37d33
comment out failing snapshot tests, add todo
jullianm Dec 15, 2025
9beec30
add locator value for accessibility identifier
jullianm Dec 16, 2025
b02805b
Merge branch 'develop' into feat/files-versioning
jullianm Dec 16, 2025
7d1a9e3
Merge branch 'develop' into feat/files-versioning
jullianm Dec 16, 2025
eeafed9
Merge branch 'develop' into feat/files-versioning
jullianm Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public extension TimeInterval {
static let fiveMinutes = 5 * oneMinute
static let oneMinute = 60 * oneSecond
static let tenSeconds = 10 * oneSecond
static let thirtySeconds = 30 * oneSecond
static let oneSecond = TimeInterval(1)

/// Number of seconds for a whole year (accounting for leap years) from now.
Expand Down
2 changes: 1 addition & 1 deletion WireMessaging/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
.library(name: "WireMessagingUI", targets: ["WireMessagingUI"])
],
dependencies: [
.package(url: "https://github.com/pydio/cells-sdk-swift.git", from: "0.1.1-alpha15"),
.package(url: "https://github.com/pydio/cells-sdk-swift.git", from: "0.1.1-alpha17"),
.package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4"),
.package(name: "WireFoundation", path: "../WireFoundation"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ public extension WireMessagingFactory {
useCases: .init(
fetchNodes: WireCellsFetchNodesPageUseCase(
configuration: .filesBrowserView,
repository: nodesAPI
repository: nodesAPI,
localAssetRepository: localAssetRepository
),
deleteNodes: WireCellsDeleteNodesUseCase(
repository: nodesAPI,
Expand All @@ -190,6 +191,16 @@ public extension WireMessagingFactory {
updateTags: WireCellsUpdateTagsUseCase(nodesAPI: nodesAPI),
getTagSuggestions: WireCellsGetTagSuggestionsUseCase(nodesAPI: nodesAPI),
createFolder: WireCellsCreateFolderUseCase(nodesRepository: nodesAPI),
fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesAPI),
getAsset: WireCellsGetAssetUseCase(
localAssetRepository: localAssetRepository,
fileCache: fileCache
),
restoreNodeVersion: WireCellsRestoreNodeVersionUseCase(
repository: nodesAPI,
localAssetsRepository: localAssetRepository,
nodeCache: nodeCache
),
),
isCellsStatePending: false,
localAssetRepository: localAssetRepository,
Expand Down Expand Up @@ -229,6 +240,7 @@ public extension WireMessagingFactory {
return viewController
}

@MainActor
func makeConversationCellProvider(
insetsProvider: @escaping () -> ConversationCellInsets
) -> ConversationCellProvider {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// Wire
// Copyright (C) 2025 Wire Swiss GmbH
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see http://www.gnu.org/licenses/.
//

import CellsSDK
import WireMessagingDomain
package import Foundation

package struct WireCellsNodeVersionsNetworkModel: Equatable, Hashable, Sendable {
package let versions: [Version]

package struct Version: Equatable, Hashable, Sendable {
package let contentUrl: URL?
package let contentHash: String?
package let description: String?
package let isDraft: Bool
package let eTag: String?
package let isHead: Bool?
package let mTime: UInt64?
package let ownerName: String?
package let ownerUuid: String?
package let size: UInt64?
package let versionId: UUID
package let downloadUrl: URL?
}
}

extension WireCellsNodeVersionsNetworkModel {
func toDomainModel() -> [WireCellsNodeVersion] {
versions.map {
WireCellsNodeVersion(
id: $0.versionId,
ownerName: $0.ownerName,
modified: $0.mTime.map { Date(timeIntervalSince1970: Double($0)) },
eTag: $0.eTag,
size: $0.size,
downloadUrl: $0.downloadUrl
)
}
}
}

package extension RestVersionCollection {
func toDTO() -> WireCellsNodeVersionsNetworkModel? {
guard let versions else { return nil }

let dtoVersions = versions.compactMap { value -> WireCellsNodeVersionsNetworkModel.Version? in
guard let id = UUID(uuidString: value.versionId) else { return nil }

return WireCellsNodeVersionsNetworkModel.Version(
contentUrl: value.preSignedGET?.url.flatMap(URL.init(string:)),
contentHash: value.contentHash,
description: value.description,
isDraft: value.draft ?? false,
eTag: value.eTag,
isHead: value.isHead,
mTime: value.mTime.flatMap(UInt64.init),
ownerName: value.ownerName,
ownerUuid: value.ownerUuid,
size: value.size.flatMap(UInt64.init),
versionId: id,
downloadUrl: value.preSignedGET?.url.flatMap(URL.init(string:))
)
}

return WireCellsNodeVersionsNetworkModel(
versions: dtoVersions
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ package final actor NodesAPI: NodesAPIProtocol, WireCellsNodesRepositoryProtocol
try await restAPI.deleteVersion(uuid: nodeID, versionID: versionID)
}

package func getVersions(nodeID: UUID) async throws -> [WireCellsNodeVersion] {
let versionsDTO = try await restAPI.getVersions(uuid: nodeID)
return versionsDTO.toDomainModel()
}

package func restoreVersion(nodeID: UUID, versionID: UUID) async throws {
try await restAPI.restoreVersion(uuid: nodeID, versionID: versionID)
}

package func downloadFile(
out: URL,
cellPath: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import WireMessagingDomain

enum WireCellsNodesAPIError: Error {
case failedToDecodeNode
case failedToDecodeNodeVersions
case missingData(String)
}

Expand Down Expand Up @@ -182,6 +183,39 @@ final class RestAPI: Sendable {
)
}

func getVersions(uuid: UUID) async throws -> WireCellsNodeVersionsNetworkModel {
let query = RestNodeVersionsFilter(
filterBy: .versionsAll,
flags: [.withPreSignedURLs],
limit: nil,
offset: nil,
sortDirDesc: true,
sortField: nil
)

let response = try await NodeServiceAPI.nodeVersions(
uuid: uuid.transportString(),
query: query,
apiConfiguration: makeConfiguration()
)

guard let dto = response.toDTO() else {
throw WireCellsNodesAPIError.failedToDecodeNodeVersions
}

return dto
}

func restoreVersion(uuid: UUID, versionID: UUID) async throws {
let parameters = RestPromoteParameters(publish: false)
_ = try await NodeServiceAPI.promoteVersion(
uuid: uuid.transportString(),
versionId: versionID.transportString(),
parameters: parameters,
apiConfiguration: makeConfiguration()
)
}

/// Creates a new folder at the specified path.
///
/// - Parameters:
Expand Down Expand Up @@ -374,7 +408,7 @@ private extension WireCellsGetNodesRequest {

var lookupRequest: RestLookupRequest {
var request = RestLookupRequest(
flags: [.withPreSignedURLs],
flags: [.withPreSignedURLs, .withEditorURLs],
limit: "\(limit)",
offset: "\(offset)",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ package import WireMessagingDomain
/// This repository acts on the `@MainActor` to allow for non async main thread access of assets from the UI.
package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepositoryProtocol {

enum Failure: Error {
case nodeVersionNotFound
}

private typealias GetNodeTask = Task<WireCellsNode, any Error>
private typealias GetNodeVersionTask = Task<[WireCellsNodeVersion], any Error>
typealias NodeID = UUID

private let nodesAPI: any NodesAPIProtocol
Expand All @@ -34,7 +39,7 @@ package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepository
private let store: any WireCellsLocalAssetStoreProtocol

@MainActor private var getNodeTasks: [NodeID: GetNodeTask] = [:]

@MainActor private var getNodeVersionTasks: [NodeID: GetNodeVersionTask] = [:]
@MainActor private var downloadTasks: [NodeID: Task<Void, any Error>] = [:]

package convenience init(
Expand Down Expand Up @@ -77,22 +82,29 @@ package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepository
}

/// Downloads the asset for the given `nodeID`.
/// - Parameters
/// - source: The source the asset is coming from, whether from a `node` itself or a `nodeVersion` of that node.
///
/// This method first refreshes the assets metadata - see `refreshMetadata(nodeID:)`.
/// The download can be observed via the `observeAsset(nodeID:)` method.
@MainActor
package func downloadAsset(nodeID: UUID) async throws {
if let existingTask = downloadTasks[nodeID] {
package func downloadAsset(source: AssetSource) async throws {
if let existingTask = downloadTasks[source.id] {
try await existingTask.value
} else {
defer { downloadTasks[nodeID] = nil }
defer { downloadTasks[source.id] = nil }

let task = Task { try await _downloadAsset(nodeID: nodeID) }
downloadTasks[nodeID] = task
let task = Task { try await _downloadAsset(source: source) }
downloadTasks[source.id] = task
try await task.value
}
}

@MainActor
package func deleteAssets(nodeIDs: [UUID]) async throws {
try await store.deleteAssets(nodeIDs: nodeIDs)
}

/// Observes the asset for the given `nodeID`. A value of `nil` is emitted if the asset has never been fetched.
@MainActor
package func observeAsset(nodeID: UUID) -> AnyPublisher<WireMessagingDomain.WireCellsLocalAsset?, Never> {
Expand All @@ -108,40 +120,73 @@ package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepository
// MARK: - Private

@MainActor
private func _downloadAsset(nodeID: UUID) async throws {
private func _downloadAsset(source: AssetSource) async throws {
do {
let node = try await getNode(nodeID: nodeID)
let (downloadURL, eTag) = try node.downloadInfo

let (downloadURL, eTag): (URL, String)
let id: UUID
let path: String
let mimeType: String?
let size: UInt64?

switch source {
case let .node(nodeID):
let node = try await getNode(nodeID: nodeID)
(downloadURL, eTag) = try node.downloadInfo
id = nodeID
path = node.path
mimeType = node.mimeType
size = node.size
case let .nodeVersion(nodeID, versionID):
let nodeVersions = try await getNodeVersions(nodeID: nodeID, versionID: versionID)
guard let nodeVersion = nodeVersions.first(where: { $0.id == versionID }) else {
throw Failure.nodeVersionNotFound
}
(downloadURL, eTag) = try nodeVersion.downloadInfo
id = versionID

// API response doesn't provide a path, set it manually to get a valid cache key and a readable asset.
// See `WireCellsLocalAsset+CacheKey`
if let url = URL(string: downloadURL.absoluteString),
let range = url.path.range(of: "/data/") {
let result = String(url.path[range.upperBound...])
path = result
} else {
path = ""
}
mimeType = nil
size = nodeVersion.size
}

try store.upsertAsset(
WireCellsLocalAsset(
nodeID: nodeID,
nodeID: id,
eTag: eTag,
path: node.path,
contentType: node.mimeType,
size: node.size,
path: path,
contentType: mimeType,
size: size,
downloadState: .pending
)
)

let (progress, download) = fileDownloader.download(from: downloadURL)
for await progress in progress {
var asset = try verifyAsset(nodeID: nodeID, eTag: eTag)
var asset = try verifyAsset(nodeID: source.id, eTag: eTag)
asset.downloadState = .downloading(progress: progress)
try store.upsertAsset(asset)
}

let (tempURL, _) = try await download.value

var asset = try verifyAsset(nodeID: nodeID, eTag: eTag)
var asset = try verifyAsset(nodeID: source.id, eTag: eTag)
try await fileCache.saveFile(at: tempURL, key: asset.cacheKey)

asset = try verifyAsset(nodeID: nodeID, eTag: eTag)
asset = try verifyAsset(nodeID: source.id, eTag: eTag)
asset.downloadState = .downloaded(cacheKey: asset.cacheKey)
try store.upsertAsset(asset)
} catch {
// We don't care about the eTag when setting download state to failed.
if var asset = try store.asset(nodeID: nodeID) {
if var asset = try store.asset(nodeID: source.id) {
asset.downloadState = .failed(error: error)
try store.upsertAsset(asset)
}
Expand Down Expand Up @@ -208,6 +253,19 @@ package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepository
}
}

@MainActor
private func getNodeVersions(nodeID: UUID, versionID: UUID) async throws -> [WireCellsNodeVersion] {
if let task = getNodeVersionTasks[versionID] {
return try await task.value
} else {
defer { getNodeVersionTasks[versionID] = nil }

let task = Task { try await nodesAPI.getVersions(nodeID: nodeID) }
getNodeVersionTasks[versionID] = task
return try await task.value
}
}

}

// MARK: - Helpers
Expand All @@ -225,6 +283,17 @@ private extension WireCellsNode {

}

private extension WireCellsNodeVersion {
var downloadInfo: (URL: URL, eTag: String) {
get throws {
typealias Error = WireCellsLocalAssetRepositoryError
guard let downloadUrl else { throw Error.missingDownloadURL }
guard let eTag else { throw Error.missingETag }
return (downloadUrl, eTag)
}
}
}

private extension WireCellsLocalAsset {

var cacheKey: String {
Expand Down
Loading
Loading