Skip to content

Commit 250ea2f

Browse files
committed
Merge branch 'feature/improve-real-time-suggestion-invalidation' into develop
2 parents ea72ac6 + 2eab928 commit 250ea2f

5 files changed

Lines changed: 204 additions & 11 deletions

File tree

Core/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ let package = Package(
106106
"SuggestionInjector",
107107
"XPCShared",
108108
"Environment",
109+
"SuggestionModel",
109110
.product(name: "Preferences", package: "Tool"),
110111
]
111112
),

Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,52 @@ struct PseudoCommandHandler {
4343
// Can't use handler if content is not available.
4444
guard
4545
let editor = await getEditorContent(sourceEditor: sourceEditor),
46-
let filespace = await getFilespace()
47-
else { return }
46+
let filespace = await getFilespace(),
47+
let (workspace, _) = try? await Workspace
48+
.fetchOrCreateWorkspaceIfNeeded(fileURL: filespace.fileURL) else { return }
4849

50+
let fileURL = filespace.fileURL
51+
let presenter = PresentInWindowSuggestionPresenter()
52+
53+
presenter.markAsProcessing(true)
54+
defer { presenter.markAsProcessing(false) }
55+
56+
// Check if the current suggestion is still valid.
4957
if await filespace.validateSuggestions(
5058
lines: editor.lines,
5159
cursorPosition: editor.cursorPosition
5260
) {
5361
return
5462
} else {
55-
PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: filespace.fileURL)
63+
presenter.discardSuggestion(fileURL: filespace.fileURL)
5664
}
65+
66+
let snapshot = Filespace.Snapshot(
67+
linesHash: editor.lines.hashValue,
68+
cursorPosition: editor.cursorPosition
69+
)
70+
71+
guard await filespace.suggestionSourceSnapshot != snapshot else { return }
5772

58-
// Otherwise, get it from pseudo handler directly.
59-
let handler = WindowBaseCommandHandler()
60-
_ = try? await handler.generateRealtimeSuggestions(editor: editor)
73+
do {
74+
try await workspace.generateSuggestions(
75+
forFileAt: fileURL,
76+
editor: editor
77+
)
78+
if let sourceEditor {
79+
_ = await filespace.validateSuggestions(
80+
lines: sourceEditor.content.lines,
81+
cursorPosition: sourceEditor.content.cursorPosition
82+
)
83+
}
84+
if await filespace.presentingSuggestion != nil {
85+
presenter.presentSuggestion(fileURL: fileURL)
86+
} else {
87+
presenter.discardSuggestion(fileURL: fileURL)
88+
}
89+
} catch {
90+
return
91+
}
6192
}
6293

6394
func invalidateRealtimeSuggestionsIfNeeded(fileURL: URL, sourceEditor: SourceEditor) async {

Core/Sources/Service/Workspace.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ final class Filespace {
7474
/// - cursorPosition: cursor position
7575
/// - Returns: `true` if the suggestion is still valid
7676
func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool {
77+
guard let presentingSuggestion else { return false }
78+
7779
// cursor has moved to another line
78-
if cursorPosition.line != suggestionSourceSnapshot.cursorPosition.line {
80+
if cursorPosition.line != presentingSuggestion.position.line {
7981
reset()
8082
return false
8183
}
@@ -87,11 +89,17 @@ final class Filespace {
8789
}
8890

8991
let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n
90-
let suggestionLines = presentingSuggestion?.text.split(separator: "\n") ?? []
92+
let suggestionLines = presentingSuggestion.text.split(separator: "\n")
9193
let suggestionFirstLine = suggestionLines.first ?? ""
9294

9395
// the line content doesn't match the suggestion
94-
if !suggestionFirstLine.hasPrefix(editingLine) {
96+
if cursorPosition.character > 0,
97+
!suggestionFirstLine.hasPrefix(editingLine[..<(editingLine.index(
98+
editingLine.startIndex,
99+
offsetBy: cursorPosition.character,
100+
limitedBy: editingLine.endIndex
101+
) ?? editingLine.endIndex)])
102+
{
95103
reset()
96104
return false
97105
}
@@ -102,8 +110,8 @@ final class Filespace {
102110
return false
103111
}
104112

105-
// the line is empty
106-
if editingLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
113+
// undo to a state before the suggestion was generated
114+
if editingLine.count < presentingSuggestion.position.character {
107115
reset()
108116
return false
109117
}
@@ -451,3 +459,4 @@ extension Workspace {
451459
await _suggestionService?.terminate()
452460
}
453461
}
462+

Core/Tests/ServiceTests/Environment.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import XPCShared
2323
}
2424

2525
Environment.triggerAction = { _ in }
26+
27+
Environment.guessProjectRootURLForFile = { $0 }
2628
}
2729

2830
func completion(text: String, range: CursorRange, uuid: String = "") -> CodeSuggestion {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import Foundation
2+
import XCTest
3+
import SuggestionModel
4+
5+
@testable import Service
6+
7+
class FilespaceSuggestionInvalidationTests: XCTestCase {
8+
@ServiceActor
9+
func prepare(suggestionText: String, cursorPosition: CursorPosition) async throws -> Filespace {
10+
let (_, filespace) = try await Workspace
11+
.fetchOrCreateWorkspaceIfNeeded(fileURL: URL(fileURLWithPath: "file/path/to.swift"))
12+
filespace.suggestions = [
13+
.init(
14+
text: suggestionText,
15+
position: cursorPosition,
16+
uuid: "",
17+
range: .outOfScope,
18+
displayText: ""
19+
)
20+
]
21+
return filespace
22+
}
23+
24+
func test_text_typing_suggestion_should_be_valid() async throws {
25+
let filespace = try await prepare(
26+
suggestionText: "hello man",
27+
cursorPosition: .init(line: 1, character: 0)
28+
)
29+
let isValid = await filespace.validateSuggestions(
30+
lines: ["\n", "hell\n", "\n"],
31+
cursorPosition: .init(line: 1, character: 4)
32+
)
33+
XCTAssertTrue(isValid)
34+
let suggestion = await filespace.presentingSuggestion
35+
XCTAssertNotNil(suggestion)
36+
}
37+
38+
func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws {
39+
let filespace = try await prepare(
40+
suggestionText: "hello man",
41+
cursorPosition: .init(line: 1, character: 0)
42+
)
43+
let isValid = await filespace.validateSuggestions(
44+
lines: ["\n", "hell man\n", "\n"],
45+
cursorPosition: .init(line: 1, character: 4)
46+
)
47+
XCTAssertTrue(isValid)
48+
let suggestion = await filespace.presentingSuggestion
49+
XCTAssertNotNil(suggestion)
50+
}
51+
52+
func test_text_cursor_moved_to_another_line_should_invalidate() async throws {
53+
let filespace = try await prepare(
54+
suggestionText: "hello man",
55+
cursorPosition: .init(line: 1, character: 0)
56+
)
57+
let isValid = await filespace.validateSuggestions(
58+
lines: ["\n", "hell\n", "\n"],
59+
cursorPosition: .init(line: 2, character: 0)
60+
)
61+
XCTAssertFalse(isValid)
62+
let suggestion = await filespace.presentingSuggestion
63+
XCTAssertNil(suggestion)
64+
}
65+
66+
func test_text_cursor_is_invalid_should_invalidate() async throws {
67+
let filespace = try await prepare(
68+
suggestionText: "hello man",
69+
cursorPosition: .init(line: 100, character: 0)
70+
)
71+
let isValid = await filespace.validateSuggestions(
72+
lines: ["\n", "hell\n", "\n"],
73+
cursorPosition: .init(line: 100, character: 4)
74+
)
75+
XCTAssertFalse(isValid)
76+
let suggestion = await filespace.presentingSuggestion
77+
XCTAssertNil(suggestion)
78+
}
79+
80+
func test_line_content_does_not_match_input_should_invalidate() async throws {
81+
let filespace = try await prepare(
82+
suggestionText: "hello man",
83+
cursorPosition: .init(line: 1, character: 0)
84+
)
85+
let isValid = await filespace.validateSuggestions(
86+
lines: ["\n", "helo\n", "\n"],
87+
cursorPosition: .init(line: 1, character: 4)
88+
)
89+
XCTAssertFalse(isValid)
90+
let suggestion = await filespace.presentingSuggestion
91+
XCTAssertNil(suggestion)
92+
}
93+
94+
func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws {
95+
let filespace = try await prepare(
96+
suggestionText: "hello man",
97+
cursorPosition: .init(line: 1, character: 0)
98+
)
99+
let isValid = await filespace.validateSuggestions(
100+
lines: ["\n", "helo\n", "\n"],
101+
cursorPosition: .init(line: 1, character: 100)
102+
)
103+
XCTAssertFalse(isValid)
104+
let suggestion = await filespace.presentingSuggestion
105+
XCTAssertNil(suggestion)
106+
}
107+
108+
func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws {
109+
let filespace = try await prepare(
110+
suggestionText: "hello man",
111+
cursorPosition: .init(line: 1, character: 0)
112+
)
113+
let isValid = await filespace.validateSuggestions(
114+
lines: ["\n", "hello man\n", "\n"],
115+
cursorPosition: .init(line: 1, character: 9)
116+
)
117+
XCTAssertFalse(isValid)
118+
let suggestion = await filespace.presentingSuggestion
119+
XCTAssertNil(suggestion)
120+
}
121+
122+
func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws {
123+
let filespace = try await prepare(
124+
suggestionText: "hello man\nhow are you?",
125+
cursorPosition: .init(line: 1, character: 0)
126+
)
127+
let isValid = await filespace.validateSuggestions(
128+
lines: ["\n", "hello man\n", "\n"],
129+
cursorPosition: .init(line: 1, character: 9)
130+
)
131+
XCTAssertTrue(isValid)
132+
let suggestion = await filespace.presentingSuggestion
133+
XCTAssertNotNil(suggestion)
134+
}
135+
136+
func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate() async throws {
137+
let filespace = try await prepare(
138+
suggestionText: "hello man",
139+
cursorPosition: .init(line: 1, character: 5) // generating man from hello
140+
)
141+
let isValid = await filespace.validateSuggestions(
142+
lines: ["\n", "hell\n", "\n"],
143+
cursorPosition: .init(line: 1, character: 4)
144+
)
145+
XCTAssertFalse(isValid)
146+
let suggestion = await filespace.presentingSuggestion
147+
XCTAssertNil(suggestion)
148+
}
149+
}
150+

0 commit comments

Comments
 (0)