- Notifications
You must be signed in to change notification settings - Fork3
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
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 | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
// 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 | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
var isExpanded: Bool { | ||
get { innerIsExpanded } | ||
set { | ||
if !newValue { | ||
withAnimation { self.innerIsExpanded = false } | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
} 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 | ||
) | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.