Files
cmux/Sources/FileExplorerStore.swift
EJandejc3 8c9ee247a0 file-explorer: drop cancelled loads before they list a stale path (#8595)
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]>
2026-08-04 01:46:33 -07:00

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