Skip to content

Commit b29247e

Browse files
committed
Add Tool/Workspace
1 parent 4219f3c commit b29247e

File tree

8 files changed

+502
-6
lines changed

8 files changed

+502
-6
lines changed

Core/Package.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ let package = Package(
1515
"FileChangeChecker",
1616
"LaunchAgentManager",
1717
"UpdateChecker",
18-
"UserDefaultsObserver",
1918
]
2019
),
2120
.library(
@@ -78,8 +77,8 @@ let package = Package(
7877
"ChatService",
7978
"PromptToCodeService",
8079
"ServiceUpdateMigration",
81-
"UserDefaultsObserver",
8280
"ChatGPTChatTab",
81+
.product(name: "UserDefaultsObserver", package: "Tool"),
8382
.product(name: "AppMonitoring", package: "Tool"),
8483
.product(name: "Environment", package: "Tool"),
8584
.product(name: "SuggestionModel", package: "Tool"),
@@ -149,7 +148,7 @@ let package = Package(
149148
.target(name: "SuggestionService", dependencies: [
150149
"GitHubCopilotService",
151150
"CodeiumService",
152-
"UserDefaultsObserver",
151+
.product(name: "UserDefaultsObserver", package: "Tool"),
153152
]),
154153

155154
// MARK: - Prompt To Code
@@ -226,7 +225,7 @@ let package = Package(
226225
name: "SuggestionWidget",
227226
dependencies: [
228227
"ChatGPTChatTab",
229-
"UserDefaultsObserver",
228+
.product(name: "UserDefaultsObserver", package: "Tool"),
230229
.product(name: "SharedUIComponents", package: "Tool"),
231230
.product(name: "AppMonitoring", package: "Tool"),
232231
.product(name: "Environment", package: "Tool"),
@@ -264,7 +263,6 @@ let package = Package(
264263
.product(name: "Preferences", package: "Tool"),
265264
]
266265
),
267-
.target(name: "UserDefaultsObserver"),
268266
.target(
269267
name: "PlusFeatureFlag",
270268
dependencies: [

Tool/Package.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ let package = Package(
2020
.library(name: "Toast", targets: ["Toast"]),
2121
.library(name: "Keychain", targets: ["Keychain"]),
2222
.library(name: "SharedUIComponents", targets: ["SharedUIComponents"]),
23+
.library(name: "UserDefaultsObserver", targets: ["UserDefaultsObserver"]),
24+
.library(name: "Workspace", targets: ["Workspace"]),
2325
.library(
2426
name: "AppMonitoring",
2527
targets: [
@@ -142,7 +144,9 @@ let package = Package(
142144
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
143145
]
144146
),
145-
147+
148+
.target(name: "UserDefaultsObserver"),
149+
146150
.target(
147151
name: "SharedUIComponents",
148152
dependencies: [
@@ -161,6 +165,17 @@ let package = Package(
161165

162166
.testTarget(name: "ASTParserTests", dependencies: ["ASTParser"]),
163167

168+
.target(
169+
name: "Workspace",
170+
dependencies: [
171+
"UserDefaultsObserver",
172+
"SuggestionModel",
173+
"Environment",
174+
"Logger",
175+
"Preferences",
176+
]
177+
),
178+
164179
// MARK: - Services
165180

166181
.target(
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
3+
public final class UserDefaultsObserver: NSObject {
4+
public var onChange: (() -> Void)?
5+
private weak var object: NSObject?
6+
private let keyPaths: [String]
7+
8+
public init(
9+
object: NSObject,
10+
forKeyPaths keyPaths: [String],
11+
context: UnsafeMutableRawPointer?
12+
) {
13+
self.object = object
14+
self.keyPaths = keyPaths
15+
super.init()
16+
for keyPath in keyPaths {
17+
object.addObserver(self, forKeyPath: keyPath, options: .new, context: context)
18+
}
19+
}
20+
21+
deinit {
22+
for keyPath in keyPaths {
23+
object?.removeObserver(self, forKeyPath: keyPath)
24+
}
25+
}
26+
27+
public override func observeValue(
28+
forKeyPath keyPath: String?,
29+
of object: Any?,
30+
change: [NSKeyValueChangeKey: Any]?,
31+
context: UnsafeMutableRawPointer?
32+
) {
33+
onChange?()
34+
}
35+
}
36+
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import Foundation
2+
3+
final class FileSaveWatcher {
4+
let url: URL
5+
var fileHandle: FileHandle?
6+
var source: DispatchSourceFileSystemObject?
7+
var changeHandler: () -> Void = {}
8+
9+
init(fileURL: URL) {
10+
url = fileURL
11+
startup()
12+
}
13+
14+
deinit {
15+
source?.cancel()
16+
}
17+
18+
func startup() {
19+
if let source = source {
20+
source.cancel()
21+
}
22+
23+
fileHandle = try? FileHandle(forReadingFrom: url)
24+
if let fileHandle = fileHandle {
25+
source = DispatchSource.makeFileSystemObjectSource(
26+
fileDescriptor: fileHandle.fileDescriptor,
27+
eventMask: .link,
28+
queue: .main
29+
)
30+
31+
source?.setEventHandler { [weak self] in
32+
self?.changeHandler()
33+
self?.startup()
34+
}
35+
36+
source?.resume()
37+
}
38+
}
39+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import Environment
2+
import Foundation
3+
import SuggestionModel
4+
5+
public protocol FilespacePropertyKey {
6+
associatedtype Value
7+
}
8+
9+
public struct FilespacePropertyValues {
10+
var storage: [ObjectIdentifier: Any] = [:]
11+
12+
public subscript<K: WorkspacePropertyKey>(key: K.Type) -> K.Value? {
13+
get {
14+
storage[ObjectIdentifier(key)] as? K.Value
15+
}
16+
set {
17+
storage[ObjectIdentifier(key)] = newValue
18+
}
19+
}
20+
}
21+
22+
public final class Filespace {
23+
public struct Snapshot: Equatable {
24+
public var linesHash: Int
25+
public var cursorPosition: CursorPosition
26+
}
27+
28+
public struct CodeMetadata: Equatable {
29+
public var uti: String?
30+
public var tabSize: Int?
31+
public var indentSize: Int?
32+
public var usesTabsForIndentation: Bool?
33+
}
34+
35+
public let fileURL: URL
36+
public private(set) lazy var language: String = languageIdentifierFromFileURL(fileURL).rawValue
37+
public var suggestions: [CodeSuggestion] = [] {
38+
didSet { refreshUpdateTime() }
39+
}
40+
41+
/// stored for pseudo command handler
42+
public var codeMetadata: CodeMetadata = .init()
43+
public var suggestionIndex: Int = 0
44+
public var suggestionSourceSnapshot: Snapshot = .init(linesHash: -1, cursorPosition: .outOfScope)
45+
public var presentingSuggestion: CodeSuggestion? {
46+
guard suggestions.endIndex > suggestionIndex, suggestionIndex >= 0 else { return nil }
47+
return suggestions[suggestionIndex]
48+
}
49+
50+
private(set) var lastSuggestionUpdateTime: Date = Environment.now()
51+
public var isExpired: Bool {
52+
Environment.now().timeIntervalSince(lastSuggestionUpdateTime) > 60 * 3
53+
}
54+
55+
let fileSaveWatcher: FileSaveWatcher
56+
let onClose: (URL) -> Void
57+
58+
deinit {
59+
onClose(fileURL)
60+
}
61+
62+
init(
63+
fileURL: URL,
64+
onSave: @escaping (Filespace) -> Void,
65+
onClose: @escaping (URL) -> Void
66+
) {
67+
self.fileURL = fileURL
68+
self.onClose = onClose
69+
fileSaveWatcher = .init(fileURL: fileURL)
70+
fileSaveWatcher.changeHandler = { [weak self] in
71+
guard let self else { return }
72+
onSave(self)
73+
}
74+
}
75+
76+
public func reset(resetSnapshot: Bool = true) {
77+
suggestions = []
78+
suggestionIndex = 0
79+
if resetSnapshot {
80+
suggestionSourceSnapshot = .init(linesHash: -1, cursorPosition: .outOfScope)
81+
}
82+
}
83+
84+
public func refreshUpdateTime() {
85+
lastSuggestionUpdateTime = Environment.now()
86+
}
87+
88+
/// Validate the suggestion is still valid.
89+
/// - Parameters:
90+
/// - lines: lines of the file
91+
/// - cursorPosition: cursor position
92+
/// - Returns: `true` if the suggestion is still valid
93+
public func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool {
94+
guard let presentingSuggestion else { return false }
95+
96+
// cursor has moved to another line
97+
if cursorPosition.line != presentingSuggestion.position.line {
98+
reset()
99+
return false
100+
}
101+
102+
// the cursor position is valid
103+
guard cursorPosition.line >= 0, cursorPosition.line < lines.count else {
104+
reset()
105+
return false
106+
}
107+
108+
let editingLine = lines[cursorPosition.line].dropLast(1) // dropping \n
109+
let suggestionLines = presentingSuggestion.text.split(separator: "\n")
110+
let suggestionFirstLine = suggestionLines.first ?? ""
111+
112+
// the line content doesn't match the suggestion
113+
if cursorPosition.character > 0,
114+
!suggestionFirstLine.hasPrefix(editingLine[..<(editingLine.index(
115+
editingLine.startIndex,
116+
offsetBy: cursorPosition.character,
117+
limitedBy: editingLine.endIndex
118+
) ?? editingLine.endIndex)])
119+
{
120+
reset()
121+
return false
122+
}
123+
124+
// finished typing the whole suggestion when the suggestion has only one line
125+
if editingLine.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 {
126+
reset()
127+
return false
128+
}
129+
130+
// undo to a state before the suggestion was generated
131+
if editingLine.count < presentingSuggestion.position.character {
132+
reset()
133+
return false
134+
}
135+
136+
return true
137+
}
138+
}
139+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import Foundation
2+
import Preferences
3+
4+
@ServiceActor
5+
final class OpenedFileRecoverableStorage {
6+
let projectRootURL: URL
7+
let userDefault = UserDefaults.shared
8+
let key = "OpenedFileRecoverableStorage"
9+
10+
init(projectRootURL: URL) {
11+
self.projectRootURL = projectRootURL
12+
}
13+
14+
func openFile(fileURL: URL) {
15+
var dict = userDefault.dictionary(forKey: key) ?? [:]
16+
var openedFiles = Set(dict[projectRootURL.path] as? [String] ?? [])
17+
openedFiles.insert(fileURL.path)
18+
dict[projectRootURL.path] = Array(openedFiles)
19+
userDefault.set(dict, forKey: key)
20+
}
21+
22+
func closeFile(fileURL: URL) {
23+
var dict = userDefault.dictionary(forKey: key) ?? [:]
24+
var openedFiles = dict[projectRootURL.path] as? [String] ?? []
25+
openedFiles.removeAll(where: { $0 == fileURL.path })
26+
dict[projectRootURL.path] = openedFiles
27+
userDefault.set(dict, forKey: key)
28+
}
29+
30+
var openedFiles: [URL] {
31+
let dict = userDefault.dictionary(forKey: key) ?? [:]
32+
let openedFiles = dict[projectRootURL.path] as? [String] ?? []
33+
return openedFiles.map { URL(fileURLWithPath: $0) }
34+
}
35+
}
36+

0 commit comments

Comments
 (0)