@hasna/recordings 0.1.10 → 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.
@@ -1,8 +1,8 @@
1
1
  import Cocoa
2
2
 
3
3
  /// Monitors the fn/Globe key using CGEventTap.
4
- /// Based on the proven pattern from CustomWispr (open-source WisprFlow alternative).
5
- /// User must set System Settings > Keyboard > "Press fn key to: Do Nothing" for this to work.
4
+ /// Fn is exposed as a modifier flag, so we watch flagsChanged events and
5
+ /// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
6
6
  final class FnKeyMonitor: @unchecked Sendable {
7
7
  var onFnKeyDown: (() -> Void)?
8
8
  var onFnKeyUp: (() -> Void)?
@@ -13,7 +13,6 @@ final class FnKeyMonitor: @unchecked Sendable {
13
13
  private var fnIsDown = false
14
14
 
15
15
  private static let fnKeyCode: UInt16 = 63
16
- private static let fnFlagMask: UInt64 = 0x800000
17
16
 
18
17
  /// Start monitoring. Returns true if successful.
19
18
  func start() -> Bool {
@@ -22,46 +21,54 @@ final class FnKeyMonitor: @unchecked Sendable {
22
21
  return true
23
22
  }
24
23
 
25
- fputs("[FnKeyMonitor] Creating event tap...\n", stderr)
26
- let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
27
-
28
- // passRetained so the callback reference stays alive
29
- let selfPtr = Unmanaged.passRetained(self).toOpaque()
30
-
31
- guard let tap = CGEvent.tapCreate(
32
- tap: .cgSessionEventTap,
33
- place: .headInsertEventTap,
34
- options: .defaultTap,
35
- eventsOfInterest: eventMask,
36
- callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
37
- guard let refcon = refcon else { return Unmanaged.passRetained(event) }
38
- let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
39
- return monitor.handleEvent(type: type, event: event)
40
- },
41
- userInfo: selfPtr
42
- ) else {
43
- Unmanaged<FnKeyMonitor>.fromOpaque(selfPtr).release()
44
- return false
45
- }
46
-
47
- self.eventTap = tap
48
-
49
- let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
50
- self.runLoopSource = source
51
- CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
52
- CGEvent.tapEnable(tap: tap, enable: true)
53
- fputs("[FnKeyMonitor] Event tap created and enabled OK\n", stderr)
24
+ let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
25
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
26
+ let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
27
+ (.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
28
+ (.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
29
+ (.cghidEventTap, .headInsertEventTap, "hid/head"),
30
+ ]
31
+
32
+ for (tapLocation, tapPlacement, label) in candidates {
33
+ fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
34
+
35
+ guard let tap = CGEvent.tapCreate(
36
+ tap: tapLocation,
37
+ place: tapPlacement,
38
+ options: .defaultTap,
39
+ eventsOfInterest: eventMask,
40
+ callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
41
+ guard let refcon else { return Unmanaged.passRetained(event) }
42
+ let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
43
+ return monitor.handleEvent(type: type, event: event)
44
+ },
45
+ userInfo: selfPtr
46
+ ) else {
47
+ continue
48
+ }
54
49
 
55
- // Health check: macOS silently disables taps — re-enable every 3 seconds
56
- healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
57
- guard let self = self, let tap = self.eventTap else { return }
58
- if !CGEvent.tapIsEnabled(tap: tap) {
59
- fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
60
- CGEvent.tapEnable(tap: tap, enable: true)
50
+ eventTap = tap
51
+ runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
52
+ if let runLoopSource {
53
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
54
+ }
55
+ CGEvent.tapEnable(tap: tap, enable: true)
56
+ fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
57
+
58
+ // Health check: macOS can silently disable taps — re-enable every 3 seconds.
59
+ healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
60
+ guard let self, let tap = self.eventTap else { return }
61
+ if !CGEvent.tapIsEnabled(tap: tap) {
62
+ fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
63
+ CGEvent.tapEnable(tap: tap, enable: true)
64
+ }
61
65
  }
66
+
67
+ return true
62
68
  }
63
69
 
64
- return true
70
+ fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
71
+ return false
65
72
  }
66
73
 
67
74
  func stop() {
@@ -80,7 +87,6 @@ final class FnKeyMonitor: @unchecked Sendable {
80
87
  }
81
88
 
82
89
  private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
83
- // Re-enable if macOS disabled the tap
84
90
  if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
85
91
  if let tap = eventTap {
86
92
  CGEvent.tapEnable(tap: tap, enable: true)
@@ -88,33 +94,36 @@ final class FnKeyMonitor: @unchecked Sendable {
88
94
  return Unmanaged.passRetained(event)
89
95
  }
90
96
 
97
+ guard type == .flagsChanged else {
98
+ return Unmanaged.passRetained(event)
99
+ }
100
+
91
101
  let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
102
+ let fnPressed = event.flags.contains(.maskSecondaryFn)
103
+ let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
92
104
 
93
- // Only handle fn key (keyCode 63)
94
- guard keyCode == FnKeyMonitor.fnKeyCode else {
105
+ guard isFnTransition else {
95
106
  return Unmanaged.passRetained(event)
96
107
  }
97
108
 
98
109
  let flags = event.flags.rawValue
99
- let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
100
-
101
110
  fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
102
111
 
103
112
  if fnPressed && !fnIsDown {
104
- // fn just pressed
105
113
  fnIsDown = true
106
114
  fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
107
115
  DispatchQueue.main.async { [weak self] in
108
116
  self?.onFnKeyDown?()
109
117
  }
110
- return nil // Swallow — prevents emoji picker / language switch
111
- } else if !fnPressed && fnIsDown {
112
- // fn released
118
+ return nil
119
+ }
120
+
121
+ if !fnPressed && fnIsDown {
113
122
  fnIsDown = false
114
123
  DispatchQueue.main.async { [weak self] in
115
124
  self?.onFnKeyUp?()
116
125
  }
117
- return nil // Swallow release too
126
+ return nil
118
127
  }
119
128
 
120
129
  return Unmanaged.passRetained(event)
@@ -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.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
11
- Divider()
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.padding(.horizontal, 16).padding(.vertical, 12)
26
+ recordingArea
27
+ .padding(.horizontal, 16).padding(.vertical, 12)
16
28
  Divider()
17
- recentList.frame(maxHeight: 180)
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
- footer.padding(.horizontal, 16).padding(.vertical, 8)
37
+ footerMenu
20
38
  }
21
39
  }
22
40
 
23
41
  // MARK: - Header
24
42
 
25
43
  private var header: some View {
26
- HStack {
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
- Toggle(isOn: $engine.useFnKey) {
69
- HStack(spacing: 4) {
70
- Text("fn").font(.system(.caption, design: .monospaced)).bold()
71
- Text("Globe key").font(.caption).foregroundStyle(.secondary)
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
- // Custom shortcut
78
- HStack(spacing: 8) {
79
- Text("Custom").font(.caption).foregroundStyle(.secondary)
80
- Spacer()
81
- let current = KeyboardShortcuts.getShortcut(for: .toggleRecording)
82
- Text(current?.description ?? "None")
83
- .font(.system(.caption, design: .monospaced))
84
- .padding(.horizontal, 8).padding(.vertical, 3)
85
- .background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
86
-
87
- SettingsLink {
88
- Text("Set").font(.caption2)
89
- }.buttonStyle(.bordered).controlSize(.mini)
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
- VStack(spacing: 8) {
89
+ Group {
98
90
  if engine.isRecording {
99
- HStack(spacing: 12) {
100
- Circle().fill(.red).frame(width: 10, height: 10)
101
- Text(fmt(engine.recordingDuration))
102
- .font(.system(.title2, design: .monospaced))
103
- Spacer()
104
- Button("Stop") { engine.stopAndTranscribe() }
105
- .buttonStyle(.borderedProminent).tint(.red).controlSize(.small)
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(12).glassEffect(.regular.tint(.red))
108
+ .padding(10)
109
+ .glassEffect(.regular)
108
110
  } else if engine.isTranscribing {
109
- HStack(spacing: 8) {
110
- ProgressView().controlSize(.small)
111
- Text("Transcribing...").font(.subheadline).foregroundStyle(.secondary)
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).padding(12).glassEffect(.regular)
126
+ .frame(maxWidth: .infinity)
127
+ .padding(10)
128
+ .glassEffect(.regular)
114
129
  } else {
115
- VStack(spacing: 10) {
130
+ VStack(spacing: 6) {
116
131
  Button { engine.startRecording() } label: {
117
- Label("Record", systemImage: "mic.circle.fill").font(.title3)
118
- }.buttonStyle(.borderedProminent).controlSize(.large)
132
+ Label("Record", systemImage: "mic.circle.fill")
133
+ }
134
+ .controlSize(.regular)
119
135
 
120
- Text(engine.mode.hint)
121
- .font(.callout).foregroundStyle(.primary.opacity(0.7))
122
- .multilineTextAlignment(.center)
123
- .fixedSize(horizontal: false, vertical: true)
136
+ if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
137
+ Text("or hold \(shortcut.description)")
138
+ .foregroundStyle(.tertiary)
139
+ }
124
140
  }
125
- .frame(maxWidth: .infinity).padding(14).glassEffect(.clear)
141
+ .frame(maxWidth: .infinity)
142
+ .padding(10)
143
+ .glassEffect(.regular)
126
144
  }
145
+ }
146
+ }
127
147
 
128
- Text(engine.statusMessage)
129
- .font(.caption2).foregroundStyle(.secondary)
130
- .lineLimit(2).truncationMode(.tail)
131
- .frame(maxWidth: .infinity, alignment: .leading)
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 engine.recentTranscriptions.isEmpty {
140
- VStack { Spacer()
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: 4) {
147
- ForEach(engine.recentTranscriptions.indices, id: \.self) { i in
148
- TranscriptionRow(item: engine.recentTranscriptions[i])
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
- }.padding(.horizontal, 16).padding(.vertical, 6)
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 footer: some View {
159
- HStack {
160
- SettingsLink { Image(systemName: "gear") }.buttonStyle(.borderless)
161
- Spacer()
162
- Button("Quit") { NSApplication.shared.terminate(nil) }
163
- .buttonStyle(.borderless).foregroundStyle(.secondary)
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
- HStack(alignment: .top, spacing: 8) {
176
- Text(item.displayText).font(.caption).lineLimit(2)
177
- .frame(maxWidth: .infinity, alignment: .leading)
178
- Text({ let s = Date().timeIntervalSince(item.timestamp)
179
- return s < 60 ? "now" : s < 3600 ? "\(Int(s/60))m" : "\(Int(s/3600))h"
180
- }()).font(.caption2).foregroundStyle(.tertiary)
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).contentShape(Rectangle())
183
- .onTapGesture {
184
- NSPasteboard.general.clearContents()
185
- NSPasteboard.general.setString(item.displayText, forType: .string)
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
  }