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

Commitf0cf155

Browse files
feat: add stubbed file sync UI (#116)
Closes#66Relates to#63The UI differs a fair bit from the wireframes & figma designs in the interest of being able to use the stock SwiftUI Table view. The biggest difference is that a modal is used to insert new file syncs, as opposed to creating them inline. This was done as it's a lot harder to do that within a SwiftUI table. This design is also consistent with tables used in Apple's own settings pages, and the HTTP header table in app settings.https://github.com/user-attachments/assets/7c3d98b9-36c4-430b-ac6f-7064b6b8dc31The UI is mostly non-functional, it still needs to be wired up over gRPC, including conversions from Mutagen data types.As a result, the file sync button on the menu will not appear unless the file sync feature flag is enabled in settings.Right now, the workspace dropdown menu is populated from the online agents (any row with a coloured dot on the menubar menu)There's no tests for this since ViewInspector still does not support Tables.
1 parentd95289b commitf0cf155

19 files changed

+415
-44
lines changed

‎Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ struct DesktopApp: App {
2323
.environmentObject(appDelegate.state)
2424
}
2525
.windowResizability(.contentSize)
26+
Window("Coder File Sync", id:Windows.fileSync.rawValue){
27+
FileSyncConfig<CoderVPNService,MutagenDaemon>()
28+
.environmentObject(appDelegate.state)
29+
.environmentObject(appDelegate.fileSyncDaemon)
30+
.environmentObject(appDelegate.vpn)
31+
}
2632
}
2733
}
2834

@@ -61,9 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
6167
awaitself.state.handleTokenExpiry()
6268
}
6369
}, content:{
64-
VPNMenu<CoderVPNService>().frame(width:256)
70+
VPNMenu<CoderVPNService,MutagenDaemon>().frame(width:256)
6571
.environmentObject(self.vpn)
6672
.environmentObject(self.state)
73+
.environmentObject(self.fileSyncDaemon)
6774
}
6875
))
6976
// Subscribe to system VPN updates
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import VPNLib
2+
3+
@MainActor
4+
finalclassPreviewFileSync:FileSyncDaemon{
5+
varsessionState:[VPNLib.FileSyncSession]=[]
6+
7+
varstate:DaemonState=.running
8+
9+
init(){}
10+
11+
func refreshSessions()async{}
12+
13+
func start()asyncthrows(DaemonError){
14+
state=.running
15+
}
16+
17+
func stop()async{
18+
state=.stopped
19+
}
20+
21+
func createSession(localPath _:String, agentHost _:String, remotePath _:String)asyncthrows(DaemonError){}
22+
23+
func deleteSessions(ids _:[String])asyncthrows(VPNLib.DaemonError){}
24+
}

‎Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftUI
33
import VPNLib
44

5-
structAgent:Identifiable,Equatable,Comparable{
5+
structAgent:Identifiable,Equatable,Comparable,Hashable{
66
letid:UUID
77
letname:String
88
letstatus:AgentStatus
@@ -135,6 +135,10 @@ struct VPNMenuState {
135135
return items.sorted()
136136
}
137137

138+
varonlineAgents:[Agent]{
139+
agents.map(\.value).filter{ $0.primaryHost!=nil}
140+
}
141+
138142
mutatingfunc clear(){
139143
agents.removeAll()
140144
workspaces.removeAll()
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
structFileSyncConfig<VPN:VPNService, FS:FileSyncDaemon>:View{
5+
@EnvironmentObjectvarvpn:VPN
6+
@EnvironmentObjectvarfileSync:FS
7+
8+
@Stateprivatevarselection:FileSyncSession.ID?
9+
@StateprivatevaraddingNewSession:Bool=false
10+
@StateprivatevareditingSession:FileSyncSession?
11+
12+
@Stateprivatevarloading:Bool=false
13+
@StateprivatevardeleteError:DaemonError?
14+
15+
varbody:someView{
16+
Group{
17+
Table(fileSync.sessionState, selection: $selection){
18+
TableColumn("Local Path"){
19+
Text($0.alphaPath).help($0.alphaPath)
20+
}.width(min:200, ideal:240)
21+
TableColumn("Workspace", value: \.agentHost)
22+
.width(min:100, ideal:120)
23+
TableColumn("Remote Path", value: \.betaPath)
24+
.width(min:100, ideal:120)
25+
TableColumn("Status"){ $0.status.body}
26+
.width(min:80, ideal:100)
27+
TableColumn("Size"){ itemin
28+
Text(item.size)
29+
}
30+
.width(min:60, ideal:80)
31+
}
32+
.contextMenu(forSelectionType:FileSyncSession.ID.self, menu:{ _in},
33+
primaryAction:{ selectedSessionsin
34+
iflet session= selectedSessions.first{
35+
editingSession= fileSync.sessionState.first(where:{ $0.id== session})
36+
}
37+
})
38+
.frame(minWidth:400, minHeight:200)
39+
.padding(.bottom,25)
40+
.overlay(alignment:.bottom){
41+
VStack(alignment:.leading, spacing:0){
42+
Divider()
43+
HStack(spacing:0){
44+
Button{
45+
addingNewSession=true
46+
} label:{
47+
Image(systemName:"plus")
48+
.frame(width:24, height:24)
49+
}.disabled(vpn.menuState.agents.isEmpty)
50+
Divider()
51+
Button{
52+
Task{
53+
loading=true
54+
defer{ loading=false}
55+
dothrows(DaemonError){
56+
tryawait fileSync.deleteSessions(ids:[selection!])
57+
} catch{
58+
deleteError= error
59+
}
60+
await fileSync.refreshSessions()
61+
selection=nil
62+
}
63+
} label:{
64+
Image(systemName:"minus").frame(width:24, height:24)
65+
}.disabled(selection==nil)
66+
iflet selection{
67+
iflet selectedSession= fileSync.sessionState.first(where:{ $0.id== selection}){
68+
Divider()
69+
Button{
70+
// TODO: Pause & Unpause
71+
} label:{
72+
switch selectedSession.status{
73+
case.paused:
74+
Image(systemName:"play").frame(width:24, height:24)
75+
default:
76+
Image(systemName:"pause").frame(width:24, height:24)
77+
}
78+
}
79+
}
80+
}
81+
}
82+
.buttonStyle(.borderless)
83+
}
84+
.background(.primary.opacity(0.04))
85+
.fixedSize(horizontal:false, vertical:true)
86+
}
87+
}.sheet(isPresented: $addingNewSession){
88+
FileSyncSessionModal<VPN,FS>()
89+
.frame(width:700)
90+
}.sheet(item: $editingSession){ sessionin
91+
FileSyncSessionModal<VPN,FS>(existingSession: session)
92+
.frame(width:700)
93+
}.alert("Error", isPresented:Binding(
94+
get:{ deleteError!=nil},
95+
set:{ isPresentedin
96+
if !isPresented{
97+
deleteError=nil
98+
}
99+
}
100+
)){} message:{
101+
Text(deleteError?.description??"An unknown error occurred.")
102+
}.task{
103+
while !Task.isCancelled{
104+
await fileSync.refreshSessions()
105+
try?awaitTask.sleep(for:.seconds(2))
106+
}
107+
}.disabled(loading)
108+
}
109+
}
110+
111+
#if DEBUG
112+
#Preview{
113+
FileSyncConfig<PreviewVPN,PreviewFileSync>()
114+
.environmentObject(AppState(persistent:false))
115+
.environmentObject(PreviewVPN())
116+
.environmentObject(PreviewFileSync())
117+
}
118+
#endif
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import SwiftUI
2+
import VPNLib
3+
4+
structFileSyncSessionModal<VPN:VPNService, FS:FileSyncDaemon>:View{
5+
varexistingSession:FileSyncSession?
6+
@Environment(\.dismiss)privatevardismiss
7+
@EnvironmentObjectprivatevarvpn:VPN
8+
@EnvironmentObjectprivatevarfileSync:FS
9+
10+
@StateprivatevarlocalPath:String=""
11+
@Stateprivatevarworkspace:Agent?
12+
@StateprivatevarremotePath:String=""
13+
14+
@Stateprivatevarloading:Bool=false
15+
@StateprivatevarcreateError:DaemonError?
16+
17+
varbody:someView{
18+
letagents= vpn.menuState.onlineAgents
19+
VStack(spacing:0){
20+
Form{
21+
Section{
22+
HStack(spacing:5){
23+
TextField("Local Path", text: $localPath)
24+
Spacer()
25+
Button{
26+
letpanel=NSOpenPanel()
27+
panel.directoryURL=FileManager.default.homeDirectoryForCurrentUser
28+
panel.allowsMultipleSelection=false
29+
panel.canChooseDirectories=true
30+
panel.canChooseFiles=false
31+
if panel.runModal()==.OK{
32+
localPath= panel.url?.path(percentEncoded:false)??"<none>"
33+
}
34+
} label:{
35+
Image(systemName:"folder")
36+
}
37+
}
38+
}
39+
Section{
40+
Picker("Workspace", selection: $workspace){
41+
ForEach(agents, id: \.id){ agentin
42+
Text(agent.primaryHost!).tag(agent)
43+
}
44+
// HACK: Silence error logs for no-selection.
45+
Divider().tag(nilasAgent?)
46+
}
47+
}
48+
Section{
49+
TextField("Remote Path", text: $remotePath)
50+
}
51+
}.formStyle(.grouped).scrollDisabled(true).padding(.horizontal)
52+
Divider()
53+
HStack{
54+
Spacer()
55+
Button("Cancel", action:{dismiss()}).keyboardShortcut(.cancelAction)
56+
Button(existingSession==nil?"Add":"Save"){Task{awaitsubmit()}}
57+
.keyboardShortcut(.defaultAction)
58+
}.padding(20)
59+
}.onAppear{
60+
iflet existingSession{
61+
localPath= existingSession.alphaPath
62+
workspace= agents.first{ $0.primaryHost== existingSession.agentHost}
63+
remotePath= existingSession.betaPath
64+
}else{
65+
// Set the picker to the first agent by default
66+
workspace= agents.first
67+
}
68+
}.disabled(loading)
69+
.alert("Error", isPresented:Binding(
70+
get:{ createError!=nil},
71+
set:{if $0{ createError=nil}}
72+
)){} message:{
73+
Text(createError?.description??"An unknown error occurred.")
74+
}
75+
}
76+
77+
func submit()async{
78+
createError=nil
79+
guardlet workspaceelse{
80+
return
81+
}
82+
loading=true
83+
defer{ loading=false}
84+
dothrows(DaemonError){
85+
iflet existingSession{
86+
// TODO: Support selecting & deleting multiple sessions at once
87+
tryawait fileSync.deleteSessions(ids:[existingSession.id])
88+
}
89+
tryawait fileSync.createSession(
90+
localPath: localPath,
91+
agentHost: workspace.primaryHost!,
92+
remotePath: remotePath
93+
)
94+
} catch{
95+
createError= error
96+
return
97+
}
98+
dismiss()
99+
}
100+
}

‎Coder-Desktop/Coder-Desktop/Views/LoginForm.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,8 @@ struct LoginForm: View {
4848
loginError=nil
4949
}
5050
}
51-
)){
52-
Button("OK", role:.cancel){}.keyboardShortcut(.defaultAction)
53-
} message:{
54-
Text(loginError?.description??"")
51+
)){} message:{
52+
Text(loginError?.description??"An unknown error occurred.")
5553
}.disabled(loading)
5654
.frame(width:550)
5755
.fixedSize()

‎Coder-Desktop/Coder-Desktop/Views/Settings/LiteralHeadersSection.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
1515
Toggle(isOn: $state.useLiteralHeaders){
1616
Text("HTTP Headers")
1717
Text("When enabled, these headers will be included on all outgoing HTTP requests.")
18-
if vpn.state!=.disabled{Text("Cannot be modified while Coder Connect is enabled.")}
18+
if!vpn.state.canBeStarted{Text("Cannot be modified while Coder Connect is enabled.")}
1919
}
2020
.controlSize(.large)
2121

@@ -65,7 +65,7 @@ struct LiteralHeadersSection<VPN: VPNService>: View {
6565
LiteralHeaderModal(existingHeader: header)
6666
}.onTapGesture{
6767
selectedHeader=nil
68-
}.disabled(vpn.state!=.disabled)
68+
}.disabled(!vpn.state.canBeStarted)
6969
.onReceive(inspection.notice){ inspection.visit(self, $0)} // ViewInspector
7070
}
7171
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import SwiftUI
2+
3+
structStatusDot:View{
4+
letcolor:Color
5+
6+
varbody:someView{
7+
ZStack{
8+
Circle()
9+
.fill(color.opacity(0.4))
10+
.frame(width:12, height:12)
11+
Circle()
12+
.fill(color.opacity(1.0))
13+
.frame(width:7, height:7)
14+
}
15+
}
16+
}

‎Coder-Desktop/Coder-Desktop/Views/VPNMenu.swiftrenamed to‎Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenu.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
2+
import VPNLib
23

3-
structVPNMenu<VPN:VPNService>:View{
4+
structVPNMenu<VPN:VPNService, FS:FileSyncDaemon>:View{
45
@EnvironmentObjectvarvpn:VPN
6+
@EnvironmentObjectvarfileSync:FS
57
@EnvironmentObjectvarstate:AppState
68
@Environment(\.openSettings)privatevaropenSettings
79
@Environment(\.openWindow)privatevaropenWindow
@@ -60,6 +62,24 @@ struct VPNMenu<VPN: VPNService>: View {
6062
}.buttonStyle(.plain)
6163
TrayDivider()
6264
}
65+
if vpn.state==.connected{
66+
Button{
67+
openWindow(id:.fileSync)
68+
} label:{
69+
ButtonRowView{
70+
HStack{
71+
// TODO: A future PR will provide users a way to recover from a daemon failure without
72+
// needing to restart the app
73+
if case.failed= fileSync.state,sessionsHaveError(fileSync.sessionState){
74+
Image(systemName:"exclamationmark.arrow.trianglehead.2.clockwise.rotate.90")
75+
.frame(width:12, height:12).help("One or more sync sessions have errors")
76+
}
77+
Text("File sync")
78+
}
79+
}
80+
}.buttonStyle(.plain)
81+
TrayDivider()
82+
}
6383
if vpn.state==.failed(.systemExtensionError(.needsUserApproval)){
6484
Button{
6585
openSystemExtensionSettings()
@@ -119,8 +139,9 @@ func openSystemExtensionSettings() {
119139
appState.login(baseAccessURL:URL(string:"http://127.0.0.1:8080")!, sessionToken:"")
120140
// appState.clearSession()
121141

122-
returnVPNMenu<PreviewVPN>().frame(width:256)
142+
returnVPNMenu<PreviewVPN,PreviewFileSync>().frame(width:256)
123143
.environmentObject(PreviewVPN())
124144
.environmentObject(appState)
145+
.environmentObject(PreviewFileSync())
125146
}
126147
#endif

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp