Skip to content

Commit 7b463fe

Browse files
committed
Move chat tab management to GUIController
1 parent 0a5260b commit 7b463fe

12 files changed

+309
-294
lines changed
Lines changed: 142 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,157 @@
11
import AppKit
2+
import ChatTab
3+
import ComposableArchitecture
24
import Environment
5+
import Preferences
36
import SuggestionWidget
47

5-
@MainActor
6-
public final class GraphicalUserInterfaceController {
7-
public nonisolated static let shared = GraphicalUserInterfaceController()
8-
nonisolated let suggestionWidget = SuggestionWidgetController()
9-
private nonisolated init() {
10-
Task { @MainActor in
11-
suggestionWidget.dependency.suggestionWidgetDataSource = WidgetDataSource.shared
12-
suggestionWidget.dependency.onOpenChatClicked = { [weak self] in
13-
Task {
14-
let uri = try await Environment.fetchFocusedElementURI()
15-
let dataSource = WidgetDataSource.shared
16-
await dataSource.createChatIfNeeded(for: uri)
17-
self?.suggestionWidget.presentChatRoom()
8+
struct GUI: ReducerProtocol {
9+
struct State: Equatable {
10+
var suggestionWidgetState = WidgetFeature.State()
11+
12+
var chatTabGroup: ChatPanelFeature.ChatTabGroup {
13+
get { suggestionWidgetState.chatPanelState.chatTapGroup }
14+
set { suggestionWidgetState.chatPanelState.chatTapGroup = newValue }
15+
}
16+
}
17+
18+
enum Action {
19+
case openChatPanel(forceDetach: Bool)
20+
case createChatGPTChatTabIfNeeded
21+
case sendCustomCommandToActiveChat(CustomCommand)
22+
23+
case suggestionWidget(WidgetFeature.Action)
24+
}
25+
26+
var body: some ReducerProtocol<State, Action> {
27+
Scope(state: \.suggestionWidgetState, action: /Action.suggestionWidget) {
28+
WidgetFeature()
29+
}
30+
31+
Scope(
32+
state: \.chatTabGroup,
33+
action: /Action.suggestionWidget .. /WidgetFeature.Action.chatPanel
34+
) {
35+
Reduce { _, action in
36+
switch action {
37+
case let .createNewTapButtonClicked(type):
38+
_ = type // always ChatGPTChatTab at the moment.
39+
let chatTap = ChatGPTChatTab()
40+
return .run { send in
41+
await send(.appendAndSelectTab(chatTap))
42+
}
43+
44+
default:
45+
return .none
1846
}
1947
}
20-
suggestionWidget.dependency.onCustomCommandClicked = { command in
21-
Task {
22-
let commandHandler = PseudoCommandHandler()
23-
await commandHandler.handleCustomCommand(command)
48+
}
49+
50+
Reduce { state, action in
51+
switch action {
52+
case let .openChatPanel(forceDetach):
53+
return .run { send in
54+
await send(
55+
.suggestionWidget(.chatPanel(.presentChatPanel(forceDetach: forceDetach)))
56+
)
57+
}
58+
59+
case .createChatGPTChatTabIfNeeded:
60+
if state.chatTabGroup.tabs.contains(where: { $0 is ChatGPTChatTab }) {
61+
return .none
62+
}
63+
let chatTab = ChatGPTChatTab()
64+
state.chatTabGroup.tabs.append(chatTab)
65+
return .none
66+
67+
case let .sendCustomCommandToActiveChat(command):
68+
@Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async {
69+
if tab.service.isReceivingMessage {
70+
await tab.service.stopReceivingMessage()
71+
}
72+
try? await tab.service.handleCustomCommand(command)
73+
}
74+
75+
if let activeTab = state.chatTabGroup.activeChatTab as? ChatGPTChatTab {
76+
return .run { send in
77+
await stopAndHandleCommand(activeTab)
78+
await send(.openChatPanel(forceDetach: false))
79+
}
2480
}
81+
82+
if let chatTab = state.chatTabGroup.tabs.first(where: {
83+
guard $0 is ChatGPTChatTab else { return false }
84+
return true
85+
}) as? ChatGPTChatTab {
86+
state.chatTabGroup.selectedTabId = chatTab.id
87+
return .run { send in
88+
await stopAndHandleCommand(chatTab)
89+
await send(.openChatPanel(forceDetach: false))
90+
}
91+
}
92+
let chatTab = ChatGPTChatTab()
93+
state.chatTabGroup.tabs.append(chatTab)
94+
return .run { send in
95+
await stopAndHandleCommand(chatTab)
96+
await send(.openChatPanel(forceDetach: false))
97+
}
98+
99+
case .suggestionWidget:
100+
return .none
25101
}
26102
}
27103
}
28-
104+
}
105+
106+
@MainActor
107+
public final class GraphicalUserInterfaceController {
108+
public static let shared = GraphicalUserInterfaceController()
109+
private let store: StoreOf<GUI>
110+
let widgetController: SuggestionWidgetController
111+
let widgetDataSource: WidgetDataSource
112+
let viewStore: ViewStoreOf<GUI>
113+
114+
private init() {
115+
let suggestionDependency = SuggestionWidgetControllerDependency()
116+
let store = StoreOf<GUI>(
117+
initialState: .init(),
118+
reducer: GUI()
119+
) { dependencies in
120+
dependencies.suggestionWidgetControllerDependency = suggestionDependency
121+
dependencies.suggestionWidgetUserDefaultsObservers = .init()
122+
}
123+
self.store = store
124+
viewStore = ViewStore(store)
125+
widgetDataSource = .init()
126+
127+
widgetController = SuggestionWidgetController(
128+
store: store.scope(
129+
state: \.suggestionWidgetState,
130+
action: GUI.Action.suggestionWidget
131+
),
132+
dependency: suggestionDependency
133+
)
134+
135+
suggestionDependency.suggestionWidgetDataSource = widgetDataSource
136+
suggestionDependency.onOpenChatClicked = { [weak self] in
137+
Task { [weak self] in
138+
await self?.viewStore.send(.createChatGPTChatTabIfNeeded).finish()
139+
self?.viewStore.send(.openChatPanel(forceDetach: false))
140+
}
141+
}
142+
suggestionDependency.onCustomCommandClicked = { command in
143+
Task {
144+
let commandHandler = PseudoCommandHandler()
145+
await commandHandler.handleCustomCommand(command)
146+
}
147+
}
148+
}
149+
29150
public func openGlobalChat() {
30-
UserDefaults.shared.set(true, for: \.useGlobalChat)
31-
let dataSource = WidgetDataSource.shared
32-
let fakeFileURL = URL(fileURLWithPath: "/")
33151
Task {
34-
await dataSource.createChatIfNeeded(for: fakeFileURL)
35-
suggestionWidget.presentDetachedGlobalChat()
152+
await self.viewStore.send(.createChatGPTChatTabIfNeeded).finish()
153+
viewStore.send(.openChatPanel(forceDetach: true))
36154
}
37155
}
38156
}
157+

Core/Sources/Service/GUI/WidgetDataSource.swift

Lines changed: 8 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,16 @@
11
import ActiveApplicationMonitor
22
import ChatService
33
import ChatTab
4+
import ComposableArchitecture
45
import Foundation
56
import GitHubCopilotService
67
import OpenAIService
78
import PromptToCodeService
89
import SuggestionModel
910
import SuggestionWidget
1011

11-
@ServiceActor
12+
@MainActor
1213
final class WidgetDataSource {
13-
static let shared = WidgetDataSource()
14-
15-
final class Chat {
16-
let chatService: ChatService
17-
let provider: ChatProvider
18-
public init(chatService: ChatService, provider: ChatProvider) {
19-
self.chatService = chatService
20-
self.provider = provider
21-
}
22-
}
23-
2414
final class PromptToCode {
2515
let promptToCodeService: PromptToCodeService
2616
let provider: PromptToCodeProvider
@@ -32,63 +22,10 @@ final class WidgetDataSource {
3222
self.provider = provider
3323
}
3424
}
35-
36-
private(set) var globalChat: Chat?
37-
private(set) var chats = [URL: Chat]()
25+
3826
private(set) var promptToCodes = [URL: PromptToCode]()
3927

40-
private init() {}
41-
42-
@discardableResult
43-
func createChatIfNeeded(for url: URL) -> ChatService {
44-
let build = {
45-
let service = ChatService()
46-
let provider = ChatProvider(
47-
service: service,
48-
fileURL: url,
49-
onCloseChat: { [weak self] in
50-
if UserDefaults.shared.value(for: \.useGlobalChat) {
51-
self?.globalChat = nil
52-
} else {
53-
self?.removeChat(for: url)
54-
}
55-
let presenter = PresentInWindowSuggestionPresenter()
56-
presenter.closeChatRoom(fileURL: url)
57-
if let app = ActiveApplicationMonitor.previousActiveApplication, app.isXcode {
58-
Task { @MainActor in
59-
try await Task.sleep(nanoseconds: 200_000_000)
60-
app.activate()
61-
}
62-
}
63-
},
64-
onSwitchContext: { [weak self] in
65-
let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat)
66-
UserDefaults.shared.set(!useGlobalChat, for: \.useGlobalChat)
67-
self?.createChatIfNeeded(for: url)
68-
let presenter = PresentInWindowSuggestionPresenter()
69-
presenter.presentChatRoom(fileURL: url)
70-
}
71-
)
72-
return Chat(chatService: service, provider: provider)
73-
}
74-
75-
let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat)
76-
if useGlobalChat {
77-
if let globalChat {
78-
return globalChat.chatService
79-
}
80-
let newChat = build()
81-
globalChat = newChat
82-
return newChat.chatService
83-
} else {
84-
if let chat = chats[url] {
85-
return chat.chatService
86-
}
87-
let newChat = build()
88-
chats[url] = newChat
89-
return newChat.chatService
90-
}
91-
}
28+
init() {}
9229

9330
@discardableResult
9431
func createPromptToCode(
@@ -140,27 +77,22 @@ final class WidgetDataSource {
14077
return newPromptToCode.promptToCodeService
14178
}
14279

143-
func removeChat(for url: URL) {
144-
chats[url] = nil
145-
}
146-
14780
func removePromptToCode(for url: URL) {
14881
promptToCodes[url] = nil
14982
}
15083

15184
func cleanup(for url: URL) {
152-
removeChat(for: url)
15385
removePromptToCode(for: url)
15486
}
15587
}
15688

15789
extension WidgetDataSource: SuggestionWidgetDataSource {
15890
func suggestionForFile(at url: URL) async -> SuggestionProvider? {
159-
for workspace in workspaces.values {
160-
if let filespace = workspace.filespaces[url],
161-
let suggestion = filespace.presentingSuggestion
91+
for workspace in await workspaces.values {
92+
if let filespace = await workspace.filespaces[url],
93+
let suggestion = await filespace.presentingSuggestion
16294
{
163-
return .init(
95+
return await .init(
16496
code: suggestion.text,
16597
language: filespace.language,
16698
startLineIndex: suggestion.position.line,
@@ -208,21 +140,6 @@ extension WidgetDataSource: SuggestionWidgetDataSource {
208140
return nil
209141
}
210142

211-
func chatForFile(at url: URL) async -> ChatProvider? {
212-
let useGlobalChat = UserDefaults.shared.value(for: \.useGlobalChat)
213-
if useGlobalChat {
214-
if let globalChat {
215-
return globalChat.provider
216-
}
217-
} else {
218-
if let chat = chats[url] {
219-
return chat.provider
220-
}
221-
}
222-
223-
return nil
224-
}
225-
226143
func promptToCodeForFile(at url: URL) async -> PromptToCodeProvider? {
227144
return promptToCodes[url]?.provider
228145
}

Core/Sources/Service/ScheduledCleaner.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public final class ScheduledCleaner {
1111
Task { @ServiceActor in
1212
while !Task.isCancelled {
1313
try await Task.sleep(nanoseconds: 10 * 60 * 1_000_000_000)
14-
cleanUp()
14+
await cleanUp()
1515
}
1616
}
1717

@@ -20,14 +20,14 @@ public final class ScheduledCleaner {
2020
for await app in ActiveApplicationMonitor.createStream() {
2121
try Task.checkCancellation()
2222
if let app, !app.isXcode {
23-
cleanUp()
23+
await cleanUp()
2424
}
2525
}
2626
}
2727
}
2828

2929
@ServiceActor
30-
func cleanUp() {
30+
func cleanUp() async {
3131
let workspaceInfos = XcodeInspector.shared.xcodes.reduce(
3232
into: [
3333
XcodeAppInstanceInspector.WorkspaceIdentifier:
@@ -47,7 +47,7 @@ public final class ScheduledCleaner {
4747
if workspace.isExpired, workspaceInfos[.url(url)] == nil {
4848
Logger.service.info("Remove idle workspace")
4949
for url in workspace.filespaces.keys {
50-
WidgetDataSource.shared.cleanup(for: url)
50+
await GraphicalUserInterfaceController.shared.widgetDataSource.cleanup(for: url)
5151
}
5252
workspace.cleanUp(availableTabs: [])
5353
workspaces[url] = nil
@@ -62,15 +62,16 @@ public final class ScheduledCleaner {
6262
availableTabs: tabs
6363
) {
6464
Logger.service.info("Remove idle filespace")
65-
WidgetDataSource.shared.cleanup(for: url)
65+
await GraphicalUserInterfaceController.shared.widgetDataSource
66+
.cleanup(for: url)
6667
}
6768
}
6869
// cleanup workspace
6970
workspace.cleanUp(availableTabs: tabs)
7071
}
7172
}
7273
}
73-
74+
7475
@ServiceActor
7576
public func closeAllChildProcesses() async {
7677
for (_, workspace) in workspaces {

0 commit comments

Comments
 (0)