Skip to content

feat: add remote folder picker to file sync GUI #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 9, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Coder-Desktop/Coder-Desktop/Info.plist
Original file line number Diff line number Diff line change
@@ -2,6 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSAppTransportSecurity</key>
<dict>
<!--
Required to make HTTP (not HTTPS) requests to workspace agents
(i.e. workspace.coder:4). These are already encrypted over wireguard.
-->
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NetworkExtension</key>
<dict>
<key>NEMachServiceName</key>
232 changes: 232 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/FileSync/FilePicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import CoderSDK
import Foundation
import SwiftUI

struct FilePicker: View {
@Environment(\.dismiss) var dismiss
@StateObject private var model: FilePickerModel
@State private var selection: FilePickerEntryModel?

@Binding var outputAbsPath: String

let inspection = Inspection<Self>()

init(
host: String,
outputAbsPath: Binding<String>
) {
_model = StateObject(wrappedValue: FilePickerModel(host: host))
_outputAbsPath = outputAbsPath
}

var body: some View {
VStack(spacing: 0) {
if model.rootIsLoading {
Spacer()
ProgressView()
.controlSize(.large)
Spacer()
} else if let loadError = model.error {
Text("\(loadError.description)")
.font(.headline)
.foregroundColor(.red)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
List(selection: $selection) {
ForEach(model.rootEntries) { entry in
FilePickerEntry(entry: entry).tag(entry)
}
}.contextMenu(
forSelectionType: FilePickerEntryModel.self,
menu: { _ in },
primaryAction: { selections in
// Per the type of `selection`, this will only ever be a set of
// one entry.
selections.forEach { entry in withAnimation { entry.isExpanded.toggle() } }
}
).listStyle(.sidebar)
}
Divider()
HStack {
Spacer()
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
Button("Select", action: submit).keyboardShortcut(.defaultAction).disabled(selection == nil)
}.padding(20)
}
.onAppear {
model.loadRoot()
}
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
}

private func submit() {
guard let selection else { return }
outputAbsPath = selection.absolute_path
dismiss()
}
}

@MainActor
class FilePickerModel: ObservableObject {
@Published var rootEntries: [FilePickerEntryModel] = []
@Published var rootIsLoading: Bool = false
@Published var error: ClientError?

// It's important that `AgentClient` is a reference type (class)
// as we were having performance issues with a struct (unless it was a binding).
let client: AgentClient

init(host: String) {
client = AgentClient(agentHost: host)
}

func loadRoot() {
error = nil
rootIsLoading = true
Task {
defer { rootIsLoading = false }
do throws(ClientError) {
rootEntries = try await client
.listAgentDirectory(.init(path: [], relativity: .root))
.toModels(client: client)
} catch {
self.error = error
}
}
}
}

struct FilePickerEntry: View {
@ObservedObject var entry: FilePickerEntryModel

var body: some View {
Group {
if entry.dir {
directory
} else {
Label(entry.name, systemImage: "doc")
.help(entry.absolute_path)
.selectionDisabled()
.foregroundColor(.secondary)
}
}
}

private var directory: some View {
DisclosureGroup(isExpanded: $entry.isExpanded) {
if let entries = entry.entries {
ForEach(entries) { entry in
FilePickerEntry(entry: entry).tag(entry)
}
}
} label: {
Label {
Text(entry.name)
ZStack {
ProgressView().controlSize(.small).opacity(entry.isLoading && entry.error == nil ? 1 : 0)
Image(systemName: "exclamationmark.triangle.fill")
.opacity(entry.error != nil ? 1 : 0)
}
} icon: {
Image(systemName: "folder")
}.help(entry.error != nil ? entry.error!.description : entry.absolute_path)
}
}
}

@MainActor
class FilePickerEntryModel: Identifiable, Hashable, ObservableObject {
nonisolated let id: [String]
let name: String
// Components of the path as an array
let path: [String]
let absolute_path: String
let dir: Bool

let client: AgentClient

@Published var entries: [FilePickerEntryModel]?
@Published var isLoading = false
@Published var error: ClientError?
@Published private var innerIsExpanded = false
var isExpanded: Bool {
get { innerIsExpanded }
set {
if !newValue {
withAnimation { self.innerIsExpanded = false }
} else {
Task {
self.loadEntries()
}
}
}
}

init(
name: String,
client: AgentClient,
absolute_path: String,
path: [String],
dir: Bool = false,
entries: [FilePickerEntryModel]? = nil
) {
self.name = name
self.client = client
self.path = path
self.dir = dir
self.absolute_path = absolute_path
self.entries = entries

// Swift Arrays are copy on write
id = path
}

func loadEntries() {
self.error = nil
withAnimation { isLoading = true }
Task {
defer {
withAnimation {
isLoading = false
innerIsExpanded = true
}
}
do throws(ClientError) {
entries = try await client
.listAgentDirectory(.init(path: path, relativity: .root))
.toModels(client: client)
} catch {
self.error = error
}
}
}

nonisolated static func == (lhs: FilePickerEntryModel, rhs: FilePickerEntryModel) -> Bool {
lhs.id == rhs.id
}

nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}

extension LSResponse {
@MainActor
func toModels(client: AgentClient) -> [FilePickerEntryModel] {
contents.compactMap { entry in
// Filter dotfiles from the picker
guard !entry.name.hasPrefix(".") else { return nil }

return FilePickerEntryModel(
name: entry.name,
client: client,
absolute_path: entry.absolute_path_string,
path: self.absolute_path + [entry.name],
dir: entry.is_dir,
entries: nil
)
}
}
}
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {

@State private var loading: Bool = false
@State private var createError: DaemonError?
@State private var pickingRemote: Bool = false

var body: some View {
let agents = vpn.menuState.onlineAgents
@@ -46,7 +47,16 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
}
}
Section {
TextField("Remote Path", text: $remotePath)
HStack(spacing: 5) {
TextField("Remote Path", text: $remotePath)
Spacer()
Button {
pickingRemote = true
} label: {
Image(systemName: "folder")
}.disabled(remoteHostname == nil)
.help(remoteHostname == nil ? "Select a workspace first" : "Open File Picker")
}
}
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
Divider()
@@ -72,6 +82,9 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
set: { if !$0 { createError = nil } }
)) {} message: {
Text(createError?.description ?? "An unknown error occurred.")
}.sheet(isPresented: $pickingRemote) {
FilePicker(host: remoteHostname!, outputAbsPath: $remotePath)
.frame(width: 300, height: 400)
}
}

Loading