mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
366 lines
13 KiB
Swift
366 lines
13 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 {
|
|
/// Empty roots mean unrestricted browsing, matching the runner's spawn policy.
|
|
public static func browseRoots(for machine: Machine) -> [String] {
|
|
unique(
|
|
machine.metadata?.workspaceRoots?
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
?? []
|
|
)
|
|
}
|
|
|
|
public static func defaultDirectory(for machine: Machine) -> String {
|
|
if let root = browseRoots(for: machine).first { return root }
|
|
if let home = machine.metadata?.homeDir,
|
|
!home.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return home.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
return machine.metadata?.platform == "win32" ? "" : "/"
|
|
}
|
|
|
|
/// Expand against the runner's home, never the phone's local filesystem.
|
|
public static func expandHome(_ input: String, homeDirectory: String?) -> String {
|
|
let path = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let home = homeDirectory?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!home.isEmpty else { return path }
|
|
if path == "~" { return home }
|
|
if path.hasPrefix("~/") || (home.contains("\\") && path.hasPrefix("~\\")) {
|
|
return join(parent: home, child: String(path.dropFirst(2)))
|
|
}
|
|
return path
|
|
}
|
|
|
|
public static func filesystemRoot(_ path: String) -> String? {
|
|
if isDriveAbsolute(path) { return String(path.prefix(3)) }
|
|
if path.hasPrefix("\\\\") || path.hasPrefix("//") {
|
|
let parts = path.dropFirst(2).split(whereSeparator: isPathSeparator)
|
|
guard parts.count >= 2 else { return nil }
|
|
let separator = path.hasPrefix("\\") ? "\\" : "/"
|
|
return separator + separator + parts[0] + separator + parts[1]
|
|
}
|
|
return path.hasPrefix("/") ? "/" : nil
|
|
}
|
|
|
|
public static func allowsBrowsing(path: String, roots: [String]) -> Bool {
|
|
filesystemRoot(path) != nil
|
|
&& (roots.isEmpty || roots.contains { isWithinRoot(path: path, root: $0) })
|
|
}
|
|
|
|
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 explicit ``roots``; empty roots allow any absolute path. 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,
|
|
defaultPath: String? = nil
|
|
) {
|
|
close()
|
|
let usableRoots = unique(roots.filter {
|
|
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
})
|
|
let selectedInitialPath = initialPath.flatMap { candidate in
|
|
RemoteDirectoryPath.allowsBrowsing(path: candidate, roots: usableRoots)
|
|
? candidate
|
|
: nil
|
|
}
|
|
let fallback = defaultPath.flatMap {
|
|
RemoteDirectoryPath.allowsBrowsing(path: $0, roots: usableRoots) ? $0 : nil
|
|
}
|
|
guard let path = selectedInitialPath ?? fallback ?? 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,
|
|
RemoteDirectoryPath.allowsBrowsing(path: path, roots: roots)
|
|
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),
|
|
RemoteDirectoryPath.allowsBrowsing(path: parent, roots: roots)
|
|
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,
|
|
RemoteDirectoryPath.allowsBrowsing(path: path, roots: roots)
|
|
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
|
|
RemoteDirectoryPath.allowsBrowsing(path: parent, roots: browseRoots)
|
|
} ?? 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] {
|
|
let breadcrumbRoots = roots.isEmpty ? [RemoteDirectoryPath.filesystemRoot(path)].compactMap { $0 } : roots
|
|
guard let root = breadcrumbRoots
|
|
.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 }
|
|
}
|
|
}
|