Skip to content

Commit e425548

Browse files
committed
feat: add remote folder picker to file sync GUI
1 parent fe20801 commit e425548

File tree

7 files changed

+463
-2
lines changed

7 files changed

+463
-2
lines changed

Coder-Desktop/Coder-Desktop/Info.plist

Lines changed: 9 additions & 0 deletions
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>
Lines changed: 255 additions & 0 deletions
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

Lines changed: 14 additions & 1 deletion
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()
@@ -71,6 +81,9 @@ struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View {
7181
set: { if !$0 { createError = nil } }
7282
)) {} message: {
7383
Text(createError?.description ?? "An unknown error occurred.")
84+
}.sheet(isPresented: $pickingRemote) {
85+
FilePicker(host: workspace!.primaryHost!, outputAbsPath: $remotePath)
86+
.frame(width: 300, height: 400)
7487
}
7588
}
7689

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy