117 lines
4.1 KiB
Swift
117 lines
4.1 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
|
|
@MainActor
|
|
public protocol RemotePathDisplaying {
|
|
var homeResolution: RemoteHomeResolutionState { get }
|
|
}
|
|
|
|
extension RemotePathDisplaying {
|
|
public func displayPath(_ path: String) -> String {
|
|
RemotePath.displayed(path, home: homeResolution)
|
|
}
|
|
}
|
|
|
|
public nonisolated enum RemotePath {
|
|
public struct Component: Identifiable, Equatable, Sendable {
|
|
public var name: String
|
|
public var path: String
|
|
public var id: String { path }
|
|
}
|
|
|
|
public static func normalized(_ path: String) -> String {
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return "/" }
|
|
var components: [Substring] = []
|
|
for component in trimmed.split(separator: "/") {
|
|
switch component {
|
|
case ".": continue
|
|
case "..": if !components.isEmpty { components.removeLast() }
|
|
default: components.append(component)
|
|
}
|
|
}
|
|
return "/" + components.joined(separator: "/")
|
|
}
|
|
|
|
public static func join(_ base: String, _ name: String) -> String {
|
|
let base = normalized(base)
|
|
return base == "/" ? "/\(name)" : "\(base)/\(name)"
|
|
}
|
|
|
|
public static func parent(of path: String) -> String {
|
|
let value = normalized(path)
|
|
guard value != "/" else { return "/" }
|
|
let parent = (value as NSString).deletingLastPathComponent
|
|
return parent.isEmpty ? "/" : parent
|
|
}
|
|
|
|
public static func components(of path: String) -> [Component] {
|
|
let value = normalized(path)
|
|
var result = [Component(name: "/", path: "/")]
|
|
guard value != "/" else { return result }
|
|
var current = ""
|
|
for name in value.split(separator: "/").map(String.init) {
|
|
current += "/" + name
|
|
result.append(Component(name: name, path: current))
|
|
}
|
|
return result
|
|
}
|
|
|
|
public static func displayed(_ path: String, home: RemoteHomeResolutionState) -> String {
|
|
let value = normalized(path)
|
|
guard case .resolved(let proposedHome) = home else { return value }
|
|
let home = normalized(proposedHome)
|
|
if value == home { return "~" }
|
|
if home == "/" { return "~" + value }
|
|
guard value.hasPrefix(home + "/") else { return value }
|
|
return "~" + value.dropFirst(home.count)
|
|
}
|
|
|
|
public static func displayedComponents(
|
|
of path: String, home: RemoteHomeResolutionState
|
|
) -> [Component] {
|
|
let value = normalized(path)
|
|
guard case .resolved(let proposedHome) = home else { return components(of: value) }
|
|
let home = normalized(proposedHome)
|
|
guard value == home || home == "/" || value.hasPrefix(home + "/") else {
|
|
return components(of: value)
|
|
}
|
|
var result = [Component(name: "~", path: home)]
|
|
guard value != home else { return result }
|
|
var current = home
|
|
let suffix = home == "/" ? value.dropFirst() : value.dropFirst(home.count + 1)
|
|
for name in suffix.split(separator: "/").map(String.init) {
|
|
current = join(current, name)
|
|
result.append(Component(name: name, path: current))
|
|
}
|
|
return result
|
|
}
|
|
|
|
public static func keepBothCandidate(for path: String, index: Int) -> String {
|
|
let ns = path as NSString
|
|
let parent = ns.deletingLastPathComponent
|
|
let name = ns.lastPathComponent as NSString
|
|
let ext = name.pathExtension
|
|
let stem = name.deletingPathExtension
|
|
let candidate = ext.isEmpty ? "\(stem).\(index)" : "\(stem).\(index).\(ext)"
|
|
return parent == "/" ? "/\(candidate)" : "\(parent)/\(candidate)"
|
|
}
|
|
}
|
|
|
|
public nonisolated enum RemoteHomeResolutionState: Hashable, Sendable {
|
|
case pending
|
|
case resolved(String)
|
|
case unavailable
|
|
|
|
public var resolvedDirectory: String? {
|
|
guard case .resolved(let path) = self else { return nil }
|
|
return RemotePath.normalized(path)
|
|
}
|
|
|
|
public var isPending: Bool {
|
|
if case .pending = self { true } else { false }
|
|
}
|
|
}
|