- Notifications
You must be signed in to change notification settings - Fork3
feat: add stubbed file sync UI#116
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,24 @@ | ||
import VPNLib | ||
@MainActor | ||
final class PreviewFileSync: FileSyncDaemon { | ||
var sessionState: [VPNLib.FileSyncSession] = [] | ||
var state: DaemonState = .running | ||
init() {} | ||
func refreshSessions() async {} | ||
func start() async throws(DaemonError) { | ||
state = .running | ||
} | ||
func stop() async { | ||
state = .stopped | ||
} | ||
func createSession(localPath _: String, agentHost _: String, remotePath _: String) async throws(DaemonError) {} | ||
func deleteSessions(ids _: [String]) async throws(VPNLib.DaemonError) {} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
import SwiftUI | ||
import VPNLib | ||
struct FileSyncConfig<VPN: VPNService, FS: FileSyncDaemon>: View { | ||
@EnvironmentObject var vpn: VPN | ||
@EnvironmentObject var fileSync: FS | ||
@State private var selection: FileSyncSession.ID? | ||
@State private var addingNewSession: Bool = false | ||
@State private var editingSession: FileSyncSession? | ||
@State private var loading: Bool = false | ||
@State private var deleteError: DaemonError? | ||
var body: some View { | ||
Group { | ||
Table(fileSync.sessionState, selection: $selection) { | ||
TableColumn("Local Path") { | ||
Text($0.alphaPath).help($0.alphaPath) | ||
}.width(min: 200, ideal: 240) | ||
TableColumn("Workspace", value: \.agentHost) | ||
.width(min: 100, ideal: 120) | ||
TableColumn("Remote Path", value: \.betaPath) | ||
.width(min: 100, ideal: 120) | ||
TableColumn("Status") { $0.status.body } | ||
.width(min: 80, ideal: 100) | ||
TableColumn("Size") { item in | ||
Text(item.size) | ||
} | ||
.width(min: 60, ideal: 80) | ||
} | ||
.contextMenu(forSelectionType: FileSyncSession.ID.self, menu: { _ in }, | ||
primaryAction: { selectedSessions in | ||
if let session = selectedSessions.first { | ||
editingSession = fileSync.sessionState.first(where: { $0.id == session }) | ||
} | ||
}) | ||
.frame(minWidth: 400, minHeight: 200) | ||
.padding(.bottom, 25) | ||
.overlay(alignment: .bottom) { | ||
VStack(alignment: .leading, spacing: 0) { | ||
Divider() | ||
HStack(spacing: 0) { | ||
Button { | ||
addingNewSession = true | ||
} label: { | ||
Image(systemName: "plus") | ||
.frame(width: 24, height: 24) | ||
}.disabled(vpn.menuState.agents.isEmpty) | ||
Divider() | ||
Button { | ||
Task { | ||
loading = true | ||
defer { loading = false } | ||
do throws(DaemonError) { | ||
try await fileSync.deleteSessions(ids: [selection!]) | ||
} catch { | ||
deleteError = error | ||
} | ||
await fileSync.refreshSessions() | ||
selection = nil | ||
} | ||
} label: { | ||
Image(systemName: "minus").frame(width: 24, height: 24) | ||
}.disabled(selection == nil) | ||
if let selection { | ||
if let selectedSession = fileSync.sessionState.first(where: { $0.id == selection }) { | ||
Divider() | ||
Button { | ||
// TODO: Pause & Unpause | ||
} label: { | ||
switch selectedSession.status { | ||
case .paused: | ||
Image(systemName: "play").frame(width: 24, height: 24) | ||
default: | ||
Image(systemName: "pause").frame(width: 24, height: 24) | ||
} | ||
} | ||
} | ||
} | ||
} | ||
.buttonStyle(.borderless) | ||
} | ||
.background(.primary.opacity(0.04)) | ||
.fixedSize(horizontal: false, vertical: true) | ||
} | ||
}.sheet(isPresented: $addingNewSession) { | ||
FileSyncSessionModal<VPN, FS>() | ||
.frame(width: 700) | ||
}.sheet(item: $editingSession) { session in | ||
FileSyncSessionModal<VPN, FS>(existingSession: session) | ||
.frame(width: 700) | ||
}.alert("Error", isPresented: Binding( | ||
get: { deleteError != nil }, | ||
set: { isPresented in | ||
if !isPresented { | ||
deleteError = nil | ||
} | ||
} | ||
)) {} message: { | ||
Text(deleteError?.description ?? "An unknown error occurred.") | ||
}.task { | ||
while !Task.isCancelled { | ||
await fileSync.refreshSessions() | ||
try? await Task.sleep(for: .seconds(2)) | ||
} | ||
}.disabled(loading) | ||
} | ||
} | ||
#if DEBUG | ||
#Preview { | ||
FileSyncConfig<PreviewVPN, PreviewFileSync>() | ||
.environmentObject(AppState(persistent: false)) | ||
.environmentObject(PreviewVPN()) | ||
.environmentObject(PreviewFileSync()) | ||
} | ||
#endif |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import SwiftUI | ||
import VPNLib | ||
struct FileSyncSessionModal<VPN: VPNService, FS: FileSyncDaemon>: View { | ||
var existingSession: FileSyncSession? | ||
@Environment(\.dismiss) private var dismiss | ||
@EnvironmentObject private var vpn: VPN | ||
@EnvironmentObject private var fileSync: FS | ||
@State private var localPath: String = "" | ||
@State private var workspace: Agent? | ||
@State private var remotePath: String = "" | ||
@State private var loading: Bool = false | ||
@State private var createError: DaemonError? | ||
var body: some View { | ||
let agents = vpn.menuState.onlineAgents | ||
VStack(spacing: 0) { | ||
Form { | ||
Section { | ||
HStack(spacing: 5) { | ||
TextField("Local Path", text: $localPath) | ||
Spacer() | ||
Button { | ||
let panel = NSOpenPanel() | ||
panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser | ||
panel.allowsMultipleSelection = false | ||
panel.canChooseDirectories = true | ||
panel.canChooseFiles = false | ||
if panel.runModal() == .OK { | ||
localPath = panel.url?.path(percentEncoded: false) ?? "<none>" | ||
} | ||
} label: { | ||
Image(systemName: "folder") | ||
} | ||
} | ||
} | ||
Section { | ||
Picker("Workspace", selection: $workspace) { | ||
ForEach(agents, id: \.id) { agent in | ||
Text(agent.primaryHost!).tag(agent) | ||
} | ||
// HACK: Silence error logs for no-selection. | ||
Divider().tag(nil as Agent?) | ||
} | ||
} | ||
Section { | ||
TextField("Remote Path", text: $remotePath) | ||
} | ||
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal) | ||
Divider() | ||
HStack { | ||
Spacer() | ||
Button("Cancel", action: { dismiss() }).keyboardShortcut(.cancelAction) | ||
Button(existingSession == nil ? "Add" : "Save") { Task { await submit() }} | ||
.keyboardShortcut(.defaultAction) | ||
}.padding(20) | ||
}.onAppear { | ||
if let existingSession { | ||
localPath = existingSession.alphaPath | ||
workspace = agents.first { $0.primaryHost == existingSession.agentHost } | ||
remotePath = existingSession.betaPath | ||
} else { | ||
// Set the picker to the first agent by default | ||
workspace = agents.first | ||
} | ||
}.disabled(loading) | ||
.alert("Error", isPresented: Binding( | ||
get: { createError != nil }, | ||
set: { if $0 { createError = nil } } | ||
)) {} message: { | ||
Text(createError?.description ?? "An unknown error occurred.") | ||
} | ||
} | ||
func submit() async { | ||
createError = nil | ||
guard let workspace else { | ||
return | ||
} | ||
loading = true | ||
defer { loading = false } | ||
do throws(DaemonError) { | ||
if let existingSession { | ||
// TODO: Support selecting & deleting multiple sessions at once | ||
try await fileSync.deleteSessions(ids: [existingSession.id]) | ||
} | ||
try await fileSync.createSession( | ||
localPath: localPath, | ||
agentHost: workspace.primaryHost!, | ||
remotePath: remotePath | ||
) | ||
} catch { | ||
createError = error | ||
return | ||
} | ||
dismiss() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -48,10 +48,8 @@ struct LoginForm: View { | ||
loginError = nil | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. I discovered this was unnecessary and removed it everywhere. An | ||
)) {} message: { | ||
Text(loginError?.description ?? "An unknown error occurred.") | ||
}.disabled(loading) | ||
.frame(width: 550) | ||
.fixedSize() | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import SwiftUI | ||
struct StatusDot: View { | ||
let color: Color | ||
var body: some View { | ||
ZStack { | ||
Circle() | ||
.fill(color.opacity(0.4)) | ||
.frame(width: 12, height: 12) | ||
Circle() | ||
.fill(color.opacity(1.0)) | ||
.frame(width: 7, height: 7) | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.