@hasna/recordings 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.takumi/settings.local.json +7 -0
- package/README.md +52 -0
- package/bun.lock +2 -2
- package/dist/cli/index.js +3866 -3279
- package/dist/index.js +3848 -3174
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/mcp/index.js +9865 -8535
- package/package.json +3 -3
- package/src/__tests__/enhancer.test.ts +65 -0
- package/src/cli/index.ts +3 -131
- package/src/lib/config.ts +56 -16
- package/src/lib/enhancer.ts +17 -11
- package/src/mcp/index.ts +2 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +58 -49
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +247 -118
- package/src/native/Recordings/Recordings/ProjectStore.swift +126 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +157 -57
- package/src/native/Recordings/Recordings/RecordingsApp.swift +12 -4
- package/src/native/Recordings/Recordings/SettingsView.swift +147 -36
- package/src/native/RecordingsHelper.swift +0 -395
|
@@ -4,89 +4,81 @@ import KeyboardShortcuts
|
|
|
4
4
|
struct MenuBarPopover: View {
|
|
5
5
|
@ObservedObject var engine: RecordingEngine
|
|
6
6
|
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
+
@ObservedObject var projectStore: ProjectStore
|
|
8
|
+
@State private var copiedIndex: Int?
|
|
9
|
+
@State private var filterProjectId: String?
|
|
10
|
+
|
|
11
|
+
private var filteredTranscriptions: [TranscriptionResult] {
|
|
12
|
+
guard let filter = filterProjectId else {
|
|
13
|
+
return engine.recentTranscriptions
|
|
14
|
+
}
|
|
15
|
+
if filter == "__none__" {
|
|
16
|
+
return engine.recentTranscriptions.filter { $0.projectId == nil }
|
|
17
|
+
}
|
|
18
|
+
return engine.recentTranscriptions.filter { $0.projectId == filter }
|
|
19
|
+
}
|
|
7
20
|
|
|
8
21
|
var body: some View {
|
|
9
22
|
VStack(spacing: 0) {
|
|
10
|
-
header
|
|
11
|
-
|
|
12
|
-
modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
|
|
13
|
-
shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
|
|
23
|
+
header
|
|
24
|
+
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
14
25
|
Divider()
|
|
15
|
-
recordingArea
|
|
26
|
+
recordingArea
|
|
27
|
+
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
16
28
|
Divider()
|
|
17
|
-
|
|
29
|
+
if !engine.recentTranscriptions.isEmpty {
|
|
30
|
+
filterBar
|
|
31
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
32
|
+
Divider()
|
|
33
|
+
}
|
|
34
|
+
recentList
|
|
35
|
+
.frame(maxHeight: 200)
|
|
18
36
|
Divider()
|
|
19
|
-
|
|
37
|
+
footerMenu
|
|
20
38
|
}
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
// MARK: - Header
|
|
24
42
|
|
|
25
43
|
private var header: some View {
|
|
26
|
-
|
|
27
|
-
Image(systemName: "mic.fill").font(.title3).foregroundStyle(.tint)
|
|
28
|
-
Text("Recordings").font(.headline)
|
|
29
|
-
Spacer()
|
|
30
|
-
Toggle(isOn: $engine.isWhisperMode) {
|
|
31
|
-
Label("Whisper", systemImage: "speaker.wave.1.fill").font(.caption)
|
|
32
|
-
}
|
|
33
|
-
.toggleStyle(.button).buttonStyle(.bordered).controlSize(.small)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// MARK: - Mode Selector
|
|
38
|
-
|
|
39
|
-
private var modeSelector: some View {
|
|
40
|
-
HStack(spacing: 8) {
|
|
41
|
-
modeBtn(.pushToTalk)
|
|
42
|
-
modeBtn(.dictation)
|
|
43
|
-
modeBtn(.command)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
@ViewBuilder
|
|
48
|
-
private func modeBtn(_ m: RecordingMode) -> some View {
|
|
49
|
-
if engine.mode == m {
|
|
50
|
-
Button { engine.mode = m } label: {
|
|
51
|
-
Label(m.rawValue, systemImage: m.icon).font(.caption)
|
|
52
|
-
.foregroundStyle(.white).frame(maxWidth: .infinity)
|
|
53
|
-
}.buttonStyle(.borderedProminent).controlSize(.small)
|
|
54
|
-
} else {
|
|
55
|
-
Button { engine.mode = m } label: {
|
|
56
|
-
Label(m.rawValue, systemImage: m.icon).font(.caption)
|
|
57
|
-
.frame(maxWidth: .infinity)
|
|
58
|
-
}.buttonStyle(.bordered).controlSize(.small)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// MARK: - Shortcut Row
|
|
63
|
-
|
|
64
|
-
private var shortcutRow: some View {
|
|
65
|
-
VStack(spacing: 6) {
|
|
66
|
-
// fn key toggle
|
|
44
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
67
45
|
HStack {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
Image(systemName: "mic.fill")
|
|
47
|
+
.foregroundStyle(.tint)
|
|
48
|
+
Text("Hasna Recordings")
|
|
49
|
+
Spacer()
|
|
50
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
51
|
+
Text(shortcut.description)
|
|
52
|
+
.foregroundStyle(.secondary)
|
|
73
53
|
}
|
|
74
|
-
.toggleStyle(.switch).controlSize(.small)
|
|
75
54
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
55
|
+
if !projectStore.settings.projects.isEmpty {
|
|
56
|
+
HStack(spacing: 4) {
|
|
57
|
+
Image(systemName: "folder.fill")
|
|
58
|
+
.foregroundColor(projectStore.activeProject != nil ? .accentColor : .secondary)
|
|
59
|
+
Menu {
|
|
60
|
+
Button("None") { projectStore.setActive(nil) }
|
|
61
|
+
Divider()
|
|
62
|
+
ForEach(projectStore.settings.projects) { project in
|
|
63
|
+
Button {
|
|
64
|
+
projectStore.setActive(project.id)
|
|
65
|
+
} label: {
|
|
66
|
+
HStack {
|
|
67
|
+
Text(project.name)
|
|
68
|
+
if project.id == projectStore.settings.activeProjectId {
|
|
69
|
+
Image(systemName: "checkmark")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} label: {
|
|
75
|
+
Text(projectStore.activeProject?.name ?? "No project")
|
|
76
|
+
.foregroundStyle(projectStore.activeProject == nil ? .secondary : .primary)
|
|
77
|
+
}
|
|
78
|
+
.menuStyle(.borderlessButton)
|
|
79
|
+
.fixedSize()
|
|
80
|
+
Spacer()
|
|
81
|
+
}
|
|
90
82
|
}
|
|
91
83
|
}
|
|
92
84
|
}
|
|
@@ -94,41 +86,94 @@ struct MenuBarPopover: View {
|
|
|
94
86
|
// MARK: - Recording Area
|
|
95
87
|
|
|
96
88
|
private var recordingArea: some View {
|
|
97
|
-
|
|
89
|
+
Group {
|
|
98
90
|
if engine.isRecording {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
VStack(spacing: 6) {
|
|
92
|
+
HStack {
|
|
93
|
+
Circle().fill(.red).frame(width: 8, height: 8)
|
|
94
|
+
Text(fmt(engine.recordingDuration))
|
|
95
|
+
.monospacedDigit()
|
|
96
|
+
Spacer()
|
|
97
|
+
Button("Stop") { engine.stopAndTranscribe() }
|
|
98
|
+
.controlSize(.small)
|
|
99
|
+
}
|
|
100
|
+
if let project = projectStore.activeProject {
|
|
101
|
+
HStack(spacing: 4) {
|
|
102
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
103
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
104
|
+
Spacer()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
106
107
|
}
|
|
107
|
-
.padding(
|
|
108
|
+
.padding(10)
|
|
109
|
+
.glassEffect(.regular)
|
|
108
110
|
} else if engine.isTranscribing {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
VStack(spacing: 6) {
|
|
112
|
+
HStack(spacing: 8) {
|
|
113
|
+
ProgressView().controlSize(.small)
|
|
114
|
+
Text("Transcribing...")
|
|
115
|
+
.foregroundStyle(.secondary)
|
|
116
|
+
Spacer()
|
|
117
|
+
}
|
|
118
|
+
if let project = projectStore.activeProject {
|
|
119
|
+
HStack(spacing: 4) {
|
|
120
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
121
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
122
|
+
Spacer()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
112
125
|
}
|
|
113
|
-
.frame(maxWidth: .infinity)
|
|
126
|
+
.frame(maxWidth: .infinity)
|
|
127
|
+
.padding(10)
|
|
128
|
+
.glassEffect(.regular)
|
|
114
129
|
} else {
|
|
115
|
-
VStack(spacing:
|
|
130
|
+
VStack(spacing: 6) {
|
|
116
131
|
Button { engine.startRecording() } label: {
|
|
117
|
-
Label("Record", systemImage: "mic.circle.fill")
|
|
118
|
-
}
|
|
132
|
+
Label("Record", systemImage: "mic.circle.fill")
|
|
133
|
+
}
|
|
134
|
+
.controlSize(.regular)
|
|
119
135
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
137
|
+
Text("or hold \(shortcut.description)")
|
|
138
|
+
.foregroundStyle(.tertiary)
|
|
139
|
+
}
|
|
124
140
|
}
|
|
125
|
-
.frame(maxWidth: .infinity)
|
|
141
|
+
.frame(maxWidth: .infinity)
|
|
142
|
+
.padding(10)
|
|
143
|
+
.glassEffect(.regular)
|
|
126
144
|
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
127
147
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
148
|
+
// MARK: - Filter Bar
|
|
149
|
+
|
|
150
|
+
private var filterBar: some View {
|
|
151
|
+
ScrollView(.horizontal, showsIndicators: false) {
|
|
152
|
+
HStack(spacing: 6) {
|
|
153
|
+
FilterChip(label: "All", isActive: filterProjectId == nil) {
|
|
154
|
+
filterProjectId = nil
|
|
155
|
+
}
|
|
156
|
+
ForEach(projectStore.settings.projects) { project in
|
|
157
|
+
let count = engine.recentTranscriptions.filter { $0.projectId == project.id }.count
|
|
158
|
+
if count > 0 {
|
|
159
|
+
FilterChip(
|
|
160
|
+
label: "\(project.name) (\(count))",
|
|
161
|
+
isActive: filterProjectId == project.id
|
|
162
|
+
) {
|
|
163
|
+
filterProjectId = filterProjectId == project.id ? nil : project.id
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
let noProjectCount = engine.recentTranscriptions.filter { $0.projectId == nil }.count
|
|
168
|
+
if noProjectCount > 0 {
|
|
169
|
+
FilterChip(
|
|
170
|
+
label: "No project (\(noProjectCount))",
|
|
171
|
+
isActive: filterProjectId == "__none__"
|
|
172
|
+
) {
|
|
173
|
+
filterProjectId = filterProjectId == "__none__" ? nil : "__none__"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
132
177
|
}
|
|
133
178
|
}
|
|
134
179
|
|
|
@@ -136,18 +181,30 @@ struct MenuBarPopover: View {
|
|
|
136
181
|
|
|
137
182
|
private var recentList: some View {
|
|
138
183
|
Group {
|
|
139
|
-
if
|
|
140
|
-
|
|
141
|
-
Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
|
|
142
|
-
Spacer()
|
|
143
|
-
}.frame(maxWidth: .infinity)
|
|
184
|
+
if filteredTranscriptions.isEmpty {
|
|
185
|
+
Spacer()
|
|
144
186
|
} else {
|
|
145
187
|
ScrollView {
|
|
146
|
-
LazyVStack(alignment: .leading, spacing:
|
|
147
|
-
ForEach(
|
|
148
|
-
|
|
188
|
+
LazyVStack(alignment: .leading, spacing: 2) {
|
|
189
|
+
ForEach(filteredTranscriptions.indices, id: \.self) { i in
|
|
190
|
+
let item = filteredTranscriptions[i]
|
|
191
|
+
TranscriptionRow(
|
|
192
|
+
item: item,
|
|
193
|
+
showProject: filterProjectId == nil,
|
|
194
|
+
isCopied: copiedIndex == i
|
|
195
|
+
) {
|
|
196
|
+
NSPasteboard.general.clearContents()
|
|
197
|
+
NSPasteboard.general.setString(item.displayText, forType: .string)
|
|
198
|
+
withAnimation(.easeInOut(duration: 0.15)) {
|
|
199
|
+
copiedIndex = i
|
|
200
|
+
}
|
|
201
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
202
|
+
withAnimation { if copiedIndex == i { copiedIndex = nil } }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
149
205
|
}
|
|
150
|
-
}
|
|
206
|
+
}
|
|
207
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
151
208
|
}
|
|
152
209
|
}
|
|
153
210
|
}
|
|
@@ -155,12 +212,33 @@ struct MenuBarPopover: View {
|
|
|
155
212
|
|
|
156
213
|
// MARK: - Footer
|
|
157
214
|
|
|
158
|
-
private var
|
|
159
|
-
|
|
160
|
-
SettingsLink {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
215
|
+
private var footerMenu: some View {
|
|
216
|
+
VStack(spacing: 0) {
|
|
217
|
+
SettingsLink {
|
|
218
|
+
HStack {
|
|
219
|
+
Text("Settings...")
|
|
220
|
+
Spacer()
|
|
221
|
+
Text("⌘,").foregroundStyle(.tertiary)
|
|
222
|
+
}
|
|
223
|
+
.contentShape(Rectangle())
|
|
224
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
225
|
+
}
|
|
226
|
+
.buttonStyle(.plain)
|
|
227
|
+
|
|
228
|
+
Divider()
|
|
229
|
+
|
|
230
|
+
Button {
|
|
231
|
+
NSApplication.shared.terminate(nil)
|
|
232
|
+
} label: {
|
|
233
|
+
HStack {
|
|
234
|
+
Text("Quit Hasna Recordings")
|
|
235
|
+
Spacer()
|
|
236
|
+
Text("⌘Q").foregroundStyle(.tertiary)
|
|
237
|
+
}
|
|
238
|
+
.contentShape(Rectangle())
|
|
239
|
+
.padding(.horizontal, 16).padding(.vertical, 6)
|
|
240
|
+
}
|
|
241
|
+
.buttonStyle(.plain)
|
|
164
242
|
}
|
|
165
243
|
}
|
|
166
244
|
|
|
@@ -169,20 +247,71 @@ struct MenuBarPopover: View {
|
|
|
169
247
|
}
|
|
170
248
|
}
|
|
171
249
|
|
|
250
|
+
// MARK: - Filter Chip
|
|
251
|
+
|
|
252
|
+
struct FilterChip: View {
|
|
253
|
+
let label: String
|
|
254
|
+
let isActive: Bool
|
|
255
|
+
let action: () -> Void
|
|
256
|
+
|
|
257
|
+
var body: some View {
|
|
258
|
+
Button(action: action) {
|
|
259
|
+
Text(label)
|
|
260
|
+
.padding(.horizontal, 8)
|
|
261
|
+
.padding(.vertical, 3)
|
|
262
|
+
.background(isActive ? Color.accentColor.opacity(0.15) : Color.clear)
|
|
263
|
+
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
264
|
+
}
|
|
265
|
+
.buttonStyle(.plain)
|
|
266
|
+
.foregroundStyle(isActive ? .primary : .secondary)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MARK: - Transcription Row
|
|
271
|
+
|
|
172
272
|
struct TranscriptionRow: View {
|
|
173
273
|
let item: TranscriptionResult
|
|
274
|
+
let showProject: Bool
|
|
275
|
+
let isCopied: Bool
|
|
276
|
+
let onCopy: () -> Void
|
|
277
|
+
|
|
174
278
|
var body: some View {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
279
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
280
|
+
HStack(alignment: .top, spacing: 8) {
|
|
281
|
+
Text(item.displayText)
|
|
282
|
+
.lineLimit(2)
|
|
283
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
284
|
+
|
|
285
|
+
if isCopied {
|
|
286
|
+
Image(systemName: "checkmark")
|
|
287
|
+
.foregroundStyle(.green)
|
|
288
|
+
.transition(.scale.combined(with: .opacity))
|
|
289
|
+
} else {
|
|
290
|
+
Text(relativeTime(item.timestamp))
|
|
291
|
+
.foregroundStyle(.tertiary)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if showProject, let name = item.projectName {
|
|
295
|
+
HStack(spacing: 3) {
|
|
296
|
+
Image(systemName: "folder")
|
|
297
|
+
Text(name)
|
|
298
|
+
}
|
|
299
|
+
.foregroundStyle(.tertiary)
|
|
300
|
+
}
|
|
181
301
|
}
|
|
182
|
-
.padding(.vertical, 4)
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
302
|
+
.padding(.vertical, 4)
|
|
303
|
+
.padding(.horizontal, 6)
|
|
304
|
+
.contentShape(Rectangle())
|
|
305
|
+
.onTapGesture { onCopy() }
|
|
306
|
+
.onHover { hovering in
|
|
307
|
+
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
186
308
|
}
|
|
187
309
|
}
|
|
310
|
+
|
|
311
|
+
private func relativeTime(_ date: Date) -> String {
|
|
312
|
+
let s = Date().timeIntervalSince(date)
|
|
313
|
+
if s < 60 { return "now" }
|
|
314
|
+
if s < 3600 { return "\(Int(s / 60))m" }
|
|
315
|
+
return "\(Int(s / 3600))h"
|
|
316
|
+
}
|
|
188
317
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct RecProject: Codable, Identifiable, Sendable {
|
|
4
|
+
let id: String
|
|
5
|
+
var name: String
|
|
6
|
+
var path: String?
|
|
7
|
+
var systemPrompt: String?
|
|
8
|
+
var appBundleIds: [String]?
|
|
9
|
+
|
|
10
|
+
init(name: String, path: String? = nil, systemPrompt: String? = nil, appBundleIds: [String]? = nil) {
|
|
11
|
+
self.id = UUID().uuidString
|
|
12
|
+
self.name = name
|
|
13
|
+
self.path = path
|
|
14
|
+
self.systemPrompt = systemPrompt
|
|
15
|
+
self.appBundleIds = appBundleIds
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
struct ProjectSettings: Codable, Sendable {
|
|
20
|
+
var globalSystemPrompt: String
|
|
21
|
+
var projects: [RecProject]
|
|
22
|
+
var activeProjectId: String?
|
|
23
|
+
|
|
24
|
+
init() {
|
|
25
|
+
globalSystemPrompt = ""
|
|
26
|
+
projects = []
|
|
27
|
+
activeProjectId = nil
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@MainActor
|
|
32
|
+
final class ProjectStore: ObservableObject {
|
|
33
|
+
@Published var settings = ProjectSettings()
|
|
34
|
+
|
|
35
|
+
private let filePath: String
|
|
36
|
+
|
|
37
|
+
var activeProject: RecProject? {
|
|
38
|
+
settings.projects.first { $0.id == settings.activeProjectId }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
var effectiveSystemPrompt: String {
|
|
42
|
+
let global = settings.globalSystemPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
43
|
+
let project = activeProject?.systemPrompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
44
|
+
if global.isEmpty && project.isEmpty { return "" }
|
|
45
|
+
if global.isEmpty { return project }
|
|
46
|
+
if project.isEmpty { return global }
|
|
47
|
+
return "\(global)\n\n\(project)"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
init() {
|
|
51
|
+
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
52
|
+
filePath = "\(home)/.hasna/recordings/projects.json"
|
|
53
|
+
load()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func load() {
|
|
57
|
+
guard FileManager.default.fileExists(atPath: filePath),
|
|
58
|
+
let data = FileManager.default.contents(atPath: filePath) else { return }
|
|
59
|
+
do {
|
|
60
|
+
settings = try JSONDecoder().decode(ProjectSettings.self, from: data)
|
|
61
|
+
} catch {
|
|
62
|
+
fputs("[ProjectStore] Failed to load: \(error)\n", stderr)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func save() {
|
|
67
|
+
do {
|
|
68
|
+
let data = try JSONEncoder().encode(settings)
|
|
69
|
+
let dir = (filePath as NSString).deletingLastPathComponent
|
|
70
|
+
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
|
71
|
+
try data.write(to: URL(fileURLWithPath: filePath))
|
|
72
|
+
} catch {
|
|
73
|
+
fputs("[ProjectStore] Failed to save: \(error)\n", stderr)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func addProject(name: String, path: String? = nil, systemPrompt: String? = nil) {
|
|
78
|
+
let project = RecProject(name: name, path: path, systemPrompt: systemPrompt)
|
|
79
|
+
settings.projects.append(project)
|
|
80
|
+
save()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func updateProject(_ project: RecProject) {
|
|
84
|
+
guard let idx = settings.projects.firstIndex(where: { $0.id == project.id }) else { return }
|
|
85
|
+
settings.projects[idx] = project
|
|
86
|
+
save()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func removeProject(id: String) {
|
|
90
|
+
settings.projects.removeAll { $0.id == id }
|
|
91
|
+
if settings.activeProjectId == id { settings.activeProjectId = nil }
|
|
92
|
+
save()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func setActive(_ id: String?) {
|
|
96
|
+
settings.activeProjectId = id
|
|
97
|
+
save()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// MARK: - Auto-detection
|
|
101
|
+
|
|
102
|
+
nonisolated static func matchProject(windowTitle: String?, bundleId: String?, projects: [RecProject]) -> RecProject? {
|
|
103
|
+
if let bundleId {
|
|
104
|
+
if let match = projects.first(where: { $0.appBundleIds?.contains(bundleId) == true }) {
|
|
105
|
+
return match
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if let title = windowTitle {
|
|
110
|
+
let lower = title.lowercased()
|
|
111
|
+
for project in projects {
|
|
112
|
+
let name = project.name.lowercased()
|
|
113
|
+
if lower.contains(name) { return project }
|
|
114
|
+
let slug = name.replacingOccurrences(of: " ", with: "-")
|
|
115
|
+
if lower.contains(slug) { return project }
|
|
116
|
+
if let path = project.path {
|
|
117
|
+
let dirName = (path as NSString).lastPathComponent.lowercased()
|
|
118
|
+
if lower.contains(dirName) { return project }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
}
|