loadChildren only checked cancellation after provider.listDirectory, so a root reload triggered during an SSH provider swap left the cancelled local load free to still call listDirectory, now through the freshly swapped SSH transport, listing the old local path. Bail at the top of loadChildren when the task is already cancelled, before any listing. Co-authored-by: ejc3 <[email protected]>
1289 lines
45 KiB
Swift
1289 lines
45 KiB
Swift
import CmuxFoundation
|
|
import AppKit
|
|
import Combine
|
|
import Foundation
|
|
import QuartzCore
|
|
import SwiftUI
|
|
|
|
// MARK: - Explorer Visual Style
|
|
|
|
enum FileExplorerStyle: Int, CaseIterable {
|
|
case liquidGlass = 0
|
|
case highDensity = 1
|
|
case terminalStealth = 2
|
|
case proStudio = 3
|
|
case finder = 4
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .liquidGlass: return "Liquid Glass"
|
|
case .highDensity: return "High-Density IDE"
|
|
case .terminalStealth: return "Terminal Stealth"
|
|
case .proStudio: return "Pro Studio"
|
|
case .finder: return "Finder"
|
|
}
|
|
}
|
|
|
|
var rowHeight: CGFloat {
|
|
let baseHeight: CGFloat
|
|
switch self {
|
|
case .liquidGlass: baseHeight = 28
|
|
case .highDensity: baseHeight = 20
|
|
case .terminalStealth: baseHeight = 24
|
|
case .proStudio: baseHeight = 32
|
|
case .finder: baseHeight = 26
|
|
}
|
|
return GlobalFontMagnification.scaledSize(baseHeight)
|
|
}
|
|
|
|
var indentation: CGFloat {
|
|
switch self {
|
|
case .liquidGlass: return 16
|
|
case .highDensity: return 12
|
|
case .terminalStealth: return 14
|
|
case .proStudio: return 20
|
|
case .finder: return 18
|
|
}
|
|
}
|
|
|
|
var iconSize: CGFloat {
|
|
switch self {
|
|
case .liquidGlass: return 16
|
|
case .highDensity: return 14
|
|
case .terminalStealth: return 12
|
|
case .proStudio: return 18
|
|
case .finder: return 18
|
|
}
|
|
}
|
|
|
|
var iconWeight: NSFont.Weight {
|
|
switch self {
|
|
case .liquidGlass: return .regular
|
|
case .highDensity: return .regular
|
|
case .terminalStealth: return .light
|
|
case .proStudio: return .regular
|
|
case .finder: return .medium
|
|
}
|
|
}
|
|
|
|
var nameFont: NSFont {
|
|
switch self {
|
|
case .liquidGlass: return GlobalFontMagnification.systemFont(ofSize: 13, weight: .medium)
|
|
case .highDensity: return GlobalFontMagnification.systemFont(ofSize: 11, weight: .regular)
|
|
case .terminalStealth: return GlobalFontMagnification.monospacedSystemFont(ofSize: 12, weight: .regular)
|
|
case .proStudio: return GlobalFontMagnification.systemFont(ofSize: 14, weight: .semibold)
|
|
case .finder: return GlobalFontMagnification.systemFont(ofSize: 13, weight: .regular)
|
|
}
|
|
}
|
|
|
|
var iconToTextSpacing: CGFloat {
|
|
switch self {
|
|
case .liquidGlass: return 8
|
|
case .highDensity: return 4
|
|
case .terminalStealth: return 6
|
|
case .proStudio: return 12
|
|
case .finder: return 6
|
|
}
|
|
}
|
|
|
|
var selectionInset: CGFloat {
|
|
switch self {
|
|
case .liquidGlass: return 8
|
|
case .highDensity: return 0
|
|
case .terminalStealth: return 0
|
|
case .proStudio: return 4
|
|
case .finder: return 4
|
|
}
|
|
}
|
|
|
|
var selectionRadius: CGFloat {
|
|
switch self {
|
|
case .liquidGlass: return 6
|
|
case .highDensity: return 0
|
|
case .terminalStealth: return 0
|
|
case .proStudio: return 8
|
|
case .finder: return 5
|
|
}
|
|
}
|
|
|
|
var selectionColor: NSColor {
|
|
switch self {
|
|
case .liquidGlass: return .controlAccentColor.withAlphaComponent(0.15)
|
|
case .highDensity: return .selectedContentBackgroundColor
|
|
case .terminalStealth: return .controlAccentColor
|
|
case .proStudio: return .controlAccentColor
|
|
case .finder: return .controlAccentColor.withAlphaComponent(0.15)
|
|
}
|
|
}
|
|
|
|
var hoverColor: NSColor {
|
|
switch self {
|
|
case .liquidGlass: return .labelColor.withAlphaComponent(0.05)
|
|
case .highDensity: return .white.withAlphaComponent(0.05)
|
|
case .terminalStealth: return .white.withAlphaComponent(0.03)
|
|
case .proStudio: return .white.withAlphaComponent(0.1)
|
|
case .finder: return .labelColor.withAlphaComponent(0.04)
|
|
}
|
|
}
|
|
|
|
var usesBorderSelection: Bool {
|
|
self == .terminalStealth
|
|
}
|
|
|
|
var fileIconTint: NSColor {
|
|
palette.fileIconTint
|
|
}
|
|
|
|
var folderIconTint: NSColor {
|
|
palette.folderIconTint
|
|
}
|
|
|
|
func gitColor(for status: GitFileStatus) -> NSColor {
|
|
palette.gitColor(for: status)
|
|
}
|
|
|
|
private var palette: FileExplorerPalette {
|
|
switch self {
|
|
case .liquidGlass: .liquidGlass
|
|
case .highDensity: .highDensity
|
|
case .terminalStealth: .terminalStealth
|
|
case .proStudio: .proStudio
|
|
case .finder: .finder
|
|
}
|
|
}
|
|
|
|
static var current: FileExplorerStyle {
|
|
let defaults = UserDefaults.standard
|
|
if defaults.object(forKey: "fileExplorer.style") == nil {
|
|
return .highDensity
|
|
}
|
|
return FileExplorerStyle(rawValue: defaults.integer(forKey: "fileExplorer.style")) ?? .highDensity
|
|
}
|
|
}
|
|
|
|
// MARK: - Models
|
|
|
|
struct FileExplorerEntry: Sendable {
|
|
let name: String
|
|
let path: String
|
|
let isDirectory: Bool
|
|
}
|
|
|
|
final class FileExplorerNode: Identifiable {
|
|
let id: String
|
|
let name: String
|
|
let path: String
|
|
let isDirectory: Bool
|
|
var children: [FileExplorerNode]?
|
|
var isLoading: Bool = false
|
|
var error: String?
|
|
|
|
init(name: String, path: String, isDirectory: Bool) {
|
|
self.id = path
|
|
self.name = name
|
|
self.path = path
|
|
self.isDirectory = isDirectory
|
|
}
|
|
|
|
var isExpandable: Bool { isDirectory }
|
|
|
|
var sortedChildren: [FileExplorerNode]? {
|
|
children?.sorted { a, b in
|
|
if a.isDirectory != b.isDirectory { return a.isDirectory }
|
|
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Root Resolver
|
|
|
|
enum FileExplorerRootResolver {
|
|
static func displayPath(for fullPath: String, homePath: String?) -> String {
|
|
guard let home = homePath, !home.isEmpty else { return fullPath }
|
|
let normalizedHome = home.hasSuffix("/") ? String(home.dropLast()) : home
|
|
let normalizedPath = fullPath.hasSuffix("/") ? String(fullPath.dropLast()) : fullPath
|
|
if normalizedPath == normalizedHome {
|
|
return "~"
|
|
}
|
|
let homePrefix = normalizedHome + "/"
|
|
if normalizedPath.hasPrefix(homePrefix) {
|
|
return "~/" + normalizedPath.dropFirst(homePrefix.count)
|
|
}
|
|
return fullPath
|
|
}
|
|
}
|
|
|
|
// MARK: - Provider Protocol
|
|
|
|
protocol FileExplorerProvider: AnyObject {
|
|
func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry]
|
|
var homePath: String { get }
|
|
var isAvailable: Bool { get }
|
|
}
|
|
|
|
struct SSHFileExplorerConnection: Equatable, Sendable {
|
|
let destination: String
|
|
let port: Int?
|
|
let identityFile: String?
|
|
let sshOptions: [String]
|
|
}
|
|
|
|
protocol SSHFileExplorerTransport: AnyObject {
|
|
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String
|
|
nonisolated func listDirectory(
|
|
path: String,
|
|
connection: SSHFileExplorerConnection,
|
|
showHidden: Bool
|
|
) async throws -> [FileExplorerEntry]
|
|
nonisolated func downloadFile(
|
|
path: String,
|
|
connection: SSHFileExplorerConnection,
|
|
to localURL: URL
|
|
) async throws
|
|
}
|
|
|
|
enum FileExplorerWorkspaceRoot: Equatable {
|
|
case none
|
|
case local(workspaceId: UUID, path: String)
|
|
case remoteSSH(
|
|
workspaceId: UUID,
|
|
connection: SSHFileExplorerConnection,
|
|
displayTarget: String,
|
|
rootPath: String?,
|
|
isAvailable: Bool,
|
|
unavailableDetail: String?
|
|
)
|
|
}
|
|
|
|
// MARK: - Local Provider
|
|
|
|
final class LocalFileExplorerProvider: FileExplorerProvider {
|
|
var homePath: String { NSHomeDirectory() }
|
|
var isAvailable: Bool { true }
|
|
|
|
func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] {
|
|
let fm = FileManager.default
|
|
let contents = try fm.contentsOfDirectory(atPath: path)
|
|
return contents.compactMap { name in
|
|
guard showHidden || !name.hasPrefix(".") else { return nil }
|
|
let fullPath = (path as NSString).appendingPathComponent(name)
|
|
var isDir: ObjCBool = false
|
|
guard fm.fileExists(atPath: fullPath, isDirectory: &isDir) else { return nil }
|
|
return FileExplorerEntry(name: name, path: fullPath, isDirectory: isDir.boolValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SSH Provider
|
|
|
|
// Captured by async SSH tasks; mutable availability/root state is guarded by stateLock.
|
|
final class SSHFileExplorerProvider: FileExplorerProvider, @unchecked Sendable {
|
|
private struct State: Sendable {
|
|
var homePath: String
|
|
var isAvailable: Bool
|
|
}
|
|
|
|
let connection: SSHFileExplorerConnection
|
|
let displayTarget: String
|
|
private let transport: SSHFileExplorerTransport
|
|
private let stateLock = NSLock()
|
|
private var state: State
|
|
|
|
var homePath: String {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
return state.homePath
|
|
}
|
|
|
|
var isAvailable: Bool {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
return state.isAvailable
|
|
}
|
|
|
|
var destination: String { connection.destination }
|
|
var port: Int? { connection.port }
|
|
var identityFile: String? { connection.identityFile }
|
|
var sshOptions: [String] { connection.sshOptions }
|
|
|
|
init(
|
|
destination: String,
|
|
port: Int?,
|
|
identityFile: String?,
|
|
sshOptions: [String],
|
|
displayTarget: String? = nil,
|
|
homePath: String,
|
|
isAvailable: Bool,
|
|
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
|
) {
|
|
self.connection = SSHFileExplorerConnection(
|
|
destination: destination,
|
|
port: port,
|
|
identityFile: identityFile,
|
|
sshOptions: sshOptions
|
|
)
|
|
self.displayTarget = displayTarget ?? {
|
|
guard let port else { return destination }
|
|
return "\(destination):\(port)"
|
|
}()
|
|
self.transport = transport
|
|
self.state = State(homePath: homePath, isAvailable: isAvailable)
|
|
}
|
|
|
|
init(
|
|
connection: SSHFileExplorerConnection,
|
|
displayTarget: String,
|
|
homePath: String,
|
|
isAvailable: Bool,
|
|
transport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
|
) {
|
|
self.connection = connection
|
|
self.displayTarget = displayTarget
|
|
self.transport = transport
|
|
self.state = State(homePath: homePath, isAvailable: isAvailable)
|
|
}
|
|
|
|
func updateAvailability(_ available: Bool, homePath: String?) {
|
|
stateLock.lock()
|
|
defer { stateLock.unlock() }
|
|
state.isAvailable = available
|
|
if let homePath {
|
|
state.homePath = homePath
|
|
}
|
|
}
|
|
|
|
func resolveHomePath() async throws -> String {
|
|
guard isAvailable else {
|
|
throw FileExplorerError.providerUnavailable
|
|
}
|
|
let home = try await transport.resolveHomePath(connection: connection)
|
|
guard !home.isEmpty else {
|
|
throw FileExplorerError.sshCommandFailed("remote HOME was empty")
|
|
}
|
|
return home
|
|
}
|
|
|
|
func listDirectory(path: String, showHidden: Bool) async throws -> [FileExplorerEntry] {
|
|
guard isAvailable else {
|
|
throw FileExplorerError.providerUnavailable
|
|
}
|
|
return try await transport.listDirectory(path: path, connection: connection, showHidden: showHidden)
|
|
}
|
|
|
|
func downloadFile(path: String, to localURL: URL) async throws {
|
|
guard isAvailable else {
|
|
throw FileExplorerError.providerUnavailable
|
|
}
|
|
try await transport.downloadFile(path: path, connection: connection, to: localURL)
|
|
}
|
|
}
|
|
|
|
final class ProcessSSHFileExplorerTransport: SSHFileExplorerTransport {
|
|
static let shared = ProcessSSHFileExplorerTransport()
|
|
|
|
nonisolated func resolveHomePath(connection: SSHFileExplorerConnection) async throws -> String {
|
|
let output = try await Self.runSSHCommand(
|
|
connection: connection,
|
|
command: #"printf '%s\n' "$HOME""#
|
|
)
|
|
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
nonisolated func listDirectory(
|
|
path: String,
|
|
connection: SSHFileExplorerConnection,
|
|
showHidden: Bool
|
|
) async throws -> [FileExplorerEntry] {
|
|
try await Self.runSSHListCommand(path: path, connection: connection, showHidden: showHidden)
|
|
}
|
|
|
|
nonisolated func downloadFile(
|
|
path: String,
|
|
connection: SSHFileExplorerConnection,
|
|
to localURL: URL
|
|
) async throws {
|
|
let escapedPath = Self.shellSingleQuote(path)
|
|
let outputURL = localURL
|
|
let commandProcess = SSHDownloadCommandProcess(
|
|
connection: connection,
|
|
command: "cat -- \(escapedPath)",
|
|
outputURL: outputURL
|
|
)
|
|
let result = try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
continuation.resume(with: Result { try commandProcess.run() })
|
|
}
|
|
}
|
|
} onCancel: {
|
|
commandProcess.terminate()
|
|
}
|
|
guard result.terminationStatus == 0 else {
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
throw FileExplorerError.sshCommandFailed(result.stderr)
|
|
}
|
|
}
|
|
|
|
private struct SSHCommandResult: Sendable {
|
|
let stdout: String
|
|
let stderr: String
|
|
let terminationStatus: Int32
|
|
}
|
|
|
|
// Keeps the child process reachable from the cancellation handler while
|
|
// the blocking wait runs off Swift's cooperative executor.
|
|
private final class SSHCommandProcess: @unchecked Sendable {
|
|
private let process = Process()
|
|
private let outPipe = Pipe()
|
|
private let errPipe = Pipe()
|
|
private let lock = NSLock()
|
|
private var terminationGate = ProcessTerminationGate()
|
|
private var cancelled = false
|
|
|
|
init(connection: SSHFileExplorerConnection, command: String) {
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command)
|
|
process.standardOutput = outPipe
|
|
process.standardError = errPipe
|
|
}
|
|
|
|
func run() throws -> SSHCommandResult {
|
|
lock.lock()
|
|
let wasCancelled = cancelled
|
|
lock.unlock()
|
|
if wasCancelled {
|
|
throw CancellationError()
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
lock.unlock()
|
|
throw error
|
|
}
|
|
|
|
lock.lock()
|
|
let shouldTerminate = cancelled
|
|
let shouldTerminateDeferredRequest = terminationGate.markLaunched()
|
|
lock.unlock()
|
|
if shouldTerminateDeferredRequest || shouldTerminate {
|
|
guard process.isRunning else {
|
|
process.waitUntilExit()
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
lock.unlock()
|
|
throw CancellationError()
|
|
}
|
|
process.terminate()
|
|
}
|
|
|
|
let data = outPipe.fileHandleForReading.readDataToEndOfFileOrEmpty()
|
|
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFileOrEmpty()
|
|
process.waitUntilExit()
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
let cancelledAfterExit = cancelled
|
|
lock.unlock()
|
|
if cancelledAfterExit {
|
|
throw CancellationError()
|
|
}
|
|
|
|
return SSHCommandResult(
|
|
stdout: String(data: data, encoding: .utf8) ?? "",
|
|
stderr: String(data: stderrData, encoding: .utf8) ?? "",
|
|
terminationStatus: process.terminationStatus
|
|
)
|
|
}
|
|
|
|
func terminate() {
|
|
lock.lock()
|
|
cancelled = true
|
|
let shouldTerminate = terminationGate.requestTermination()
|
|
lock.unlock()
|
|
|
|
guard shouldTerminate else {
|
|
return
|
|
}
|
|
guard process.isRunning else {
|
|
return
|
|
}
|
|
process.terminate()
|
|
}
|
|
}
|
|
|
|
private final class SSHDownloadCommandProcess: @unchecked Sendable {
|
|
private let process = Process()
|
|
private let outPipe = Pipe()
|
|
private let errPipe = Pipe()
|
|
private let outputURL: URL
|
|
private let lock = NSLock()
|
|
private var terminationGate = ProcessTerminationGate()
|
|
private var cancelled = false
|
|
|
|
init(connection: SSHFileExplorerConnection, command: String, outputURL: URL) {
|
|
self.outputURL = outputURL
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
|
|
process.arguments = ProcessSSHFileExplorerTransport.sshArguments(connection: connection, command: command)
|
|
process.standardOutput = outPipe
|
|
process.standardError = errPipe
|
|
}
|
|
|
|
func run() throws -> SSHCommandResult {
|
|
lock.lock()
|
|
let wasCancelled = cancelled
|
|
lock.unlock()
|
|
if wasCancelled {
|
|
throw CancellationError()
|
|
}
|
|
|
|
try FileManager.default.createDirectory(
|
|
at: outputURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
|
|
let outputHandle = try FileHandle(forWritingTo: outputURL)
|
|
defer { try? outputHandle.close() }
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
lock.unlock()
|
|
throw error
|
|
}
|
|
|
|
lock.lock()
|
|
let shouldTerminate = cancelled
|
|
let shouldTerminateDeferredRequest = terminationGate.markLaunched()
|
|
lock.unlock()
|
|
if shouldTerminateDeferredRequest || shouldTerminate {
|
|
guard process.isRunning else {
|
|
process.waitUntilExit()
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
lock.unlock()
|
|
throw CancellationError()
|
|
}
|
|
process.terminate()
|
|
}
|
|
|
|
try outPipe.fileHandleForReading.copyDataToEndOfFile(to: outputHandle)
|
|
let stderrData = errPipe.fileHandleForReading.readDataToEndOfFileOrEmpty()
|
|
process.waitUntilExit()
|
|
lock.lock()
|
|
terminationGate.markFinished()
|
|
let cancelledAfterExit = cancelled
|
|
lock.unlock()
|
|
if cancelledAfterExit {
|
|
throw CancellationError()
|
|
}
|
|
|
|
return SSHCommandResult(
|
|
stdout: "",
|
|
stderr: String(data: stderrData, encoding: .utf8) ?? "",
|
|
terminationStatus: process.terminationStatus
|
|
)
|
|
}
|
|
|
|
func terminate() {
|
|
lock.lock()
|
|
cancelled = true
|
|
let shouldTerminate = terminationGate.requestTermination()
|
|
lock.unlock()
|
|
|
|
guard shouldTerminate else {
|
|
return
|
|
}
|
|
guard process.isRunning else {
|
|
return
|
|
}
|
|
process.terminate()
|
|
}
|
|
}
|
|
|
|
private static func runSSHCommand(connection: SSHFileExplorerConnection, command: String) async throws -> String {
|
|
let commandProcess = SSHCommandProcess(connection: connection, command: command)
|
|
let result = try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
continuation.resume(with: Result { try commandProcess.run() })
|
|
}
|
|
}
|
|
} onCancel: {
|
|
commandProcess.terminate()
|
|
}
|
|
|
|
guard result.terminationStatus == 0 else {
|
|
throw FileExplorerError.sshCommandFailed(result.stderr)
|
|
}
|
|
return result.stdout
|
|
}
|
|
|
|
private static func sshArguments(connection: SSHFileExplorerConnection, command: String) -> [String] {
|
|
var args: [String] = SSHHostConfiguredRemoteCommand().overrideArguments
|
|
if let port = connection.port {
|
|
args += ["-p", String(port)]
|
|
}
|
|
if let identityFile = connection.identityFile {
|
|
args += ["-i", identityFile]
|
|
}
|
|
for option in connection.sshOptions {
|
|
args += ["-o", option]
|
|
}
|
|
// Batch mode, no TTY, connection timeout
|
|
args += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "-T"]
|
|
args += [connection.destination, command]
|
|
return args
|
|
}
|
|
|
|
private static func runSSHListCommand(
|
|
path: String,
|
|
connection: SSHFileExplorerConnection,
|
|
showHidden: Bool
|
|
) async throws -> [FileExplorerEntry] {
|
|
// Escape single quotes in path for shell safety
|
|
let escapedPath = shellSingleQuote(path)
|
|
let lsFlags = showHidden ? "-1paFA" : "-1paF"
|
|
let output = try await runSSHCommand(
|
|
connection: connection,
|
|
command: "ls \(lsFlags) \(escapedPath) 2>/dev/null"
|
|
)
|
|
|
|
let normalizedPath = path.hasSuffix("/") ? path : path + "/"
|
|
return output.split(separator: "\n", omittingEmptySubsequences: true).compactMap { line in
|
|
let entry = String(line)
|
|
// Skip . and .. entries
|
|
guard entry != "./" && entry != "../" else { return nil }
|
|
let isDir = entry.hasSuffix("/")
|
|
let name = isDir ? String(entry.dropLast()) : entry
|
|
guard showHidden || !name.hasPrefix(".") else { return nil }
|
|
// Strip type indicators from -F flag (*, @, =, |) for files
|
|
let cleanName: String
|
|
if !isDir, let last = name.last, "*@=|".contains(last) {
|
|
cleanName = String(name.dropLast())
|
|
} else {
|
|
cleanName = name
|
|
}
|
|
let fullPath = normalizedPath + cleanName
|
|
return FileExplorerEntry(name: cleanName, path: fullPath, isDirectory: isDir)
|
|
}
|
|
}
|
|
|
|
private static func shellSingleQuote(_ value: String) -> String {
|
|
"'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
|
|
}
|
|
}
|
|
|
|
enum FileExplorerError: LocalizedError {
|
|
case providerUnavailable
|
|
case sshCommandFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .providerUnavailable:
|
|
return String(localized: "fileExplorer.error.unavailable", defaultValue: "File explorer is not available")
|
|
case .sshCommandFailed:
|
|
return String(localized: "fileExplorer.error.sshFailed", defaultValue: "SSH command failed")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Selection Restoration
|
|
|
|
enum FileExplorerSelectionRestoration {
|
|
static func scrollRow(anchorRow: Int?, exactRows: IndexSet) -> Int? {
|
|
if let anchorRow, exactRows.contains(anchorRow) {
|
|
return anchorRow
|
|
}
|
|
return exactRows.first
|
|
}
|
|
}
|
|
|
|
// MARK: - Store
|
|
|
|
/// Main-actor store for file-explorer presentation and loading state.
|
|
@MainActor
|
|
final class FileExplorerStore: ObservableObject {
|
|
@Published var rootPath: String = ""
|
|
@Published var rootNodes: [FileExplorerNode] = []
|
|
@Published private(set) var isRootLoading: Bool = false
|
|
@Published private(set) var gitStatusByPath: [String: GitFileStatus] = [:]
|
|
@Published private(set) var contentRevision = 0
|
|
@Published private(set) var rootStatusMessage: String?
|
|
private(set) var workspaceRootIdentity: UUID?
|
|
|
|
var provider: FileExplorerProvider?
|
|
|
|
/// Whether hidden files are shown. Set from FileExplorerState externally.
|
|
var showHiddenFiles: Bool = false
|
|
|
|
/// Watches the root directory for filesystem changes (local only).
|
|
private var directoryWatcher: FileWatcher?
|
|
private var directoryWatchTask: Task<Void, Never>?
|
|
private var directoryWatchPath: String?
|
|
|
|
/// Paths that are logically expanded (persisted across provider changes)
|
|
private(set) var expandedPaths: Set<String> = []
|
|
|
|
/// Stable navigation selection. The outline view mirrors this path after reloads.
|
|
private(set) var selectedPath: String?
|
|
|
|
/// Stable multi-selection. `selectedPath` remains the keyboard/navigation anchor.
|
|
private(set) var selectedPaths: Set<String> = []
|
|
|
|
/// Folder path whose first child should be selected once its async load completes.
|
|
private var pendingDescendIntoFirstChildPath: String?
|
|
|
|
/// Paths currently being loaded
|
|
private(set) var loadingPaths: Set<String> = []
|
|
|
|
/// In-flight load tasks keyed by path
|
|
private var loadTasks: [String: Task<Void, Never>] = [:]
|
|
|
|
/// Cache of path -> node for quick lookup
|
|
private var nodesByPath: [String: FileExplorerNode] = [:]
|
|
|
|
/// Prefetch debounce schedulers keyed by path.
|
|
private var prefetchSchedulers: [String: MainActorDeferredActionScheduler] = [:]
|
|
|
|
private var remoteHomeResolutionTask: Task<Void, Never>?
|
|
private var remoteHomeResolutionKey: String?
|
|
|
|
private let gitStatusProvider: GitStatusProvider
|
|
|
|
init(gitStatusProvider: GitStatusProvider = GitStatusProvider()) {
|
|
self.gitStatusProvider = gitStatusProvider
|
|
}
|
|
|
|
var displayRootPath: String {
|
|
if let sshProvider = provider as? SSHFileExplorerProvider {
|
|
guard !rootPath.isEmpty else {
|
|
return "ssh://\(sshProvider.displayTarget)"
|
|
}
|
|
return "ssh://\(sshProvider.displayTarget):\(rootPath)"
|
|
}
|
|
return FileExplorerRootResolver.displayPath(for: rootPath, homePath: provider?.homePath)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
func applyWorkspaceRoot(
|
|
_ request: FileExplorerWorkspaceRoot,
|
|
sshTransport: SSHFileExplorerTransport = ProcessSSHFileExplorerTransport.shared
|
|
) {
|
|
switch request {
|
|
case .none:
|
|
cancelRemoteHomeResolution(); setRootStatusMessage(nil); setWorkspaceRootIdentity(nil)
|
|
if provider != nil { setProvider(nil, reloadIfAvailable: false) }
|
|
setRootPath("")
|
|
case .local(let workspaceId, let path):
|
|
cancelRemoteHomeResolution(); setRootStatusMessage(nil); setWorkspaceRootIdentity(workspaceId)
|
|
if !(provider is LocalFileExplorerProvider) {
|
|
setRootPath("")
|
|
setProvider(LocalFileExplorerProvider(), reloadIfAvailable: false)
|
|
}
|
|
setRootPath(path)
|
|
case .remoteSSH(let workspaceId, let connection, let displayTarget, let rootPath, let isAvailable, let unavailableDetail):
|
|
applyRemoteSSHWorkspaceRoot(
|
|
workspaceId: workspaceId,
|
|
connection: connection,
|
|
displayTarget: displayTarget,
|
|
rootPath: rootPath,
|
|
isAvailable: isAvailable,
|
|
unavailableDetail: unavailableDetail,
|
|
sshTransport: sshTransport
|
|
)
|
|
}
|
|
}
|
|
private func setWorkspaceRootIdentity(_ identity: UUID?) { guard workspaceRootIdentity != identity else { return }; objectWillChange.send(); workspaceRootIdentity = identity }
|
|
|
|
func setRootPath(_ path: String) {
|
|
guard path != rootPath else {
|
|
#if DEBUG
|
|
NSLog("[FileExplorer] setRootPath skipped (same path): \(path)")
|
|
#endif
|
|
return
|
|
}
|
|
#if DEBUG
|
|
NSLog("[FileExplorer] setRootPath: \(rootPath) -> \(path)")
|
|
#endif
|
|
if let selectedPath, !Self.path(selectedPath, isContainedIn: path) {
|
|
self.selectedPath = nil
|
|
selectedPaths = []
|
|
pendingDescendIntoFirstChildPath = nil
|
|
}
|
|
rootPath = path
|
|
reload()
|
|
refreshGitStatus()
|
|
updateDirectoryWatcher()
|
|
}
|
|
|
|
func refreshGitStatus() {
|
|
guard !rootPath.isEmpty else {
|
|
gitStatusByPath = [:]
|
|
return
|
|
}
|
|
let path = rootPath
|
|
if let sshProvider = provider as? SSHFileExplorerProvider {
|
|
let dest = sshProvider.destination
|
|
let port = sshProvider.port
|
|
let identity = sshProvider.identityFile
|
|
let opts = sshProvider.sshOptions
|
|
let gitStatusProvider = self.gitStatusProvider
|
|
DispatchQueue.global(qos: .utility).async {
|
|
let status = gitStatusProvider.fetchStatusSSH(
|
|
directory: path, destination: dest, port: port,
|
|
identityFile: identity, sshOptions: opts
|
|
)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.gitStatusByPath = status
|
|
}
|
|
}
|
|
} else {
|
|
let gitStatusProvider = self.gitStatusProvider
|
|
DispatchQueue.global(qos: .utility).async {
|
|
let status = gitStatusProvider.fetchStatus(directory: path)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.gitStatusByPath = status
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func materializeRemoteFileForPreview(path: String) async throws -> URL {
|
|
guard let sshProvider = provider as? SSHFileExplorerProvider else {
|
|
throw FileExplorerError.providerUnavailable
|
|
}
|
|
let cacheURL = Self.remotePreviewCacheURL(
|
|
displayTarget: sshProvider.displayTarget,
|
|
remotePath: path
|
|
)
|
|
try await sshProvider.downloadFile(path: path, to: cacheURL)
|
|
return cacheURL
|
|
}
|
|
|
|
private func updateDirectoryWatcher() {
|
|
if provider is LocalFileExplorerProvider, !rootPath.isEmpty {
|
|
guard directoryWatchPath != rootPath || directoryWatcher == nil else { return }
|
|
stopDirectoryWatcher()
|
|
// Preserve the previous 0.3s coalescing as a leading-edge throttle.
|
|
let watcher = FileWatcher(path: rootPath, throttle: .milliseconds(300))
|
|
directoryWatcher = watcher
|
|
directoryWatchPath = rootPath
|
|
let events = watcher.events
|
|
directoryWatchTask = Task { @MainActor [weak self] in
|
|
for await _ in events {
|
|
guard let self else { break }
|
|
self.reload()
|
|
self.refreshGitStatus()
|
|
}
|
|
}
|
|
} else {
|
|
stopDirectoryWatcher()
|
|
}
|
|
}
|
|
|
|
/// Cancels the directory-watch consumer and drops the watcher; the watcher's
|
|
/// deinit cancels its `DispatchSource`s synchronously.
|
|
private func stopDirectoryWatcher() {
|
|
directoryWatchTask?.cancel()
|
|
directoryWatchTask = nil
|
|
directoryWatcher = nil
|
|
directoryWatchPath = nil
|
|
}
|
|
|
|
private func setProvider(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
|
|
#if DEBUG
|
|
NSLog("[FileExplorer] setProvider: \(type(of: newProvider).self) available=\(newProvider?.isAvailable ?? false)")
|
|
#endif
|
|
provider = newProvider
|
|
// Re-expand previously expanded nodes if provider becomes available
|
|
if reloadIfAvailable, newProvider?.isAvailable == true {
|
|
reload()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
func setProviderForTesting(_ newProvider: FileExplorerProvider?, reloadIfAvailable: Bool = true) {
|
|
setProvider(newProvider, reloadIfAvailable: reloadIfAvailable)
|
|
}
|
|
#endif
|
|
|
|
func reload() {
|
|
#if DEBUG
|
|
NSLog("[FileExplorer] reload() path=\(rootPath) provider=\(type(of: provider).self)")
|
|
#endif
|
|
contentRevision &+= 1
|
|
cancelAllLoads()
|
|
rootNodes = []
|
|
nodesByPath = [:]
|
|
guard !rootPath.isEmpty, provider != nil else { return }
|
|
isRootLoading = true
|
|
let path = rootPath
|
|
let task = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.loadChildren(for: nil, at: path)
|
|
}
|
|
loadTasks[rootPath] = task
|
|
}
|
|
|
|
func expand(node: FileExplorerNode) {
|
|
guard node.isDirectory else { return }
|
|
expandedPaths.insert(node.path)
|
|
if node.children == nil, loadTasks[node.path] == nil, !loadingPaths.contains(node.path) {
|
|
node.isLoading = true
|
|
node.error = nil
|
|
objectWillChange.send()
|
|
let nodePath = node.path
|
|
let task = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.loadChildren(for: node, at: nodePath)
|
|
}
|
|
loadTasks[node.path] = task
|
|
}
|
|
}
|
|
|
|
func collapse(node: FileExplorerNode) {
|
|
expandedPaths.remove(node.path)
|
|
if pendingDescendIntoFirstChildPath == node.path {
|
|
pendingDescendIntoFirstChildPath = nil
|
|
}
|
|
objectWillChange.send()
|
|
}
|
|
|
|
func isExpanded(_ node: FileExplorerNode) -> Bool {
|
|
expandedPaths.contains(node.path)
|
|
}
|
|
|
|
func select(node: FileExplorerNode?) {
|
|
let path = node?.path
|
|
let paths = path.map { Set([$0]) } ?? []
|
|
guard selectedPath != path || selectedPaths != paths else { return }
|
|
selectedPath = path
|
|
selectedPaths = paths
|
|
if path != pendingDescendIntoFirstChildPath {
|
|
pendingDescendIntoFirstChildPath = nil
|
|
}
|
|
}
|
|
|
|
func select(nodes: [FileExplorerNode], anchor: FileExplorerNode?) {
|
|
let paths = Set(nodes.map(\.path))
|
|
let path = anchor?.path ?? nodes.first?.path
|
|
guard selectedPath != path || selectedPaths != paths else { return }
|
|
selectedPath = path
|
|
selectedPaths = paths
|
|
if path != pendingDescendIntoFirstChildPath {
|
|
pendingDescendIntoFirstChildPath = nil
|
|
}
|
|
}
|
|
|
|
func requestDescendIntoFirstChild(of node: FileExplorerNode) {
|
|
guard node.isDirectory else { return }
|
|
selectedPath = node.path
|
|
selectedPaths = [node.path]
|
|
pendingDescendIntoFirstChildPath = node.path
|
|
expand(node: node)
|
|
}
|
|
|
|
func prefetchChildren(for node: FileExplorerNode) {
|
|
guard node.isDirectory, node.children == nil, !loadingPaths.contains(node.path) else { return }
|
|
// Debounce: only prefetch if hover persists for 200ms
|
|
let path = node.path
|
|
let scheduler = prefetchSchedulers[path] ?? MainActorDeferredActionScheduler()
|
|
prefetchSchedulers[path] = scheduler
|
|
scheduler.schedule(after: .milliseconds(200)) { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, node.children == nil, !self.loadingPaths.contains(path) else { return }
|
|
// Silent prefetch: don't show loading indicator
|
|
await self.loadChildren(for: node, at: path, silent: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelPrefetch(for node: FileExplorerNode) {
|
|
prefetchSchedulers[node.path]?.cancel()
|
|
prefetchSchedulers.removeValue(forKey: node.path)
|
|
}
|
|
|
|
/// Called when SSH provider becomes available after being unavailable.
|
|
/// Re-hydrates expanded nodes that were waiting.
|
|
func hydrateExpandedNodes() {
|
|
guard let provider, provider.isAvailable, !expandedPaths.isEmpty else { return }
|
|
#if DEBUG
|
|
NSLog("[FileExplorer] hydrateExpandedNodes: \(expandedPaths.count) paths to hydrate")
|
|
#endif
|
|
reload()
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
@MainActor
|
|
private func loadChildren(for parentNode: FileExplorerNode?, at path: String, silent: Bool = false) async {
|
|
// A load cancelled by cancelAllLoads (e.g. a root reload during an SSH provider swap) must not
|
|
// reach provider.listDirectory: the provider may have been replaced, so a stale in-flight load
|
|
// would list the old path through the new transport. Bail before any listing.
|
|
guard !Task.isCancelled else { return }
|
|
guard let provider else { return }
|
|
|
|
if !silent {
|
|
loadingPaths.insert(path)
|
|
parentNode?.error = nil
|
|
objectWillChange.send()
|
|
}
|
|
|
|
do {
|
|
let entries = try await provider.listDirectory(path: path, showHidden: showHiddenFiles)
|
|
try Task.checkCancellation()
|
|
let children = entries.map { entry in
|
|
let node = FileExplorerNode(name: entry.name, path: entry.path, isDirectory: entry.isDirectory)
|
|
nodesByPath[entry.path] = node
|
|
return node
|
|
}.sorted { a, b in
|
|
if a.isDirectory != b.isDirectory { return a.isDirectory }
|
|
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
|
|
}
|
|
|
|
if let parentNode {
|
|
parentNode.children = children
|
|
parentNode.isLoading = false
|
|
parentNode.error = nil
|
|
if pendingDescendIntoFirstChildPath == parentNode.path {
|
|
let path = children.first?.path ?? parentNode.path
|
|
selectedPath = path
|
|
selectedPaths = [path]
|
|
pendingDescendIntoFirstChildPath = nil
|
|
}
|
|
} else {
|
|
rootNodes = children
|
|
isRootLoading = false
|
|
setRootStatusMessage(nil)
|
|
if selectedPath == nil {
|
|
selectedPath = children.first?.path
|
|
selectedPaths = selectedPath.map { Set([$0]) } ?? []
|
|
}
|
|
}
|
|
loadingPaths.remove(path)
|
|
loadTasks.removeValue(forKey: path)
|
|
objectWillChange.send()
|
|
|
|
// Auto-expand children that were previously expanded
|
|
for child in children where child.isDirectory && expandedPaths.contains(child.path) {
|
|
child.isLoading = true
|
|
objectWillChange.send()
|
|
let childPath = child.path
|
|
let childTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await self.loadChildren(for: child, at: childPath)
|
|
}
|
|
loadTasks[child.path] = childTask
|
|
}
|
|
} catch {
|
|
if !Task.isCancelled {
|
|
if let parentNode {
|
|
parentNode.isLoading = false
|
|
parentNode.error = error.localizedDescription
|
|
} else {
|
|
isRootLoading = false
|
|
setRootStatusMessage(error.localizedDescription)
|
|
}
|
|
loadingPaths.remove(path)
|
|
loadTasks.removeValue(forKey: path)
|
|
objectWillChange.send()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cancelAllLoads() {
|
|
for (_, task) in loadTasks {
|
|
task.cancel()
|
|
}
|
|
loadTasks.removeAll()
|
|
loadingPaths.removeAll()
|
|
pendingDescendIntoFirstChildPath = nil
|
|
for scheduler in prefetchSchedulers.values {
|
|
scheduler.cancel()
|
|
}
|
|
prefetchSchedulers.removeAll()
|
|
isRootLoading = false
|
|
}
|
|
|
|
private func applyRemoteSSHWorkspaceRoot(
|
|
workspaceId: UUID,
|
|
connection: SSHFileExplorerConnection,
|
|
displayTarget: String,
|
|
rootPath requestedRootPath: String?,
|
|
isAvailable: Bool,
|
|
unavailableDetail: String?,
|
|
sshTransport: SSHFileExplorerTransport
|
|
) {
|
|
setWorkspaceRootIdentity(workspaceId)
|
|
|
|
let existingProvider = provider as? SSHFileExplorerProvider
|
|
let sshProvider: SSHFileExplorerProvider
|
|
if let existingProvider,
|
|
existingProvider.connection == connection,
|
|
existingProvider.displayTarget == displayTarget {
|
|
sshProvider = existingProvider
|
|
sshProvider.updateAvailability(isAvailable, homePath: nil)
|
|
} else {
|
|
cancelRemoteHomeResolution()
|
|
setRootPath("")
|
|
sshProvider = SSHFileExplorerProvider(
|
|
connection: connection,
|
|
displayTarget: displayTarget,
|
|
homePath: "",
|
|
isAvailable: isAvailable,
|
|
transport: sshTransport
|
|
)
|
|
setProvider(sshProvider, reloadIfAvailable: false)
|
|
}
|
|
|
|
guard isAvailable else {
|
|
cancelRemoteHomeResolution()
|
|
setRootPath("")
|
|
let detail = unavailableDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let detail, !detail.isEmpty {
|
|
setRootStatusMessage(
|
|
String(
|
|
localized: "fileExplorer.status.sshUnavailableWithDetail",
|
|
defaultValue: "SSH files unavailable: \(detail)"
|
|
)
|
|
)
|
|
} else {
|
|
setRootStatusMessage(
|
|
String(localized: "fileExplorer.status.sshUnavailable", defaultValue: "SSH files unavailable")
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
let requestedRootPath = Self.normalizedRootPath(requestedRootPath)
|
|
if let requestedRootPath {
|
|
cancelRemoteHomeResolution()
|
|
setRootStatusMessage(nil)
|
|
setRootPath(requestedRootPath)
|
|
return
|
|
}
|
|
|
|
let currentHomePath = sshProvider.homePath
|
|
if !currentHomePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
setRootStatusMessage(nil)
|
|
setRootPath(currentHomePath)
|
|
return
|
|
}
|
|
|
|
resolveRemoteHome(
|
|
workspaceId: workspaceId,
|
|
provider: sshProvider,
|
|
connection: connection
|
|
)
|
|
}
|
|
|
|
private func resolveRemoteHome(
|
|
workspaceId: UUID,
|
|
provider sshProvider: SSHFileExplorerProvider,
|
|
connection: SSHFileExplorerConnection
|
|
) {
|
|
let resolutionKey = [
|
|
workspaceId.uuidString,
|
|
connection.destination,
|
|
connection.port.map(String.init) ?? "",
|
|
connection.identityFile ?? "",
|
|
connection.sshOptions.joined(separator: "\u{1f}"),
|
|
].joined(separator: "\u{1e}")
|
|
|
|
guard remoteHomeResolutionKey != resolutionKey else { return }
|
|
remoteHomeResolutionTask?.cancel()
|
|
remoteHomeResolutionKey = resolutionKey
|
|
setRootPath("")
|
|
setRootStatusMessage(String(localized: "fileExplorer.status.sshResolvingHome", defaultValue: "Resolving remote home..."))
|
|
|
|
remoteHomeResolutionTask = Task { [weak self, weak sshProvider] in
|
|
guard let sshProvider else { return }
|
|
do {
|
|
let homePath = try await sshProvider.resolveHomePath()
|
|
await MainActor.run { [weak self, weak sshProvider] in
|
|
guard let self,
|
|
let sshProvider,
|
|
self.remoteHomeResolutionKey == resolutionKey,
|
|
self.provider === sshProvider else { return }
|
|
self.remoteHomeResolutionKey = nil
|
|
self.remoteHomeResolutionTask = nil
|
|
sshProvider.updateAvailability(true, homePath: homePath)
|
|
self.setRootStatusMessage(nil)
|
|
self.setRootPath(homePath)
|
|
}
|
|
} catch {
|
|
await MainActor.run { [weak self, weak sshProvider] in
|
|
guard let self,
|
|
let sshProvider,
|
|
self.remoteHomeResolutionKey == resolutionKey,
|
|
self.provider === sshProvider else { return }
|
|
self.remoteHomeResolutionKey = nil
|
|
self.remoteHomeResolutionTask = nil
|
|
self.setRootPath("")
|
|
self.setRootStatusMessage(
|
|
String(
|
|
localized: "fileExplorer.status.sshHomeFailed",
|
|
defaultValue: "Unable to resolve SSH home: \(error.localizedDescription)"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cancelRemoteHomeResolution() {
|
|
remoteHomeResolutionTask?.cancel()
|
|
remoteHomeResolutionTask = nil
|
|
remoteHomeResolutionKey = nil
|
|
}
|
|
|
|
private func setRootStatusMessage(_ message: String?) {
|
|
guard rootStatusMessage != message else { return }
|
|
rootStatusMessage = message
|
|
}
|
|
|
|
private static func path(_ candidate: String, isContainedIn root: String) -> Bool {
|
|
guard !root.isEmpty else { return false }
|
|
if root == "/" {
|
|
return candidate.hasPrefix("/")
|
|
}
|
|
return candidate == root || candidate.hasPrefix(root + "/")
|
|
}
|
|
|
|
private static func normalizedRootPath(_ path: String?) -> String? {
|
|
guard let path else { return nil }
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return trimmed
|
|
}
|
|
|
|
private static func remotePreviewCacheURL(displayTarget: String, remotePath: String) -> URL {
|
|
let cacheRoot = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-remote-file-previews", isDirectory: true)
|
|
let target = sanitizedCacheComponent(displayTarget)
|
|
let remote = sanitizedCacheComponent(remotePath)
|
|
let basename = URL(fileURLWithPath: remotePath).lastPathComponent
|
|
let filename = basename.isEmpty ? remote : "\(remote)-\(basename)"
|
|
return cacheRoot
|
|
.appendingPathComponent(target, isDirectory: true)
|
|
.appendingPathComponent(filename, isDirectory: false)
|
|
}
|
|
|
|
private static func sanitizedCacheComponent(_ value: String) -> String {
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
|
|
let scalars = value.unicodeScalars.map { allowed.contains($0) ? Character($0) : "-" }
|
|
let candidate = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
|
return candidate.isEmpty ? UUID().uuidString : String(candidate.prefix(160))
|
|
}
|
|
|
|
deinit {
|
|
remoteHomeResolutionTask?.cancel()
|
|
directoryWatchTask?.cancel()
|
|
}
|
|
}
|