Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 5b4d965

Browse files
committedApr 8, 2025
feat: add remote folder picker to file sync GUI
1 parent 9f625fd commit 5b4d965

File tree

7 files changed

+463
-2
lines changed

7 files changed

+463
-2
lines changed
 

‎Coder-Desktop/Coder-Desktop/Info.plist

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<key>NSAppTransportSecurity</key>
6+
<dict>
7+
<!--
8+
Required to make HTTP (not HTTPS) requests to workspace agents
9+
(i.e. workspace.coder:4). These are already encrypted over wireguard.
10+
-->
11+
<key>NSAllowsArbitraryLoads</key>
12+
<true/>
13+
</dict>
514
<key>NetworkExtension</key>
615
<dict>
716
<key>NEMachServiceName</key>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import CoderSDK
2+
import Foundation
3+
import SwiftUI
4+
5+
struct FilePicker: View {
6+
@Environment(\.dismiss) var dismiss
7+
@StateObject private var model: FilePickerModel
8+
@State private var selection: FilePickerItemModel.ID?
9+
10+
@Binding var outputAbsPath: String
11+
12+
let inspection = Inspection<Self>()
13+
14+
init(
15+
host: String,
16+
outputAbsPath: Binding<String>
17+
) {
18+
_model = StateObject(wrappedValue: FilePickerModel(host: host))
19+
_outputAbsPath = outputAbsPath
20+
}
21+
22+
var body: some View {
23+
VStack(spacing: 0) {
24+
if model.isLoading {
25+
Spacer()
26+
ProgressView()
27+
.controlSize(.large)
28+
Spacer()
29+
} else if let loadError = model.error {
30+
Text("\(loadError.description)")
31+
.font(.headline)
32+
.foregroundColor(.red)
33+
.multilineTextAlignment(.center)
34+
.frame(maxWidth: .infinity, maxHeight: .infinity)
35+
.padding()
36+
} else {
37+
List(selection: $selection) {
38+
ForEach(model.rootFiles) { rootItem in
39+
FilePickerItem(item: rootItem)
40+
}
41+
}.contextMenu(
42+
forSelectionType: FilePickerItemModel.ID.self,
43+
menu: { _ in },
44+
primaryAction: { selections in
45+
// Per the type of `selection`, this will only ever be a set of
46+
// one item.
47+
let files = model.findFilesByIds(ids: selections)
48+
files.forEach { file in withAnimation { file.isExpanded.toggle() } }
49+
}
50+
).listStyle(.sidebar)
51+
}
52+
Divider()
53+
HStack {
54+
Spacer()
55+
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction)
56+
Button("Select", action: submit).keyboardShortcut(.defaultAction).disabled(selection == nil)
57+
}.padding(20)
58+
}
59+
.onAppear {
60+
model.loadRoot()
61+
}
62+
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
63+
}
64+
65+
private func submit() {
66+
guard let selection else { return }
67+
let files = model.findFilesByIds(ids: [selection])
68+
if let file = files.first {
69+
outputAbsPath = file.absolute_path
70+
}
71+
dismiss()
72+
}
73+
}
74+
75+
@MainActor
76+
class FilePickerModel: ObservableObject {
77+
@Published var rootFiles: [FilePickerItemModel] = []
78+
@Published var isLoading: Bool = false
79+
@Published var error: ClientError?
80+
81+
let client: Client
82+
83+
init(host: String) {
84+
client = Client(url: URL(string: "http://\(host):4")!)
85+
}
86+
87+
func loadRoot() {
88+
error = nil
89+
isLoading = true
90+
Task {
91+
defer { isLoading = false }
92+
do throws(ClientError) {
93+
rootFiles = try await client
94+
.listAgentDirectory(.init(path: [], relativity: .root))
95+
.toModels(client: Binding(get: { self.client }, set: { _ in }), path: [])
96+
} catch {
97+
self.error = error
98+
}
99+
}
100+
}
101+
102+
func findFilesByIds(ids: Set<FilePickerItemModel.ID>) -> [FilePickerItemModel] {
103+
var result: [FilePickerItemModel] = []
104+
105+
for id in ids {
106+
if let file = findFileByPath(path: id, in: rootFiles) {
107+
result.append(file)
108+
}
109+
}
110+
111+
return result
112+
}
113+
114+
private func findFileByPath(path: [String], in files: [FilePickerItemModel]?) -> FilePickerItemModel? {
115+
guard let files, !path.isEmpty else { return nil }
116+
117+
if let file = files.first(where: { $0.name == path[0] }) {
118+
if path.count == 1 {
119+
return file
120+
}
121+
// Array slices are just views, so this isn't expensive
122+
return findFileByPath(path: Array(path[1...]), in: file.contents)
123+
}
124+
125+
return nil
126+
}
127+
}
128+
129+
struct FilePickerItem: View {
130+
@ObservedObject var item: FilePickerItemModel
131+
132+
var body: some View {
133+
Group {
134+
if item.dir {
135+
directory
136+
} else {
137+
Label(item.name, systemImage: "doc")
138+
.help(item.absolute_path)
139+
.selectionDisabled()
140+
.foregroundColor(.secondary)
141+
}
142+
}
143+
}
144+
145+
private var directory: some View {
146+
DisclosureGroup(isExpanded: $item.isExpanded) {
147+
if let contents = item.contents {
148+
ForEach(contents) { item in
149+
FilePickerItem(item: item)
150+
}
151+
}
152+
} label: {
153+
Label {
154+
Text(item.name)
155+
ZStack {
156+
ProgressView().controlSize(.small).opacity(item.isLoading && item.error == nil ? 1 : 0)
157+
Image(systemName: "exclamationmark.triangle.fill")
158+
.opacity(item.error != nil ? 1 : 0)
159+
}
160+
} icon: {
161+
Image(systemName: "folder")
162+
}.help(item.error != nil ? item.error!.description : item.absolute_path)
163+
}
164+
}
165+
}
166+
167+
@MainActor
168+
class FilePickerItemModel: Identifiable, ObservableObject {
169+
nonisolated let id: [String]
170+
let name: String
171+
// Components of the path as an array
172+
let path: [String]
173+
let absolute_path: String
174+
let dir: Bool
175+
176+
// This being a binding is pretty important performance-wise, as it's a struct
177+
// that would otherwise be recreated every time the the item row is rendered.
178+
// Removing the binding results in very noticeable lag when scrolling a file tree.
179+
@Binding var client: Client
180+
181+
@Published var contents: [FilePickerItemModel]?
182+
@Published var isLoading = false
183+
@Published var error: ClientError?
184+
@Published private var innerIsExpanded = false
185+
var isExpanded: Bool {
186+
get { innerIsExpanded }
187+
set {
188+
if !newValue {
189+
withAnimation { self.innerIsExpanded = false }
190+
} else {
191+
Task {
192+
self.loadContents()
193+
}
194+
}
195+
}
196+
}
197+
198+
init(
199+
name: String,
200+
client: Binding<Client>,
201+
absolute_path: String,
202+
path: [String],
203+
dir: Bool = false,
204+
contents: [FilePickerItemModel]? = nil
205+
) {
206+
self.name = name
207+
_client = client
208+
self.path = path
209+
self.dir = dir
210+
self.absolute_path = absolute_path
211+
self.contents = contents
212+
213+
// Swift Arrays are COW
214+
id = path
215+
}
216+
217+
func loadContents() {
218+
self.error = nil
219+
withAnimation { isLoading = true }
220+
Task {
221+
defer {
222+
withAnimation {
223+
isLoading = false
224+
innerIsExpanded = true
225+
}
226+
}
227+
do throws(ClientError) {
228+
contents = try await client
229+
.listAgentDirectory(.init(path: path, relativity: .root))
230+
.toModels(client: $client, path: path)
231+
} catch {
232+
self.error = error
233+
}
234+
}
235+
}
236+
}
237+
238+
extension LSResponse {
239+
@MainActor
240+
func toModels(client: Binding<Client>, path: [String]) -> [FilePickerItemModel] {
241+
contents.compactMap { file in
242+
// Filter dotfiles from the picker
243+
guard !file.name.hasPrefix(".") else { return nil }
244+
245+
return FilePickerItemModel(
246+
name: file.name,
247+
client: client,
248+
absolute_path: file.absolute_path_string,
249+
path: path + [file.name],
250+
dir: file.is_dir,
251+
contents: nil
252+
)
253+
}
254+
}
255+
}

‎Coder-Desktop/Coder-Desktop/Views/FileSync/FileSyncSessionModal.swift

+14-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
1313

1414
@State private var loading: Bool = false
1515
@State private var createError: DaemonError?
16+
@State private var pickingRemote: Bool = false
1617

1718
var body: some View {
1819
let agents = vpn.menuState.onlineAgents
@@ -46,7 +47,16 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
4647
}
4748
}
4849
Section {
49-
TextField("Remote Path", text: $remotePath)
50+
HStack(spacing: 5) {
51+
TextField("Remote Path", text: $remotePath)
52+
Spacer()
53+
Button {
54+
pickingRemote = true
55+
} label: {
56+
Image(systemName: "folder")
57+
}.disabled(workspace == nil)
58+
.help(workspace == nil ? "Select a workspace first" : "Open File Picker")
59+
}
5060
}
5161
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
5262
Divider()
@@ -72,6 +82,9 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
7282
set: { if !$0 { createError = nil } }
7383
)) {} message: {
7484
Text(createError?.description ?? "An unknown error occurred.")
85+
}.sheet(isPresented: $pickingRemote) {
86+
FilePicker(host: workspace!.primaryHost!, outputAbsPath: $remotePath)
87+
.frame(width: 300, height: 400)
7588
}
7689
}
7790

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
@testable import Coder_Desktop
2+
@testable import CoderSDK
3+
import Mocker
4+
import SwiftUI
5+
import Testing
6+
import ViewInspector
7+
8+
@MainActor
9+
@Suite(.timeLimit(.minutes(1)))
10+
struct FilePickerTests {
11+
let mockResponse: LSResponse
12+
13+
init() {
14+
mockResponse = LSResponse(
15+
absolute_path: ["/"],
16+
absolute_path_string: "/",
17+
contents: [
18+
LSFile(name: "home", absolute_path_string: "/home", is_dir: true),
19+
LSFile(name: "tmp", absolute_path_string: "/tmp", is_dir: true),
20+
LSFile(name: "etc", absolute_path_string: "/etc", is_dir: true),
21+
LSFile(name: "README.md", absolute_path_string: "/README.md", is_dir: false),
22+
]
23+
)
24+
}
25+
26+
@Test
27+
func testLoadError() async throws {
28+
let host = "test-error.coder"
29+
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
30+
let view = sut
31+
32+
let url = URL(string: "http://\(host):4")!
33+
34+
let errorMessage = "Connection failed"
35+
Mock(
36+
url: url.appendingPathComponent("/api/v0/list-directory"),
37+
contentType: .json,
38+
statusCode: 500,
39+
data: [.post: errorMessage.data(using: .utf8)!]
40+
).register()
41+
42+
try await ViewHosting.host(view) {
43+
try await sut.inspection.inspect { view in
44+
try #expect(await eventually { @MainActor in
45+
let text = try view.find(ViewType.Text.self)
46+
return try text.string().contains("Connection failed")
47+
})
48+
}
49+
}
50+
}
51+
52+
@Test
53+
func testSuccessfulFileLoad() async throws {
54+
let host = "test-success.coder"
55+
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
56+
let view = sut
57+
58+
let url = URL(string: "http://\(host):4")!
59+
60+
try Mock(
61+
url: url.appendingPathComponent("/api/v0/list-directory"),
62+
statusCode: 200,
63+
data: [.post: Client.encoder.encode(mockResponse)]
64+
).register()
65+
66+
try await ViewHosting.host(view) {
67+
try await sut.inspection.inspect { view in
68+
try #expect(await eventually { @MainActor in
69+
_ = try view.find(ViewType.List.self)
70+
return true
71+
})
72+
_ = try view.find(text: "README.md")
73+
_ = try view.find(text: "home")
74+
let selectButton = try view.find(button: "Select")
75+
#expect(selectButton.isDisabled())
76+
}
77+
}
78+
}
79+
80+
@Test
81+
func testDirectoryExpansion() async throws {
82+
let host = "test-expansion.coder"
83+
let sut = FilePicker(host: host, outputAbsPath: .constant(""))
84+
let view = sut
85+
86+
let url = URL(string: "http://\(host):4")!
87+
88+
try Mock(
89+
url: url.appendingPathComponent("/api/v0/list-directory"),
90+
statusCode: 200,
91+
data: [.post: Client.encoder.encode(mockResponse)]
92+
).register()
93+
94+
try await ViewHosting.host(view) {
95+
try await sut.inspection.inspect { view in
96+
try #expect(await eventually { @MainActor in
97+
_ = try view.find(ViewType.List.self)
98+
return true
99+
})
100+
101+
let disclosureGroup = try view.find(ViewType.DisclosureGroup.self)
102+
#expect(view.findAll(ViewType.DisclosureGroup.self).count == 3)
103+
try disclosureGroup.expand()
104+
105+
// Disclosure group should expand out to 3 more directories
106+
try #expect(await eventually { @MainActor in
107+
return try view.findAll(ViewType.DisclosureGroup.self).count == 6
108+
})
109+
}
110+
}
111+
}
112+
113+
// TODO: The writing of more extensive tests is blocked by ViewInspector,
114+
// as it can't select an item in a list...
115+
}

‎Coder-Desktop/Coder-DesktopTests/Util.swift

+25
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,28 @@ class MockFileSyncDaemon: FileSyncDaemon {
5757
}
5858

5959
extension Inspection: @unchecked Sendable, @retroactive InspectionEmissary {}
60+
61+
public func eventually(
62+
timeout: Duration = .milliseconds(500),
63+
interval: Duration = .milliseconds(10),
64+
condition: @escaping () async throws -> Bool
65+
) async throws -> Bool {
66+
let endTime = ContinuousClock.now.advanced(by: timeout)
67+
68+
var lastError: Error?
69+
70+
while ContinuousClock.now < endTime {
71+
do {
72+
if try await condition() { return true }
73+
lastError = nil
74+
} catch {
75+
lastError = error
76+
try await Task.sleep(for: interval)
77+
}
78+
}
79+
80+
if let lastError {
81+
throw lastError
82+
}
83+
return false
84+
}

‎Coder-Desktop/CoderSDK/AgentLS.swift

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
public extension Client {
2+
// The Client's URL MUST be set to that of an accessible agent
3+
func listAgentDirectory(_ req: LSRequest) async throws(ClientError) -> LSResponse {
4+
let res = try await request("/api/v0/list-directory", method: .post, body: req)
5+
guard res.resp.statusCode == 200 else {
6+
throw responseAsError(res)
7+
}
8+
return try decode(LSResponse.self, from: res.data)
9+
}
10+
}
11+
12+
public struct LSRequest: Sendable, Codable {
13+
// e.g. [], ["repos", "coder"]
14+
public let path: [String]
15+
// Whether the supplied path is relative to the user's home directory,
16+
// or the root directory.
17+
public let relativity: LSRelativity
18+
19+
public init(path: [String], relativity: LSRelativity) {
20+
self.path = path
21+
self.relativity = relativity
22+
}
23+
24+
public enum LSRelativity: String, Sendable, Codable {
25+
case root
26+
case home
27+
}
28+
}
29+
30+
public struct LSResponse: Sendable, Codable {
31+
public let absolute_path: [String]
32+
// e.g. Windows: "C:\\Users\\coder"
33+
// Linux: "/home/coder"
34+
public let absolute_path_string: String
35+
public let contents: [LSFile]
36+
}
37+
38+
public struct LSFile: Sendable, Codable {
39+
public let name: String
40+
// e.g. "C:\\Users\\coder\\hello.txt"
41+
// "/home/coder/hello.txt"
42+
public let absolute_path_string: String
43+
public let is_dir: Bool
44+
}

‎Coder-Desktop/CoderSDK/Client.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Foundation
22

3-
public struct Client {
3+
public struct Client: Sendable {
44
public let url: URL
55
public var token: String?
66
public var headers: [HTTPHeader]

0 commit comments

Comments
 (0)
Please sign in to comment.