Skip to content

Commit 0c746d4

Browse files
committed
Migrate custom command settings to HostApp
1 parent 9084a74 commit 0c746d4

1 file changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import Preferences
2+
import SwiftUI
3+
4+
extension List {
5+
@ViewBuilder
6+
func removeBackground() -> some View {
7+
if #available(macOS 13.0, *) {
8+
scrollContentBackground(.hidden)
9+
} else {
10+
background(Color.clear)
11+
}
12+
}
13+
}
14+
15+
struct CustomCommandView: View {
16+
final class Settings: ObservableObject {
17+
@AppStorage(\.customCommands) var customCommands
18+
var illegalNames: [String] {
19+
let existed = customCommands.map(\.name)
20+
let builtin: [String] = [
21+
"Get Suggestions",
22+
"Accept Suggestion",
23+
"Reject Suggestion",
24+
"Next Suggestion",
25+
"Previous Suggestion",
26+
"Toggle Real-time Suggestions",
27+
"Real-time Suggestions",
28+
"Prefetch Suggestions",
29+
"Chat with Selection",
30+
"Prompt to Code",
31+
]
32+
33+
return existed + builtin
34+
}
35+
36+
init(customCommands: AppStorage<[CustomCommand]>? = nil) {
37+
if let list = customCommands {
38+
_customCommands = list
39+
}
40+
}
41+
}
42+
43+
struct EditingCommand {
44+
var isNew: Bool
45+
var command: CustomCommand
46+
}
47+
48+
@State var editingCommand: EditingCommand?
49+
50+
@StateObject var settings = Settings()
51+
52+
var body: some View {
53+
HStack(spacing: 0) {
54+
List {
55+
ForEach(settings.customCommands, id: \.name) { command in
56+
HStack(spacing: 4) {
57+
Image(systemName: "line.3.horizontal")
58+
59+
VStack(alignment: .leading) {
60+
Text(command.name)
61+
62+
Group {
63+
switch command.feature {
64+
case .chatWithSelection:
65+
Text("Chat with Selection")
66+
case .customChat:
67+
Text("Custom Chat")
68+
case .promptToCode:
69+
Text("Prompt to Code")
70+
}
71+
}
72+
.font(.caption)
73+
.foregroundStyle(.tertiary)
74+
}
75+
.contentShape(Rectangle())
76+
.onTapGesture {
77+
editingCommand = .init(isNew: false, command: command)
78+
}
79+
}
80+
.frame(maxWidth: .infinity, alignment: .leading)
81+
.padding(4)
82+
.background(
83+
editingCommand?.command.id == command.id
84+
? Color.primary.opacity(0.05)
85+
: Color.clear,
86+
in: RoundedRectangle(cornerRadius: 4)
87+
)
88+
.contextMenu {
89+
Button("Remove") {
90+
settings.customCommands.removeAll(
91+
where: { $0.name == command.name }
92+
)
93+
}
94+
}
95+
}
96+
.onMove(perform: { indices, newOffset in
97+
settings.customCommands.move(fromOffsets: indices, toOffset: newOffset)
98+
})
99+
}
100+
.removeBackground()
101+
.padding(.vertical, 4)
102+
.listStyle(.plain)
103+
.frame(width: 200)
104+
.background(Color.primary.opacity(0.05))
105+
.overlay {
106+
if settings.customCommands.isEmpty {
107+
Text("""
108+
Empty
109+
Add command with "+" button
110+
""")
111+
.multilineTextAlignment(.center)
112+
}
113+
}
114+
115+
Divider()
116+
117+
if let editingCommand {
118+
EditCustomCommandView(
119+
editingCommand: $editingCommand,
120+
settings: settings
121+
).id(editingCommand.command.id)
122+
} else {
123+
Color.clear
124+
}
125+
}
126+
}
127+
}
128+
129+
struct EditCustomCommandView: View {
130+
@Binding var editingCommand: CustomCommandView.EditingCommand?
131+
var settings: CustomCommandView.Settings
132+
let originalName: String
133+
@State var commandType: CommandType
134+
135+
@State var name: String
136+
@State var prompt: String
137+
@State var systemPrompt: String
138+
@State var continuousMode: Bool
139+
@State var errorMessage: String?
140+
141+
enum CommandType: Int, CaseIterable {
142+
case chatWithSelection
143+
case promptToCode
144+
case customChat
145+
}
146+
147+
init(
148+
editingCommand: Binding<CustomCommandView.EditingCommand?>,
149+
settings: CustomCommandView.Settings
150+
) {
151+
_editingCommand = editingCommand
152+
self.settings = settings
153+
originalName = editingCommand.wrappedValue?.command.name ?? ""
154+
name = originalName
155+
switch editingCommand.wrappedValue?.command.feature {
156+
case let .chatWithSelection(extraSystemPrompt, prompt):
157+
commandType = .chatWithSelection
158+
self.prompt = prompt ?? ""
159+
systemPrompt = extraSystemPrompt ?? ""
160+
continuousMode = false
161+
case let .customChat(systemPrompt, prompt):
162+
commandType = .customChat
163+
self.systemPrompt = systemPrompt ?? ""
164+
self.prompt = prompt ?? ""
165+
continuousMode = false
166+
case let .promptToCode(extraSystemPrompt, prompt, continuousMode):
167+
commandType = .promptToCode
168+
self.prompt = prompt ?? ""
169+
systemPrompt = extraSystemPrompt ?? ""
170+
self.continuousMode = continuousMode ?? false
171+
case .none:
172+
commandType = .chatWithSelection
173+
prompt = ""
174+
systemPrompt = ""
175+
continuousMode = false
176+
}
177+
}
178+
179+
var body: some View {
180+
ScrollView {
181+
Form {
182+
TextField("Name", text: $name)
183+
184+
Picker("Command Type", selection: $commandType) {
185+
ForEach(CommandType.allCases, id: \.rawValue) { commandType in
186+
Text({
187+
switch commandType {
188+
case .chatWithSelection:
189+
return "Chat with Selection"
190+
case .promptToCode:
191+
return "Prompt to Code"
192+
case .customChat:
193+
return "Custom Chat"
194+
}
195+
}() as String).tag(commandType)
196+
}
197+
}
198+
199+
switch commandType {
200+
case .chatWithSelection:
201+
systemPromptTextField(title: "Extra System Prompt")
202+
promptTextField
203+
case .promptToCode:
204+
systemPromptTextField(title: "Extra System Prompt")
205+
promptTextField
206+
continuousModeToggle
207+
case .customChat:
208+
systemPromptTextField()
209+
promptTextField
210+
}
211+
}.padding()
212+
}.safeAreaInset(edge: .bottom) {
213+
VStack {
214+
Divider()
215+
216+
VStack {
217+
Text(
218+
"After renaming or adding a custom command, please restart Xcode to refresh the menu."
219+
)
220+
.foregroundStyle(.secondary)
221+
222+
if let errorMessage {
223+
Text(errorMessage).foregroundColor(.red)
224+
}
225+
226+
HStack {
227+
Spacer()
228+
Button("Cancel") {
229+
editingCommand = nil
230+
}
231+
232+
lazy var newCommand = CustomCommand(
233+
commandId: editingCommand?.command.id ?? UUID().uuidString,
234+
name: name,
235+
feature: {
236+
switch commandType {
237+
case .chatWithSelection:
238+
return .chatWithSelection(
239+
extraSystemPrompt: systemPrompt,
240+
prompt: prompt
241+
)
242+
case .promptToCode:
243+
return .promptToCode(
244+
extraSystemPrompt: systemPrompt,
245+
prompt: prompt,
246+
continuousMode: continuousMode
247+
)
248+
case .customChat:
249+
return .customChat(systemPrompt: systemPrompt, prompt: prompt)
250+
}
251+
}()
252+
)
253+
254+
if editingCommand?.isNew ?? true {
255+
Button("Add") {
256+
guard !settings.illegalNames.contains(newCommand.name) else {
257+
errorMessage = "Command name is illegal."
258+
return
259+
}
260+
guard !newCommand.name.isEmpty else {
261+
errorMessage = "Command name cannot be empty."
262+
return
263+
}
264+
settings.customCommands.append(newCommand)
265+
editingCommand = nil
266+
}
267+
} else {
268+
Button("Update") {
269+
guard !settings.illegalNames.contains(newCommand.name)
270+
|| newCommand.name == originalName
271+
else {
272+
errorMessage = "Command name is illegal."
273+
return
274+
}
275+
guard !newCommand.name.isEmpty else {
276+
errorMessage = "Command name cannot be empty."
277+
return
278+
}
279+
280+
if let index = settings.customCommands.firstIndex(where: {
281+
$0.id == newCommand.id
282+
}) {
283+
settings.customCommands[index] = newCommand
284+
} else {
285+
settings.customCommands.append(newCommand)
286+
}
287+
editingCommand = nil
288+
}
289+
}
290+
}
291+
}
292+
.padding(.horizontal)
293+
}
294+
.padding(.bottom)
295+
.background(.regularMaterial)
296+
}
297+
}
298+
299+
@ViewBuilder
300+
var promptTextField: some View {
301+
VStack(alignment: .leading, spacing: 4) {
302+
Text("Prompt")
303+
TextEditor(text: $prompt)
304+
.font(Font.system(.body, design: .monospaced))
305+
.padding(2)
306+
.frame(minHeight: 120)
307+
.multilineTextAlignment(.leading)
308+
.overlay(
309+
RoundedRectangle(cornerRadius: 1)
310+
.stroke(.black, lineWidth: 1 / 3)
311+
.opacity(0.3)
312+
)
313+
}
314+
.padding(.vertical, 4)
315+
}
316+
317+
@ViewBuilder
318+
func systemPromptTextField(title: String? = nil) -> some View {
319+
VStack(alignment: .leading, spacing: 4) {
320+
Text(title ?? "System Prompt")
321+
TextEditor(text: $systemPrompt)
322+
.font(Font.system(.body, design: .monospaced))
323+
.padding(2)
324+
.frame(minHeight: 120)
325+
.multilineTextAlignment(.leading)
326+
.overlay(
327+
RoundedRectangle(cornerRadius: 1)
328+
.stroke(.black, lineWidth: 1 / 3)
329+
.opacity(0.3)
330+
)
331+
}
332+
.padding(.vertical, 4)
333+
}
334+
335+
var continuousModeToggle: some View {
336+
Toggle("Continuous Mode", isOn: $continuousMode)
337+
}
338+
}
339+
340+
// MARK: - Previews
341+
342+
struct CustomCommandView_Preview: PreviewProvider {
343+
static var previews: some View {
344+
CustomCommandView(
345+
editingCommand: .init(isNew: false, command: .init(
346+
commandId: "1",
347+
name: "Explain Code",
348+
feature: .chatWithSelection(extraSystemPrompt: nil, prompt: "Hello")
349+
)),
350+
settings: .init(customCommands: .init(wrappedValue: [
351+
.init(
352+
commandId: "1",
353+
name: "Explain Code",
354+
feature: .chatWithSelection(extraSystemPrompt: nil, prompt: "Hello")
355+
),
356+
.init(
357+
commandId: "2",
358+
name: "Refactor Code",
359+
feature: .promptToCode(
360+
extraSystemPrompt: nil,
361+
prompt: "Refactor",
362+
continuousMode: false
363+
)
364+
),
365+
], "CustomCommandView_Preview"))
366+
)
367+
}
368+
}
369+
370+
struct EditCustomCommandView_Preview: PreviewProvider {
371+
static var previews: some View {
372+
EditCustomCommandView(
373+
editingCommand: .constant(CustomCommandView.EditingCommand(
374+
isNew: false,
375+
command: .init(
376+
commandId: "4",
377+
name: "Explain Code",
378+
feature: .promptToCode(
379+
extraSystemPrompt: nil,
380+
prompt: "Hello",
381+
continuousMode: false
382+
)
383+
)
384+
)),
385+
settings: .init(customCommands: .init(wrappedValue: [], "CustomCommandView_Preview"))
386+
)
387+
.frame(width: 800)
388+
}
389+
}
390+

0 commit comments

Comments
 (0)