Skip to content

Commit 172d243

Browse files
committed
Add CodeiumChatTab
1 parent 1500e0c commit 172d243

File tree

7 files changed

+556
-52
lines changed

7 files changed

+556
-52
lines changed
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import Preferences
4+
import WebKit
5+
import Workspace
6+
import XcodeInspector
7+
8+
@Reducer
9+
struct CodeiumChatBrowser {
10+
@ObservableState
11+
struct State: Equatable {
12+
var loadingProgress: Double = 0
13+
var isLoading = false
14+
var title = "Codeium Chat"
15+
var error: String?
16+
var url: URL?
17+
}
18+
19+
enum Action: Equatable, BindableAction {
20+
case binding(BindingAction<State>)
21+
22+
case initialize
23+
case loadCurrentWorkspace
24+
case reload
25+
case presentError(String)
26+
case removeError
27+
28+
case observeTitleChange
29+
case updateTitle(String)
30+
case observeURLChange
31+
case updateURL(URL?)
32+
case observeIsLoading
33+
case updateIsLoading(Double)
34+
}
35+
36+
let webView: WKWebView
37+
let uuid = UUID()
38+
39+
private enum CancelID: Hashable {
40+
case observeTitleChange(UUID)
41+
case observeURLChange(UUID)
42+
case observeIsLoading(UUID)
43+
}
44+
45+
@Dependency(\.workspacePool) var workspacePool
46+
47+
var body: some ReducerOf<Self> {
48+
BindingReducer()
49+
50+
Reduce { state, action in
51+
switch action {
52+
case .binding:
53+
return .none
54+
55+
case .initialize:
56+
return .merge(
57+
.run { send in await send(.observeTitleChange) },
58+
.run { send in await send(.observeURLChange) },
59+
.run { send in await send(.observeIsLoading) }
60+
)
61+
62+
case .loadCurrentWorkspace:
63+
return .run { send in
64+
guard let workspaceURL = await XcodeInspector.shared.safe.activeWorkspaceURL
65+
else {
66+
await send(.presentError("Can't find workspace."))
67+
return
68+
}
69+
do {
70+
let workspace = try await workspacePool
71+
.fetchOrCreateWorkspace(workspaceURL: workspaceURL)
72+
let codeiumPlugin = workspace.plugin(for: CodeiumWorkspacePlugin.self)
73+
guard let service = await codeiumPlugin?.codeiumService
74+
else {
75+
await send(.presentError("Can't start service."))
76+
return
77+
}
78+
let url = try await service.getChatURL()
79+
await send(.removeError)
80+
await webView.load(URLRequest(url: url))
81+
} catch {
82+
await send(.presentError(error.localizedDescription))
83+
}
84+
}
85+
86+
case .reload:
87+
webView.reload()
88+
return .none
89+
90+
case .removeError:
91+
state.error = nil
92+
return .none
93+
94+
case let .presentError(error):
95+
state.error = error
96+
return .none
97+
98+
// MARK: Observation
99+
100+
case .observeTitleChange:
101+
let stream = AsyncStream<String> { continuation in
102+
let observation = webView.observe(\.title, options: [.new, .initial]) {
103+
webView, _ in
104+
continuation.yield(webView.title ?? "")
105+
}
106+
107+
continuation.onTermination = { _ in
108+
observation.invalidate()
109+
}
110+
}
111+
112+
return .run { send in
113+
for await title in stream where !title.isEmpty {
114+
try Task.checkCancellation()
115+
await send(.updateTitle(title))
116+
}
117+
}
118+
.cancellable(id: CancelID.observeTitleChange(uuid), cancelInFlight: true)
119+
120+
case let .updateTitle(title):
121+
state.title = title
122+
return .none
123+
124+
case .observeURLChange:
125+
let stream = AsyncStream<URL?> { continuation in
126+
let observation = webView.observe(\.url, options: [.new, .initial]) {
127+
_, url in
128+
if let it = url.newValue {
129+
continuation.yield(it)
130+
}
131+
}
132+
133+
continuation.onTermination = { _ in
134+
observation.invalidate()
135+
}
136+
}
137+
138+
return .run { send in
139+
for await url in stream {
140+
try Task.checkCancellation()
141+
await send(.updateURL(url))
142+
}
143+
}.cancellable(id: CancelID.observeURLChange(uuid), cancelInFlight: true)
144+
145+
case let .updateURL(url):
146+
state.url = url
147+
return .none
148+
149+
case .observeIsLoading:
150+
let stream = AsyncStream<Double> { continuation in
151+
let observation = webView
152+
.observe(\.estimatedProgress, options: [.new]) { _, estimatedProgress in
153+
if let it = estimatedProgress.newValue {
154+
continuation.yield(it)
155+
}
156+
}
157+
158+
continuation.onTermination = { _ in
159+
observation.invalidate()
160+
}
161+
}
162+
163+
return .run { send in
164+
for await isLoading in stream {
165+
try Task.checkCancellation()
166+
await send(.updateIsLoading(isLoading))
167+
}
168+
}.cancellable(id: CancelID.observeIsLoading(uuid), cancelInFlight: true)
169+
170+
case let .updateIsLoading(progress):
171+
state.isLoading = progress != 1
172+
state.loadingProgress = progress
173+
return .none
174+
}
175+
}
176+
}
177+
}
178+
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import AppKit
2+
import ChatTab
3+
import Combine
4+
import ComposableArchitecture
5+
import LicenseManagement
6+
import Logger
7+
import Preferences
8+
import SwiftUI
9+
import WebKit
10+
import XcodeInspector
11+
12+
public class CodeiumChatTab: ChatTab {
13+
public static var name: String { "Codeium Chat" }
14+
15+
struct RestorableState: Codable {}
16+
17+
public struct EditorContent {
18+
public var selectedText: String
19+
public var language: String
20+
public var fileContent: String
21+
22+
public init(selectedText: String, language: String, fileContent: String) {
23+
self.selectedText = selectedText
24+
self.language = language
25+
self.fileContent = fileContent
26+
}
27+
28+
public static var empty: EditorContent {
29+
.init(selectedText: "", language: "", fileContent: "")
30+
}
31+
}
32+
33+
struct Builder: ChatTabBuilder {
34+
var title: String
35+
var buildable: Bool { true }
36+
var afterBuild: (CodeiumChatTab) async -> Void = { _ in }
37+
38+
func build(store: StoreOf<ChatTabItem>) async -> (any ChatTab)? {
39+
let tab = await CodeiumChatTab(chatTabStore: store)
40+
await Task { @MainActor in
41+
_ = tab.store.send(.loadCurrentWorkspace)
42+
}.value
43+
await afterBuild(tab)
44+
return tab
45+
}
46+
}
47+
48+
let store: StoreOf<CodeiumChatBrowser>
49+
let webView: WKWebView
50+
let webViewDelegate: WKWebViewDelegate
51+
var cancellable = Set<AnyCancellable>()
52+
private var observer = NSObject()
53+
54+
@MainActor
55+
public init(chatTabStore: StoreOf<ChatTabItem>) {
56+
let webView = CodeiumWebView(getEditorContent: {
57+
guard let content = await XcodeInspector.shared.getFocusedEditorContent()
58+
else { return .empty }
59+
return .init(
60+
selectedText: content.selectedContent,
61+
language: content.language.rawValue,
62+
fileContent: content.editorContent?.content ?? ""
63+
)
64+
})
65+
self.webView = webView
66+
store = .init(
67+
initialState: .init(),
68+
reducer: { CodeiumChatBrowser(webView: webView) }
69+
)
70+
webViewDelegate = .init(store: store)
71+
72+
super.init(store: chatTabStore)
73+
74+
webView.navigationDelegate = webViewDelegate
75+
webView.uiDelegate = webViewDelegate
76+
webView.store = store
77+
78+
Task {
79+
await CodeiumServiceLifeKeeper.shared.add(self)
80+
}
81+
}
82+
83+
public func start() {
84+
observer = .init()
85+
cancellable = []
86+
chatTabStore.send(.updateTitle("Codeium Chat"))
87+
store.send(.initialize)
88+
89+
do {
90+
var previousURL: URL?
91+
observer.observe { [weak self] in
92+
guard let self else { return }
93+
if store.url != previousURL {
94+
previousURL = store.url
95+
Task { @MainActor in
96+
self.chatTabStore.send(.tabContentUpdated)
97+
}
98+
}
99+
}
100+
}
101+
102+
observer.observe { [weak self] in
103+
guard let self, !store.title.isEmpty else { return }
104+
let title = store.title
105+
Task { @MainActor in
106+
self.chatTabStore.send(.updateTitle(title))
107+
}
108+
}
109+
}
110+
111+
public func buildView() -> any View {
112+
BrowserView(store: store, webView: webView)
113+
}
114+
115+
public func buildTabItem() -> any View {
116+
CodeiumChatTabItem(store: store)
117+
}
118+
119+
public func buildIcon() -> any View {
120+
Image(systemName: "message")
121+
}
122+
123+
public func buildMenu() -> any View {
124+
EmptyView()
125+
}
126+
127+
@MainActor
128+
public func restorableState() -> Data {
129+
let state = store.withState { _ in
130+
RestorableState()
131+
}
132+
133+
return (try? JSONEncoder().encode(state)) ?? Data()
134+
}
135+
136+
public static func restore(
137+
from data: Data,
138+
externalDependency: ExternalDependency
139+
) throws -> any ChatTabBuilder {
140+
let builder = Builder(title: "") { @MainActor _ in }
141+
return builder
142+
}
143+
144+
public static func chatBuilders(externalDependency: Void) -> [ChatTabBuilder] {
145+
[Builder(title: "Codeium Chat (Beta)")]
146+
}
147+
}
148+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import Preferences
4+
import SwiftUI
5+
6+
struct CodeiumChatTabItem: View {
7+
@Perception.Bindable var store: StoreOf<CodeiumChatBrowser>
8+
9+
var body: some View {
10+
WithPerceptionTracking {
11+
Text(store.title)
12+
}
13+
}
14+
}
15+
16+
struct CodeiumChatMenuItem: View {
17+
@Perception.Bindable var store: StoreOf<CodeiumChatBrowser>
18+
19+
var body: some View {
20+
WithPerceptionTracking {
21+
Button("Load Active Workspace") {
22+
store.send(.loadCurrentWorkspace)
23+
}
24+
25+
Button("Reload") {
26+
store.send(.reload)
27+
}
28+
}
29+
}
30+
}
31+
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import ComposableArchitecture
2+
import Foundation
3+
import SharedUIComponents
4+
import SwiftUI
5+
import WebKit
6+
7+
struct BrowserView: View {
8+
@Perception.Bindable var store: StoreOf<CodeiumChatBrowser>
9+
let webView: WKWebView
10+
11+
var body: some View {
12+
WithPerceptionTracking {
13+
VStack(spacing: 0) {
14+
ZStack {
15+
WebView(webView: webView)
16+
}
17+
}
18+
.overlay {
19+
if store.isLoading {
20+
ProgressView()
21+
}
22+
}
23+
}
24+
}
25+
}
26+
27+
struct WebView: NSViewRepresentable {
28+
var webView: WKWebView
29+
30+
func makeNSView(context: Context) -> WKWebView {
31+
return webView
32+
}
33+
34+
func updateNSView(_ nsView: WKWebView, context: Context) {}
35+
}

0 commit comments

Comments
 (0)