Skip to content

Commit d859b34

Browse files
committed
Fix deterministic workspace indexing and search
1 parent cec6287 commit d859b34

6 files changed

Lines changed: 132 additions & 115 deletions

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Find.swift

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ extension WorkspaceDocument.SearchState {
8888
///
8989
/// - Parameter query: The search query to search for.
9090
func search(_ query: String) async {
91-
clearResults()
91+
await resetResults()
9292

9393
await MainActor.run {
9494
self.searchQuery = query
@@ -104,44 +104,33 @@ extension WorkspaceDocument.SearchState {
104104
}
105105

106106
let asyncController = SearchIndexer.AsyncManager(index: indexer)
107-
let evaluateResultGroup = DispatchGroup()
108-
let evaluateSearchQueue = DispatchQueue(label: "app.codeedit.CodeEdit.EvaluateSearch")
109107

110108
let searchStream = await asyncController.search(query: searchQuery, 20)
111109
for try await result in searchStream {
112-
for file in result.results {
113-
let fileURL = file.url
114-
let fileScore = file.score
115-
let capturedRegexPattern = regexPattern
116-
117-
evaluateSearchQueue.async(group: evaluateResultGroup) {
118-
evaluateResultGroup.enter()
119-
Task { [weak self] in
120-
guard let self else {
121-
evaluateResultGroup.leave()
122-
return
123-
}
124-
125-
let result = await self.evaluateSearchResult(
110+
await withTaskGroup(of: SearchResultModel?.self) { group in
111+
for file in result.results {
112+
let fileURL = file.url
113+
let fileScore = file.score
114+
let capturedRegexPattern = regexPattern
115+
116+
group.addTask { [weak self] in
117+
await self?.evaluateSearchResult(
126118
fileURL: fileURL,
127119
fileScore: fileScore,
128120
regexPattern: capturedRegexPattern
129121
)
122+
}
123+
}
130124

131-
if let result = result {
132-
await self.appendNewResultsToTempResults(newResult: result)
133-
}
134-
evaluateResultGroup.leave()
125+
for await evaluatedResult in group {
126+
if let evaluatedResult {
127+
await appendNewResultsToTempResults(newResult: evaluatedResult)
135128
}
136129
}
137130
}
138131
}
139132

140-
evaluateResultGroup.notify(queue: evaluateSearchQueue) {
141-
Task { @MainActor [weak self] in
142-
self?.setSearchResults()
143-
}
144-
}
133+
await setSearchResults()
145134
}
146135

147136
/// Appends a new search result to the temporary search results array on the main thread.
@@ -346,7 +335,13 @@ extension WorkspaceDocument.SearchState {
346335

347336
/// Resets the search results along with counts for overall results and file-specific results.
348337
func clearResults() {
349-
DispatchQueue.main.async {
338+
Task {
339+
await resetResults()
340+
}
341+
}
342+
343+
private func resetResults() async {
344+
await MainActor.run {
350345
self.searchResult.removeAll()
351346
self.searchResultsCount = 0
352347
self.searchResultsFileCount = 0

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+Index.swift

Lines changed: 96 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,64 +11,115 @@ extension WorkspaceDocument.SearchState {
1111
/// Adds the contents of the current workspace URL to the search index.
1212
/// That means that the contents of the workspace will be indexed and searchable.
1313
func addProjectToIndex() {
14+
startProjectIndexing()
15+
}
16+
17+
/// Starts project indexing without blocking the caller.
18+
func startProjectIndexing() {
1419
guard let indexer = indexer else { return }
1520
guard let url = workspace.fileURL else { return }
1621

17-
indexStatus = .indexing(progress: 0.0)
22+
indexingTask?.cancel()
23+
indexingTask = Task { [weak self] in
24+
await self?.indexProject(indexer: indexer, url: url)
25+
}
26+
}
27+
28+
/// Indexes the project and returns after the index has been flushed.
29+
func indexProject() async {
30+
let previousTask = indexingTask
31+
previousTask?.cancel()
32+
await previousTask?.value
33+
indexingTask = nil
34+
35+
guard let indexer = indexer else { return }
36+
guard let url = workspace.fileURL else { return }
37+
38+
await indexProject(indexer: indexer, url: url)
39+
}
40+
41+
private func indexProject(indexer: SearchIndexer, url: URL) async {
1842
let uuidString = UUID().uuidString
43+
await publishIndexingStarted(id: uuidString)
44+
45+
let filePaths = getFileURLs(at: url)
46+
let asyncController = SearchIndexer.AsyncManager(index: indexer)
47+
var lastProgress: Double = 0
48+
49+
for await (file, index) in AsyncFileIterator(fileURLs: filePaths) {
50+
guard !Task.isCancelled else {
51+
await publishIndexingCancelled(id: uuidString)
52+
return
53+
}
54+
55+
_ = await asyncController.addText(files: [file], flushWhenComplete: false)
56+
let progress = Double(index + 1) / Double(filePaths.count)
57+
58+
if progress - lastProgress > 0.005 || index == filePaths.count - 1 {
59+
lastProgress = progress
60+
await publishIndexingProgress(id: uuidString, progress: progress)
61+
}
62+
}
63+
64+
guard !Task.isCancelled else {
65+
await publishIndexingCancelled(id: uuidString)
66+
return
67+
}
68+
69+
asyncController.index.flush()
70+
await publishIndexingFinished(id: uuidString)
71+
}
72+
73+
@MainActor
74+
private func publishIndexingStarted(id: String) {
75+
indexStatus = .indexing(progress: 0.0)
1976
let createInfo: [String: Any] = [
20-
"id": uuidString,
77+
"id": id,
2178
"action": "create",
2279
"title": "Indexing | Processing files",
2380
"message": "Creating an index to enable fast and accurate searches within your codebase.",
2481
"isLoading": true
2582
]
2683
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: createInfo)
84+
}
2785

28-
Task.detached {
29-
let filePaths = self.getFileURLs(at: url)
30-
31-
let asyncController = SearchIndexer.AsyncManager(index: indexer)
32-
var lastProgress: Double = 0
33-
34-
for await (file, index) in AsyncFileIterator(fileURLs: filePaths) {
35-
_ = await asyncController.addText(files: [file], flushWhenComplete: false)
36-
let progress = Double(index) / Double(filePaths.count)
37-
38-
// Send only if difference is > 0.5%, to keep updates from sending too frequently
39-
if progress - lastProgress > 0.005 || index == filePaths.count - 1 {
40-
lastProgress = progress
41-
await MainActor.run {
42-
self.indexStatus = .indexing(progress: progress)
43-
}
44-
let updateInfo: [String: Any] = [
45-
"id": uuidString,
46-
"action": "update",
47-
"percentage": progress
48-
]
49-
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo)
50-
}
51-
}
52-
asyncController.index.flush()
86+
@MainActor
87+
private func publishIndexingProgress(id: String, progress: Double) {
88+
indexStatus = .indexing(progress: progress)
89+
let updateInfo: [String: Any] = [
90+
"id": id,
91+
"action": "update",
92+
"percentage": progress
93+
]
94+
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo)
95+
}
5396

54-
await MainActor.run {
55-
self.indexStatus = .done
56-
}
57-
let updateInfo: [String: Any] = [
58-
"id": uuidString,
59-
"action": "update",
60-
"title": "Finished indexing",
61-
"isLoading": false
62-
]
63-
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo)
64-
65-
let deleteInfo = [
66-
"id": uuidString,
67-
"action": "deleteWithDelay",
68-
"delay": 4.0
69-
]
70-
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo)
71-
}
97+
@MainActor
98+
private func publishIndexingFinished(id: String) {
99+
indexStatus = .done
100+
let updateInfo: [String: Any] = [
101+
"id": id,
102+
"action": "update",
103+
"title": "Finished indexing",
104+
"isLoading": false
105+
]
106+
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: updateInfo)
107+
108+
let deleteInfo: [String: Any] = [
109+
"id": id,
110+
"action": "deleteWithDelay",
111+
"delay": 4.0
112+
]
113+
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo)
114+
}
115+
116+
@MainActor
117+
private func publishIndexingCancelled(id: String) {
118+
let deleteInfo: [String: Any] = [
119+
"id": id,
120+
"action": "delete"
121+
]
122+
NotificationCenter.default.post(name: .taskNotification, object: nil, userInfo: deleteInfo)
72123
}
73124

74125
/// Retrieves an array of file URLs within the specified directory URL.

CodeEdit/Features/Documents/WorkspaceDocument/WorkspaceDocument+SearchState.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ extension WorkspaceDocument {
3838
@Published var shouldFocusSearchField: Bool = false
3939

4040
unowned var workspace: WorkspaceDocument
41+
var indexingTask: Task<Void, Never>?
4142
var tempSearchResults = [SearchResultModel]()
4243
var caseSensitive: Bool = false
4344
var indexer: SearchIndexer?
@@ -53,6 +54,10 @@ extension WorkspaceDocument {
5354
addProjectToIndex()
5455
}
5556

57+
deinit {
58+
indexingTask?.cancel()
59+
}
60+
5661
/// Represents the compare options to be used for find and replace.
5762
///
5863
/// The `replaceOptions` property is a lazy, computed property that dynamically calculates

CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindAndReplaceTests.swift

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import XCTest
99
@testable import CodeEdit
1010

1111
@MainActor
12-
final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_body_length
12+
final class FindAndReplaceTests: XCTestCase {
1313
private var directory: URL!
1414
private var files: [CEWorkspaceFile] = []
1515
private var mockWorkspace: WorkspaceDocument!
@@ -64,20 +64,11 @@ final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_bod
6464
files[1].parent = folder1File
6565
files[2].parent = folder2File
6666

67-
mockWorkspace.searchState?.addProjectToIndex()
67+
await mockWorkspace.searchState?.indexProject()
6868

6969
// NOTE: This is a temporary solution. In the future, a file watcher should track file updates
7070
// and trigger an index update.
71-
let startTime = Date()
72-
let timeoutInSeconds = 2.0
73-
while searchState.indexStatus != .done {
74-
// Check every 0.1 seconds for index completion
75-
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
76-
if Date().timeIntervalSince(startTime) > timeoutInSeconds {
77-
XCTFail("TIMEOUT: Indexing took to long or did not complete.")
78-
return
79-
}
80-
}
71+
XCTAssertEqual(searchState.indexStatus, .done)
8172

8273
// Retrieve indexed documents from the indexer
8374
guard let documentsInIndex = searchState.indexer?.documents() else {
@@ -99,15 +90,8 @@ final class FindAndReplaceTests: XCTestCase { // swiftlint:disable:this type_bod
9990
// IMPORTANT:
10091
// This is only a temporary solution, in the feature a file watcher would track the file update
10192
// and trigger a index update.
102-
searchState.addProjectToIndex()
103-
let startTime = Date()
104-
while searchState.indexStatus != .done {
105-
try? await Task.sleep(nanoseconds: 100_000_000)
106-
if Date().timeIntervalSince(startTime) > 2.0 {
107-
XCTFail("TIMEOUT: Indexing took to long or did not complete.")
108-
return
109-
}
110-
}
93+
await searchState.indexProject()
94+
XCTAssertEqual(searchState.indexStatus, .done)
11195
}
11296

11397
func testFindAndReplace() async {

CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+FindTests.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,20 +60,11 @@ final class FindTests: XCTestCase {
6060
files[1].parent = parent1
6161
files[2].parent = parent2
6262

63-
await mockWorkspace.searchState?.addProjectToIndex()
63+
await mockWorkspace.searchState?.indexProject()
6464

6565
// The following code also tests whether the workspace is indexed correctly
6666
// Wait until the index is up to date and flushed
67-
let startTime = Date()
68-
let timeoutInSeconds = 2.0
69-
while searchState.indexStatus != .done {
70-
// Check every 0.1 seconds for index completion
71-
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
72-
if Date().timeIntervalSince(startTime) > timeoutInSeconds {
73-
XCTFail("TIMEOUT: Indexing took to long or did not complete.")
74-
return
75-
}
76-
}
67+
XCTAssertEqual(searchState.indexStatus, .done)
7768

7869
// Retrieve indexed documents from the indexer
7970
guard let documentsInIndex = searchState.indexer?.documents() else {

CodeEditTests/Features/Documents/WorkspaceDocument+SearchState+IndexTests.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,11 @@ final class WorkspaceDocumentIndexTests: XCTestCase {
6363
files[1].parent = folder1File
6464
files[2].parent = folder2File
6565

66-
await mockWorkspace.searchState?.addProjectToIndex()
66+
await mockWorkspace.searchState?.indexProject()
6767

6868
// The following code also tests whether the workspace is indexed correctly
6969
// Wait until the index is up to date and flushed
70-
let startTime = Date()
71-
let timeoutInSeconds = 2.0
72-
while searchState.indexStatus != .done {
73-
// Check every 0.1 seconds for index completion
74-
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
75-
if Date().timeIntervalSince(startTime) > timeoutInSeconds {
76-
XCTFail("TIMEOUT: Indexing took to long or did not complete.")
77-
return
78-
}
79-
}
70+
XCTAssertEqual(searchState.indexStatus, .done)
8071

8172
// Retrieve indexed documents from the indexer
8273
guard let documentsInIndex = searchState.indexer?.documents() else {

0 commit comments

Comments
 (0)