Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/DirectoryBrowser/RemoteDirectoryBrowserModel.swift
T

323 lines
11 KiB
Swift

import Foundation
import HapiProtocol
import Observation
public protocol MachineDirectoryRequesting: Sendable {
func listMachineDirectory(
machineId: String,
path: String,
includeHidden: Bool
) async throws -> MachineListDirectoryResponse
}
extension APIClient: MachineDirectoryRequesting {}
public struct RemoteDirectoryBreadcrumb: Identifiable, Equatable, Sendable {
public let label: String
public let path: String
public var id: String { path }
public init(label: String, path: String) {
self.label = label
self.path = path
}
}
/** Pure remote-path operations shared by directory-browser consumers. */
public enum RemoteDirectoryPath {
public static func browseRoots(for machine: Machine) -> [String] {
let workspaceRoots = unique(
machine.metadata?.workspaceRoots?
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
?? []
)
if !workspaceRoots.isEmpty { return workspaceRoots }
if let home = machine.metadata?.homeDir,
!home.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return [home]
}
return []
}
public static func join(parent: String, child: String) -> String {
let separator = parent.contains("\\") && !parent.contains("/") ? "\\" : "/"
return parent.hasSuffix("/") || parent.hasSuffix("\\")
? parent + child
: parent + separator + child
}
public static func parent(_ path: String) -> String? {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != "/", !isDriveRoot(trimmed) else { return nil }
var characters = Array(trimmed)
while characters.count > 1, let last = characters.last, isPathSeparator(last) {
if characters.count == 3, characters[1] == ":" { break }
characters.removeLast()
}
let withoutTrailing = String(characters)
let isUNC = withoutTrailing.hasPrefix("\\\\") || withoutTrailing.hasPrefix("//")
if isUNC {
let components = String(withoutTrailing.dropFirst(2))
.split(whereSeparator: isPathSeparator)
if components.count <= 2 { return nil }
}
guard let separatorIndex = characters.lastIndex(where: isPathSeparator) else { return nil }
if separatorIndex == 0 { return String(characters.prefix(1)) }
if separatorIndex == 2, characters.count >= 2, characters[1] == ":" {
return String(characters.prefix(3))
}
return String(characters[..<separatorIndex])
}
/** Lexical UI boundary; the runner remains authoritative and resolves symlinks. */
public static func isWithinRoot(path: String, root: String) -> Bool {
func normalize(_ value: String) -> String {
var slashed = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\", with: "/")
while slashed.count > 1, slashed.hasSuffix("/"), !isDriveRoot(slashed) {
slashed.removeLast()
}
return slashed
}
var normalizedPath = normalize(path)
var normalizedRoot = normalize(root)
guard !normalizedPath.isEmpty, !normalizedRoot.isEmpty else { return false }
let caseInsensitive = isDriveAbsolute(normalizedRoot) || normalizedRoot.hasPrefix("//")
if caseInsensitive {
normalizedPath = normalizedPath.lowercased()
normalizedRoot = normalizedRoot.lowercased()
}
let rootPrefix = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/"
return normalizedPath == normalizedRoot || normalizedPath.hasPrefix(rootPrefix)
}
private static func unique(_ paths: [String]) -> [String] {
var seen: Set<String> = []
return paths.filter { seen.insert($0).inserted }
}
private static func isPathSeparator(_ character: Character) -> Bool {
character == "/" || character == "\\"
}
private static func isDriveRoot(_ value: String) -> Bool {
let characters = Array(value)
return characters.count == 3
&& characters[0].isLetter
&& characters[1] == ":"
&& isPathSeparator(characters[2])
}
private static func isDriveAbsolute(_ value: String) -> Bool {
let characters = Array(value)
return characters.count >= 3
&& characters[0].isLetter
&& characters[1] == ":"
&& isPathSeparator(characters[2])
}
}
/**
* Reusable runner-backed directory navigation state machine.
*
* Consumers own presentation and selection handling. Navigation is lexically
* confined to ``roots``; the runner performs canonical symlink-aware checks.
*/
@MainActor @Observable
public final class RemoteDirectoryBrowserModel {
public private(set) var isPresented = false
public private(set) var path = ""
public private(set) var roots: [String] = []
public private(set) var breadcrumbs: [RemoteDirectoryBreadcrumb] = []
public private(set) var entries: [MachineDirectoryEntry] = []
public private(set) var isLoading = false
public private(set) var error: String?
public private(set) var includeHidden = false
public private(set) var canGoUp = false
private let requester: any MachineDirectoryRequesting
private let fallbackError: String
private var machineId: String?
@ObservationIgnored private var loadTask: Task<Void, Never>?
@ObservationIgnored private var requestVersion = 0
public init(
requester: any MachineDirectoryRequesting,
fallbackError: String = "Failed to browse directories"
) {
self.requester = requester
self.fallbackError = fallbackError
}
public func open(machineId: String, roots: [String], initialPath: String? = nil) {
close()
let usableRoots = unique(roots.filter {
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
})
let selectedInitialPath = initialPath.flatMap { candidate in
usableRoots.contains { RemoteDirectoryPath.isWithinRoot(path: candidate, root: $0) }
? candidate
: nil
}
guard let path = selectedInitialPath ?? usableRoots.first else { return }
self.machineId = machineId
self.isPresented = true
self.path = path
self.roots = usableRoots
load(path)
}
public func close() {
loadTask?.cancel()
requestVersion += 1
machineId = nil
isPresented = false
path = ""
roots = []
breadcrumbs = []
entries = []
isLoading = false
error = nil
includeHidden = false
canGoUp = false
}
public func navigate(to path: String) {
guard isPresented,
roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: path, root: $0) })
else {
return
}
load(path)
}
public func navigateEntry(_ name: String) {
navigate(to: RemoteDirectoryPath.join(parent: path, child: name))
}
public func navigateUp() {
guard let parent = RemoteDirectoryPath.parent(path),
roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: parent, root: $0) })
else {
return
}
navigate(to: parent)
}
public func refresh() {
load(path)
}
public func setIncludeHidden(_ includeHidden: Bool) {
guard isPresented else { return }
self.includeHidden = includeHidden
load(path)
}
private func load(_ path: String) {
guard let machineId,
isPresented,
roots.contains(where: { RemoteDirectoryPath.isWithinRoot(path: path, root: $0) })
else {
return
}
loadTask?.cancel()
requestVersion += 1
let currentRequest = requestVersion
let requestedIncludeHidden = includeHidden
let browseRoots = roots
self.path = path
entries = []
isLoading = true
error = nil
breadcrumbs = makeBreadcrumbs(path: path, roots: browseRoots)
canGoUp = RemoteDirectoryPath.parent(path).map { parent in
browseRoots.contains { RemoteDirectoryPath.isWithinRoot(path: parent, root: $0) }
} ?? false
loadTask = Task { [weak self] in
guard let self else { return }
let response: MachineListDirectoryResponse
do {
response = try await self.requester.listMachineDirectory(
machineId: machineId,
path: path,
includeHidden: requestedIncludeHidden
)
} catch is CancellationError {
return
} catch {
guard self.isCurrent(
currentRequest,
machineId: machineId,
path: path,
includeHidden: requestedIncludeHidden
) else {
return
}
self.isLoading = false
self.error = (error as? LocalizedError)?.errorDescription ?? self.fallbackError
return
}
guard self.isCurrent(
currentRequest,
machineId: machineId,
path: path,
includeHidden: requestedIncludeHidden
) else {
return
}
guard response.success else {
self.isLoading = false
self.error = response.error ?? self.fallbackError
return
}
self.isLoading = false
self.entries = (response.entries ?? [])
.filter { $0.type == .directory }
.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
}
}
private func isCurrent(
_ request: Int,
machineId: String,
path: String,
includeHidden: Bool
) -> Bool {
requestVersion == request
&& self.machineId == machineId
&& isPresented
&& self.path == path
&& self.includeHidden == includeHidden
}
private func makeBreadcrumbs(path: String, roots: [String]) -> [RemoteDirectoryBreadcrumb] {
guard let root = roots
.filter({ RemoteDirectoryPath.isWithinRoot(path: path, root: $0) })
.max(by: { $0.count < $1.count })
else {
return [RemoteDirectoryBreadcrumb(label: path, path: path)]
}
var result = [RemoteDirectoryBreadcrumb(label: root, path: root)]
let relative = String(path.dropFirst(root.count))
.trimmingCharacters(in: CharacterSet(charactersIn: "/\\"))
var cursor = root
for segment in relative.split(whereSeparator: { $0 == "/" || $0 == "\\" }) {
cursor = RemoteDirectoryPath.join(parent: cursor, child: String(segment))
result.append(RemoteDirectoryBreadcrumb(label: String(segment), path: cursor))
}
return result
}
private func unique(_ values: [String]) -> [String] {
var seen: Set<String> = []
return values.filter { seen.insert($0).inserted }
}
}