Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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

Merged
ethanndickson merged 3 commits intomainfromethan/stubbed-fs-ui
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes fromall 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: 8 additions & 1 deletionCoder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,12 @@ struct DesktopApp: App {
.environmentObject(appDelegate.state)
}
.windowResizability(.contentSize)
Window("Coder File Sync", id: Windows.fileSync.rawValue) {
FileSyncConfig<CoderVPNService, MutagenDaemon>()
.environmentObject(appDelegate.state)
.environmentObject(appDelegate.fileSyncDaemon)
.environmentObject(appDelegate.vpn)
}
}
}

Expand DownExpand Up@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
await self.state.handleTokenExpiry()
}
}, content: {
VPNMenu<CoderVPNService>().frame(width: 256)
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
.environmentObject(self.vpn)
.environmentObject(self.state)
.environmentObject(self.fileSyncDaemon)
}
))
// Subscribe to system VPN updates
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff 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) {}
}
6 changes: 5 additions & 1 deletionCoder-Desktop/Coder-Desktop/VPN/MenuState.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import VPNLib

struct Agent: Identifiable, Equatable, Comparable {
struct Agent: Identifiable, Equatable, Comparable, Hashable {
let id: UUID
let name: String
let status: AgentStatus
Expand DownExpand Up@@ -135,6 +135,10 @@ struct VPNMenuState {
return items.sorted()
}

var onlineAgents: [Agent] {
agents.map(\.value).filter { $0.primaryHost != nil }
}

mutating func clear() {
agents.removeAll()
workspaces.removeAll()
Expand Down
118 changes: 118 additions & 0 deletionsCoder-Desktop/Coder-Desktop/Views/FileSync/FileSyncConfig.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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
View file
Open in desktop
Original file line numberDiff line numberDiff 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()
}
}
6 changes: 2 additions & 4 deletionsCoder-Desktop/Coder-Desktop/Views/LoginForm.swift
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,10 +48,8 @@ struct LoginForm: View {
loginError = nil
}
}
)) {
Button("OK", role: .cancel) {}.keyboardShortcut(.defaultAction)
Copy link
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I discovered this was unnecessary and removed it everywhere. AnOK button always appears on alerts, and it always gets selected by pressing enter.

} message: {
Text(loginError?.description ?? "")
)) {} message: {
Text(loginError?.description ?? "An unknown error occurred.")
}.disabled(loading)
.frame(width: 550)
.fixedSize()
Expand Down
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
Toggle(isOn: $state.useLiteralHeaders) {
Text("HTTP Headers")
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
if vpn.state != .disabled { Text("Cannot be modified while Coder Connect is enabled.") }
if!vpn.state.canBeStarted { Text("Cannot be modified while Coder Connect is enabled.") }
}
.controlSize(.large)

Expand DownExpand Up@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
LiteralHeaderModal(existingHeader: header)
}.onTapGesture {
selectedHeader = nil
}.disabled(vpn.state != .disabled)
}.disabled(!vpn.state.canBeStarted)
.onReceive(inspection.notice) { inspection.visit(self, $0) } // ViewInspector
}
}
16 changes: 16 additions & 0 deletionsCoder-Desktop/Coder-Desktop/Views/StatusDot.swift
View file
Open in desktop
Original file line numberDiff line numberDiff 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)
}
}
}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import SwiftUI
import VPNLib

struct VPNMenu<VPN: VPNService>: View {
struct VPNMenu<VPN: VPNService, FS: FileSyncDaemon>: View {
@EnvironmentObject var vpn: VPN
@EnvironmentObject var fileSync: FS
@EnvironmentObject var state: AppState
@Environment(\.openSettings) private var openSettings
@Environment(\.openWindow) private var openWindow
Expand DownExpand Up@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
}.buttonStyle(.plain)
TrayDivider()
}
if vpn.state == .connected {
Button {
openWindow(id: .fileSync)
} label: {
ButtonRowView {
HStack {
// TODO: A future PR will provide users a way to recover from a daemon failure without
// needing to restart the app
if case .failed = fileSync.state, sessionsHaveError(fileSync.sessionState) {
Image(systemName: "exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
.frame(width: 12, height: 12).help("One or more sync sessions have errors")
}
Text("File sync")
}
}
}.buttonStyle(.plain)
TrayDivider()
}
if vpn.state == .failed(.systemExtensionError(.needsUserApproval)) {
Button {
openSystemExtensionSettings()
Expand DownExpand Up@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
appState.login(baseAccessURL: URL(string: "http://127.0.0.1:8080")!, sessionToken: "")
// appState.clearSession()

return VPNMenu<PreviewVPN>().frame(width: 256)
return VPNMenu<PreviewVPN, PreviewFileSync>().frame(width: 256)
.environmentObject(PreviewVPN())
.environmentObject(appState)
.environmentObject(PreviewFileSync())
}
#endif
Loading
Loading

[8]ページ先頭

©2009-2025 Movatter.jp