Files

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 }
}
}