Skip to content

Commit 720a67e

Browse files
committed
Add custom commands to widget context menu
1 parent a9a2170 commit 720a67e

File tree

5 files changed

+200
-82
lines changed

5 files changed

+200
-82
lines changed

Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ public final class GraphicalUserInterfaceController {
2525
))
2626
}
2727
}
28+
suggestionWidget.onCustomCommandClicked = { name in
29+
Task {
30+
let commandHandler = PseudoCommandHandler()
31+
await commandHandler.handleCustomDomain(name: name)
32+
}
33+
}
2834
}
2935
}
3036
}

Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,36 @@ struct PseudoCommandHandler {
6868
))
6969
}
7070

71+
func handleCustomDomain(name: String) async {
72+
guard let editor = await getEditorContent(sourceEditor: nil)
73+
else {
74+
do {
75+
try await Environment.triggerAction(name)
76+
} catch {
77+
let presenter = PresentInWindowSuggestionPresenter()
78+
presenter.presentError(error)
79+
}
80+
return
81+
}
82+
83+
let handler = WindowBaseCommandHandler()
84+
do {
85+
try await handler.handleCustomCommand(name: name, editor: editor)
86+
} catch {
87+
let presenter = PresentInWindowSuggestionPresenter()
88+
presenter.presentError(error)
89+
}
90+
}
91+
7192
func acceptSuggestion() async {
7293
if UserDefaults.shared.value(for: \.acceptSuggestionWithAccessibilityAPI) {
73-
guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor.latestXcode else { return }
94+
guard let xcode = ActiveApplicationMonitor.activeXcode ?? ActiveApplicationMonitor
95+
.latestXcode else { return }
7496
let application = AXUIElementCreateApplication(xcode.processIdentifier)
7597
guard let focusElement = application.focusedElement,
7698
focusElement.description == "Source Editor"
7799
else { return }
78-
guard let (content, lines, cursorPosition) = await getFileContent(sourceEditor: nil)
100+
guard let (content, lines, _, cursorPosition) = await getFileContent(sourceEditor: nil)
79101
else {
80102
PresentInWindowSuggestionPresenter()
81103
.presentErrorMessage("Unable to get file content.")
@@ -153,9 +175,14 @@ struct PseudoCommandHandler {
153175
}
154176
}
155177

156-
private extension PseudoCommandHandler {
178+
extension PseudoCommandHandler {
157179
func getFileContent(sourceEditor: AXUIElement?) async
158-
-> (content: String, lines: [String], cursorPosition: CursorPosition)?
180+
-> (
181+
content: String,
182+
lines: [String],
183+
selections: [CursorRange],
184+
cursorPosition: CursorPosition
185+
)?
159186
{
160187
guard let xcode = ActiveApplicationMonitor.activeXcode
161188
?? ActiveApplicationMonitor.latestXcode else { return nil }
@@ -166,20 +193,8 @@ private extension PseudoCommandHandler {
166193
guard let selectionRange = focusElement.selectedTextRange else { return nil }
167194
let content = focusElement.value
168195
let split = content.breakLines()
169-
let selectedPosition = selectionRange.upperBound
170-
// find row and col from content at selected position
171-
var rowIndex = 0
172-
var count = 0
173-
var colIndex = 0
174-
for (i, row) in split.enumerated() {
175-
if count + row.count > selectedPosition {
176-
rowIndex = i
177-
colIndex = selectedPosition - count
178-
break
179-
}
180-
count += row.count
181-
}
182-
return (content, split, CursorPosition(line: rowIndex, character: colIndex))
196+
let range = convertRangeToCursorRange(selectionRange, in: content)
197+
return (content, split, [range], range.end)
183198
}
184199

185200
func getFileURL() async -> URL? {
@@ -211,7 +226,9 @@ private extension PseudoCommandHandler {
211226
lines: content.lines,
212227
uti: uti,
213228
cursorPosition: content.cursorPosition,
214-
selections: [],
229+
selections: content.selections.map {
230+
.init(start: $0.start, end: $0.end)
231+
},
215232
tabSize: tabSize,
216233
indentSize: indentSize,
217234
usesTabsForIndentation: usesTabsForIndentation
@@ -241,6 +258,32 @@ private extension PseudoCommandHandler {
241258
}
242259
return range
243260
}
261+
262+
func convertRangeToCursorRange(
263+
_ range: ClosedRange<Int>,
264+
in content: String
265+
) -> CursorRange {
266+
let lines = content.breakLines()
267+
guard !lines.isEmpty else { return CursorRange(start: .zero, end: .zero) }
268+
var countS = 0
269+
var countE = 0
270+
var cursorRange = CursorRange(start: .zero, end: .outOfScope)
271+
for (i, line) in lines.enumerated() {
272+
if countS <= range.lowerBound && range.lowerBound < countS + line.count {
273+
cursorRange.start = .init(line: i, character: range.lowerBound - countS)
274+
}
275+
if countE <= range.upperBound && range.upperBound < countE + line.count {
276+
cursorRange.end = .init(line: i, character: range.upperBound - countE)
277+
break
278+
}
279+
countS += line.count
280+
countE += line.count
281+
}
282+
if cursorRange.end == .outOfScope {
283+
cursorRange.end = .init(line: lines.endIndex - 1, character: lines.last?.count ?? 0)
284+
}
285+
return cursorRange
286+
}
244287
}
245288

246289
public extension String {

Core/Sources/SuggestionWidget/SuggestionWidgetController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public final class SuggestionWidgetController {
3939
panelViewModel: suggestionPanelViewModel,
4040
onOpenChatClicked: { [weak self] in
4141
self?.onOpenChatClicked()
42+
},
43+
onCustomCommandClicked: { [weak self] name in
44+
self?.onCustomCommandClicked(name)
4245
}
4346
)
4447
)
@@ -101,6 +104,7 @@ public final class SuggestionWidgetController {
101104
private var colorScheme: ColorScheme = .light
102105

103106
public var onOpenChatClicked: () -> Void = {}
107+
public var onCustomCommandClicked: (String) -> Void = { _ in }
104108
public var dataSource: SuggestionWidgetDataSource?
105109

106110
public nonisolated init() {

Core/Sources/SuggestionWidget/WidgetView.swift

Lines changed: 85 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ struct WidgetView: View {
1717
@State var isHovering: Bool = false
1818
@State var processingProgress: Double = 0
1919
var onOpenChatClicked: () -> Void = {}
20+
var onCustomCommandClicked: (String) -> Void = { _ in }
2021

2122
var body: some View {
2223
Circle().fill(isHovering ? .white.opacity(0.8) : .white.opacity(0.3))
@@ -77,7 +78,8 @@ struct WidgetView: View {
7778
WidgetContextMenu(
7879
widgetViewModel: viewModel,
7980
isChatOpen: panelViewModel.isPanelDisplayed && panelViewModel.chat != nil,
80-
onOpenChatClicked: onOpenChatClicked
81+
onOpenChatClicked: onOpenChatClicked,
82+
onCustomCommandClicked: onCustomCommandClicked
8183
)
8284
}
8385
}
@@ -103,84 +105,92 @@ struct WidgetContextMenu: View {
103105
@AppStorage(\.forceOrderWidgetToFront) var forceOrderWidgetToFront
104106
@AppStorage(\.disableSuggestionFeatureGlobally) var disableSuggestionFeatureGlobally
105107
@AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList
108+
@AppStorage(\.customCommands) var customCommands
106109
@ObservedObject var widgetViewModel: WidgetViewModel
107110
@State var projectPath: String?
108111
var isChatOpen: Bool
109112
var onOpenChatClicked: () -> Void = {}
113+
var onCustomCommandClicked: (String) -> Void = { _ in }
110114

111115
var body: some View {
112116
Group {
113-
if !isChatOpen {
114-
Button(action: {
115-
onOpenChatClicked()
116-
}) {
117-
Text("Open Chat")
117+
Group { // Commands
118+
if !isChatOpen {
119+
Button(action: {
120+
onOpenChatClicked()
121+
}) {
122+
Text("Open Chat")
123+
}
118124
}
125+
126+
customCommandMenu()
119127
}
120128

121129
Divider()
122130

123-
Button(action: {
124-
useGlobalChat.toggle()
125-
}) {
126-
Text("Use Global Chat")
127-
if useGlobalChat {
128-
Image(systemName: "checkmark")
129-
}
130-
}
131-
132-
Button(action: {
133-
realtimeSuggestionToggle.toggle()
134-
}) {
135-
Text("Realtime Suggestion")
136-
if realtimeSuggestionToggle {
137-
Image(systemName: "checkmark")
138-
}
139-
}
140-
141-
Button(action: {
142-
acceptSuggestionWithAccessibilityAPI.toggle()
143-
}, label: {
144-
Text("Accept Suggestion with Accessibility API")
145-
if acceptSuggestionWithAccessibilityAPI {
146-
Image(systemName: "checkmark")
147-
}
148-
})
149-
150-
Button(action: {
151-
hideCommonPrecedingSpacesInSuggestion.toggle()
152-
}, label: {
153-
Text("Hide Common Preceding Spaces in Suggestion")
154-
if hideCommonPrecedingSpacesInSuggestion {
155-
Image(systemName: "checkmark")
156-
}
157-
})
158-
159-
Button(action: {
160-
forceOrderWidgetToFront.toggle()
161-
}, label: {
162-
Text("Force Order Widget to Front")
163-
if forceOrderWidgetToFront {
164-
Image(systemName: "checkmark")
131+
Group { // Settings
132+
Button(action: {
133+
useGlobalChat.toggle()
134+
}) {
135+
Text("Use Global Chat")
136+
if useGlobalChat {
137+
Image(systemName: "checkmark")
138+
}
165139
}
166-
})
167-
168-
if let projectPath, disableSuggestionFeatureGlobally {
169-
let matchedPath = suggestionFeatureEnabledProjectList.first { path in
170-
projectPath.hasPrefix(path)
140+
141+
Button(action: {
142+
realtimeSuggestionToggle.toggle()
143+
}) {
144+
Text("Realtime Suggestion")
145+
if realtimeSuggestionToggle {
146+
Image(systemName: "checkmark")
147+
}
171148
}
149+
172150
Button(action: {
173-
if matchedPath != nil {
174-
suggestionFeatureEnabledProjectList
175-
.removeAll { path in path == matchedPath }
176-
} else {
177-
suggestionFeatureEnabledProjectList.append(projectPath)
151+
acceptSuggestionWithAccessibilityAPI.toggle()
152+
}, label: {
153+
Text("Accept Suggestion with Accessibility API")
154+
if acceptSuggestionWithAccessibilityAPI {
155+
Image(systemName: "checkmark")
178156
}
179-
}) {
180-
if matchedPath == nil {
181-
Text("Add to Suggestion-Enabled Project List")
182-
} else {
183-
Text("Remove from Suggestion-Enabled Project List")
157+
})
158+
159+
Button(action: {
160+
hideCommonPrecedingSpacesInSuggestion.toggle()
161+
}, label: {
162+
Text("Hide Common Preceding Spaces in Suggestion")
163+
if hideCommonPrecedingSpacesInSuggestion {
164+
Image(systemName: "checkmark")
165+
}
166+
})
167+
168+
Button(action: {
169+
forceOrderWidgetToFront.toggle()
170+
}, label: {
171+
Text("Force Order Widget to Front")
172+
if forceOrderWidgetToFront {
173+
Image(systemName: "checkmark")
174+
}
175+
})
176+
177+
if let projectPath, disableSuggestionFeatureGlobally {
178+
let matchedPath = suggestionFeatureEnabledProjectList.first { path in
179+
projectPath.hasPrefix(path)
180+
}
181+
Button(action: {
182+
if matchedPath != nil {
183+
suggestionFeatureEnabledProjectList
184+
.removeAll { path in path == matchedPath }
185+
} else {
186+
suggestionFeatureEnabledProjectList.append(projectPath)
187+
}
188+
}) {
189+
if matchedPath == nil {
190+
Text("Add to Suggestion-Enabled Project List")
191+
} else {
192+
Text("Remove from Suggestion-Enabled Project List")
193+
}
184194
}
185195
}
186196
}
@@ -211,6 +221,18 @@ struct WidgetContextMenu: View {
211221
}
212222
}
213223
}
224+
225+
func customCommandMenu() -> some View {
226+
Menu("Custom Commands") {
227+
ForEach(customCommands, id: \.name) { command in
228+
Button(action: {
229+
onCustomCommandClicked(command.name)
230+
}) {
231+
Text(command.name)
232+
}
233+
}
234+
}
235+
}
214236
}
215237

216238
struct WidgetView_Preview: PreviewProvider {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import CopilotModel
2+
import XCTest
3+
@testable import Service
4+
5+
class PseudoCommandHandlerFileProcessingTests: XCTestCase {
6+
func test_convert_range_0_0() {
7+
XCTAssertEqual(
8+
PseudoCommandHandler().convertRangeToCursorRange(0...0, in: "\n"),
9+
CursorRange(start: .zero, end: .init(line: 0, character: 0))
10+
)
11+
}
12+
13+
func test_convert_range_same_line() {
14+
XCTAssertEqual(
15+
PseudoCommandHandler().convertRangeToCursorRange(1...5, in: "123456789\n"),
16+
CursorRange(start: .init(line: 0, character: 1), end: .init(line: 0, character: 5))
17+
)
18+
}
19+
20+
func test_convert_range_multiple_line() {
21+
XCTAssertEqual(
22+
PseudoCommandHandler()
23+
.convertRangeToCursorRange(5...25, in: "123456789\n123456789\n123456789\n"),
24+
CursorRange(start: .init(line: 0, character: 5), end: .init(line: 2, character: 5))
25+
)
26+
}
27+
28+
func test_convert_range_all_line() {
29+
XCTAssertEqual(
30+
PseudoCommandHandler()
31+
.convertRangeToCursorRange(0...29, in: "123456789\n123456789\n123456789\n"),
32+
CursorRange(start: .init(line: 0, character: 0), end: .init(line: 2, character: 9))
33+
)
34+
}
35+
36+
func test_convert_range_out_of_range() {
37+
XCTAssertEqual(
38+
PseudoCommandHandler()
39+
.convertRangeToCursorRange(0...70, in: "123456789\n123456789\n123456789\n"),
40+
CursorRange(start: .init(line: 0, character: 0), end: .init(line: 3, character: 0))
41+
)
42+
}
43+
}

0 commit comments

Comments
 (0)