Skip to content

Commit 9084a74

Browse files
committed
Migrate account settings to HostApp
1 parent e2b8f3f commit 9084a74

3 files changed

Lines changed: 423 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import AppKit
2+
import Client
3+
import GitHubCopilotService
4+
import Preferences
5+
import SuggestionModel
6+
import SwiftUI
7+
8+
struct CopilotView: View {
9+
class Settings: ObservableObject {
10+
@AppStorage(\.nodePath) var nodePath: String
11+
@AppStorage(\.runNodeWith) var runNodeWith
12+
@AppStorage("username") var username: String = ""
13+
14+
init() {}
15+
}
16+
17+
@Environment(\.openURL) var openURL
18+
@StateObject var settings = Settings()
19+
20+
@State var copilotStatus: GitHubCopilotAccountStatus?
21+
@State var message: String?
22+
@State var userCode: String?
23+
@State var version: String?
24+
@State var isRunningAction: Bool = false
25+
@State var isUserCodeCopiedAlertPresented = false
26+
27+
var body: some View {
28+
HStack {
29+
VStack(alignment: .leading, spacing: 8) {
30+
Form {
31+
TextField(text: $settings.nodePath, prompt: Text("node")) {
32+
Text("Path to Node")
33+
}
34+
35+
Picker(selection: $settings.runNodeWith) {
36+
ForEach(NodeRunner.allCases, id: \.rawValue) { runner in
37+
switch runner {
38+
case .env:
39+
Text("/usr/bin/env").tag(runner)
40+
case .bash:
41+
Text("/bin/bash -i -l").tag(runner)
42+
case .shell:
43+
Text("$SHELL -i -l").tag(runner)
44+
}
45+
}
46+
} label: {
47+
Text("Run Node with")
48+
}
49+
50+
Text(
51+
"You may have to restart the helper app to apply the changes. To do so, simply close the helper app by clicking on the menu bar icon that looks like a steer wheel, it will automatically restart as needed."
52+
)
53+
.foregroundColor(.secondary)
54+
}
55+
56+
VStack(alignment: .leading) {
57+
Text("Copilot Version: \(version ?? "Loading..")")
58+
Text("Status: \(copilotStatus?.description ?? "Loading..")")
59+
60+
HStack(alignment: .center) {
61+
Button("Refresh") { checkStatus() }
62+
if copilotStatus == .notSignedIn {
63+
Button("Sign In") { signIn() }
64+
.alert(isPresented: $isUserCodeCopiedAlertPresented) {
65+
Alert(
66+
title: Text(userCode ?? ""),
67+
message: Text(
68+
"The user code is pasted into your clipboard, please paste it in the opened website to login.\nAfter that, click \"Confirm Sign-in\" to finish."
69+
),
70+
dismissButton: .default(Text("OK"))
71+
)
72+
}
73+
Button("Confirm Sign-in") { confirmSignIn() }
74+
}
75+
if copilotStatus == .ok || copilotStatus == .alreadySignedIn ||
76+
copilotStatus == .notAuthorized
77+
{
78+
Button("Sign Out") { signOut() }
79+
}
80+
if isRunningAction {
81+
ActivityIndicatorView()
82+
}
83+
}
84+
.opacity(isRunningAction ? 0.8 : 1)
85+
.disabled(isRunningAction)
86+
}
87+
.padding(8)
88+
.frame(maxWidth: .infinity, alignment: .leading)
89+
.overlay {
90+
RoundedRectangle(cornerRadius: 8)
91+
.stroke(Color(nsColor: .separatorColor), style: .init(lineWidth: 1))
92+
}
93+
}
94+
Spacer()
95+
}.overlay(alignment: .topTrailing) {
96+
if let message {
97+
Text(message)
98+
.padding(.horizontal, 4)
99+
.padding(.vertical, 2)
100+
.background(
101+
RoundedRectangle(cornerRadius: 4)
102+
.fill(Color.red)
103+
)
104+
.frame(maxWidth: 200, alignment: .topTrailing)
105+
}
106+
}.onAppear {
107+
if isPreview { return }
108+
checkStatus()
109+
}
110+
}
111+
112+
func checkStatus() {
113+
Task {
114+
isRunningAction = true
115+
defer { isRunningAction = false }
116+
do {
117+
let service = try getService()
118+
copilotStatus = try await service.checkStatus()
119+
version = try await service.getVersion()
120+
isRunningAction = false
121+
} catch {
122+
message = error.localizedDescription
123+
}
124+
}
125+
}
126+
127+
func signIn() {
128+
Task {
129+
isRunningAction = true
130+
defer { isRunningAction = false }
131+
do {
132+
let service = try getService()
133+
let (uri, userCode) = try await service.signInInitiate()
134+
self.userCode = userCode
135+
guard let url = URL(string: uri) else {
136+
message = "Verification URI is incorrect."
137+
return
138+
}
139+
let pasteboard = NSPasteboard.general
140+
pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil)
141+
pasteboard.setString(userCode, forType: NSPasteboard.PasteboardType.string)
142+
message = "Usercode \(userCode) already copied!"
143+
openURL(url)
144+
isUserCodeCopiedAlertPresented = true
145+
} catch {
146+
message = error.localizedDescription
147+
}
148+
}
149+
}
150+
151+
func confirmSignIn() {
152+
Task {
153+
isRunningAction = true
154+
defer { isRunningAction = false }
155+
do {
156+
let service = try getService()
157+
guard let userCode else {
158+
message = "Usercode is empty."
159+
return
160+
}
161+
let (username, status) = try await service.signInConfirm(userCode: userCode)
162+
self.settings.username = username
163+
copilotStatus = status
164+
} catch {
165+
message = error.localizedDescription
166+
}
167+
}
168+
}
169+
170+
func signOut() {
171+
Task {
172+
isRunningAction = true
173+
defer { isRunningAction = false }
174+
do {
175+
let service = try getService()
176+
copilotStatus = try await service.signOut()
177+
} catch {
178+
message = error.localizedDescription
179+
}
180+
}
181+
}
182+
}
183+
184+
struct ActivityIndicatorView: NSViewRepresentable {
185+
func makeNSView(context _: Context) -> NSProgressIndicator {
186+
let progressIndicator = NSProgressIndicator()
187+
progressIndicator.style = .spinning
188+
progressIndicator.appearance = NSAppearance(named: .vibrantLight)
189+
progressIndicator.controlSize = .small
190+
progressIndicator.startAnimation(nil)
191+
return progressIndicator
192+
}
193+
194+
func updateNSView(_: NSProgressIndicator, context _: Context) {
195+
// No-op
196+
}
197+
}
198+
199+
struct CopilotView_Previews: PreviewProvider {
200+
static var previews: some View {
201+
VStack(alignment: .leading, spacing: 8) {
202+
CopilotView(copilotStatus: .notSignedIn, version: "1.0.0")
203+
204+
CopilotView(
205+
copilotStatus: .alreadySignedIn,
206+
message: "Error"
207+
)
208+
209+
CopilotView(copilotStatus: .alreadySignedIn, isRunningAction: true)
210+
}
211+
.frame(height: 800)
212+
.padding(.all, 8)
213+
}
214+
}
215+
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import AppKit
2+
import Client
3+
import Preferences
4+
import SuggestionModel
5+
import SwiftUI
6+
7+
final class OpenAIViewSettings: ObservableObject {
8+
static let availableLocalizedLocales = Locale.availableLocalizedLocales
9+
@AppStorage(\.openAIAPIKey) var openAIAPIKey: String
10+
@AppStorage(\.chatGPTModel) var chatGPTModel: String
11+
@AppStorage(\.chatGPTEndpoint) var chatGPTEndpoint: String
12+
@AppStorage(\.chatGPTLanguage) var chatGPTLanguage: String
13+
@AppStorage(\.chatGPTMaxToken) var chatGPTMaxToken: Int
14+
@AppStorage(\.chatGPTTemperature) var chatGPTTemperature: Double
15+
@AppStorage(\.chatGPTMaxMessageCount) var chatGPTMaxMessageCount: Int
16+
init() {}
17+
}
18+
19+
struct OpenAIView: View {
20+
let apiKeyURL = URL(string: "https://platform.openai.com/account/api-keys")!
21+
let modelURL = URL(
22+
string: "https://platform.openai.com/docs/models/model-endpoint-compatibility"
23+
)!
24+
@Environment(\.openURL) var openURL
25+
@StateObject var settings = OpenAIViewSettings()
26+
27+
var body: some View {
28+
Form {
29+
HStack {
30+
TextField(text: $settings.openAIAPIKey, prompt: Text("sk-*")) {
31+
Text("OpenAI API Key")
32+
}.textFieldStyle(.roundedBorder)
33+
Button(action: {
34+
openURL(apiKeyURL)
35+
}) {
36+
Image(systemName: "questionmark.circle.fill")
37+
}.buttonStyle(.plain)
38+
}
39+
40+
HStack {
41+
Picker(selection: $settings.chatGPTModel) {
42+
if !settings.chatGPTModel.isEmpty,
43+
ChatGPTModel(rawValue: settings.chatGPTModel) == nil
44+
{
45+
Text(settings.chatGPTModel).tag(settings.chatGPTModel)
46+
}
47+
ForEach(ChatGPTModel.allCases, id: \.self) { model in
48+
Text(model.rawValue).tag(model.rawValue)
49+
}
50+
} label: {
51+
Text("ChatGPT Model")
52+
}.pickerStyle(.menu)
53+
Button(action: {
54+
openURL(modelURL)
55+
}) {
56+
Image(systemName: "questionmark.circle.fill")
57+
}.buttonStyle(.plain)
58+
}
59+
60+
TextField(
61+
text: $settings.chatGPTEndpoint,
62+
prompt: Text("https://api.openai.com/v1/chat/completions")
63+
) {
64+
Text("ChatGPT Server")
65+
}.textFieldStyle(.roundedBorder)
66+
67+
if #available(macOS 13.0, *) {
68+
LabeledContent("Reply in Language") {
69+
languagePicker
70+
}
71+
} else {
72+
HStack {
73+
Text("Reply in Language")
74+
languagePicker
75+
}
76+
}
77+
78+
if let model = ChatGPTModel(rawValue: settings.chatGPTModel) {
79+
let binding = Binding(
80+
get: { String(settings.chatGPTMaxToken) },
81+
set: {
82+
if let selectionMaxToken = Int($0) {
83+
settings.chatGPTMaxToken = model
84+
.maxToken < selectionMaxToken ? model
85+
.maxToken : selectionMaxToken
86+
} else {
87+
settings.chatGPTMaxToken = 0
88+
}
89+
}
90+
)
91+
HStack {
92+
Stepper(
93+
value: $settings.chatGPTMaxToken,
94+
in: 0...model.maxToken,
95+
step: 1
96+
) {
97+
Text("Max Token")
98+
}
99+
TextField(text: binding) {
100+
EmptyView()
101+
}
102+
.labelsHidden()
103+
.textFieldStyle(.roundedBorder)
104+
}
105+
}
106+
107+
HStack {
108+
Slider(value: $settings.chatGPTTemperature, in: 0...2, step: 0.1) {
109+
Text("Temperature")
110+
}
111+
112+
Text(
113+
"\(settings.chatGPTTemperature.formatted(.number.precision(.fractionLength(1))))"
114+
)
115+
.monospacedDigit()
116+
}
117+
118+
Picker(
119+
"Memory",
120+
selection: $settings.chatGPTMaxMessageCount
121+
) {
122+
Text("No Limit").tag(0)
123+
Text("3 Messages").tag(3)
124+
Text("5 Messages").tag(5)
125+
Text("7 Messages").tag(7)
126+
}
127+
}
128+
}
129+
130+
var languagePicker: some View {
131+
Menu {
132+
if !settings.chatGPTLanguage.isEmpty,
133+
!OpenAIViewSettings.availableLocalizedLocales
134+
.contains(settings.chatGPTLanguage)
135+
{
136+
Button(
137+
settings.chatGPTLanguage,
138+
action: { self.settings.chatGPTLanguage = settings.chatGPTLanguage }
139+
)
140+
}
141+
Button(
142+
"Auto-detected by ChatGPT",
143+
action: { self.settings.chatGPTLanguage = "" }
144+
)
145+
ForEach(
146+
OpenAIViewSettings.availableLocalizedLocales,
147+
id: \.self
148+
) { localizedLocales in
149+
Button(
150+
localizedLocales,
151+
action: { self.settings.chatGPTLanguage = localizedLocales }
152+
)
153+
}
154+
} label: {
155+
Text(
156+
settings.chatGPTLanguage.isEmpty
157+
? "Auto-detected by ChatGPT"
158+
: settings.chatGPTLanguage
159+
)
160+
}
161+
}
162+
}
163+
164+
struct OpenAIView_Previews: PreviewProvider {
165+
static var previews: some View {
166+
VStack(alignment: .leading, spacing: 8) {
167+
OpenAIView()
168+
}
169+
.frame(height: 800)
170+
.padding(.all, 8)
171+
}
172+
}
173+

0 commit comments

Comments
 (0)