@@ -34,6 +34,28 @@ enum StatusMenuLogic {
3434 return " cd ' \( dir) ' && \( session. resumeCmd) "
3535 }
3636
37+ static func projectLabel( from workingDir: String ) -> String {
38+ guard !workingDir. isEmpty else { return " " }
39+ let name = URL ( fileURLWithPath: workingDir) . lastPathComponent
40+ return name. isEmpty ? workingDir : name
41+ }
42+
43+ static func groupedByProject( from sessions: [ Session ] ) -> [ ( label: String , sessions: [ Session ] ) ] {
44+ var groups : [ ( label: String , sessions: [ Session ] ) ] = [ ]
45+ var index : [ String : Int ] = [ : ]
46+ for session in sessions {
47+ let key = ( session. workingDir as NSString ) . standardizingPath
48+ let label = projectLabel ( from: session. workingDir)
49+ if let i = index [ key] {
50+ groups [ i] . sessions. append ( session)
51+ } else {
52+ index [ key] = groups. count
53+ groups. append ( ( label: label, sessions: [ session] ) )
54+ }
55+ }
56+ return groups
57+ }
58+
3759 static func relativeTime( from iso8601: String , now: Date = Date ( ) ) -> String {
3860 let formatter = ISO8601DateFormatter ( )
3961 formatter. formatOptions = [ . withInternetDateTime, . withFractionalSeconds]
@@ -68,6 +90,8 @@ private class SessionMenuEntry: NSObject {
6890public class StatusMenuController : NSObject , NSMenuDelegate {
6991 typealias SessionStatusWriter = ( _ id: String , _ status: String ) -> Void
7092 typealias SessionDeleter = ( _ id: String ) -> Void
93+ typealias LaunchAtLoginGetter = ( ) -> Bool
94+ typealias LaunchAtLoginSetter = ( _ enabled: Bool ) throws -> Void
7195
7296 let statusItem : NSStatusItem
7397 let menu : NSMenu
@@ -77,6 +101,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
77101 private let configLoader : ( ) -> PerchConfig
78102 private let statusWriter : SessionStatusWriter
79103 private let sessionDeleter : SessionDeleter
104+ private let launchAtLoginGetter : LaunchAtLoginGetter
105+ private let launchAtLoginSetter : LaunchAtLoginSetter
80106 private let pasteboard : NSPasteboard
81107 private let toastHandler : ( ( String , String ) -> Void ) ?
82108 private var toastPanel : NSPanel ?
@@ -99,6 +125,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
99125 }
100126 } ,
101127 sessionDeleter: @escaping SessionDeleter = { id in SessionStore . delete ( id: id) } ,
128+ launchAtLoginGetter: @escaping LaunchAtLoginGetter = { LaunchAtLoginManager . isEnabled ( ) } ,
129+ launchAtLoginSetter: @escaping LaunchAtLoginSetter = { enabled in try LaunchAtLoginManager . setEnabled ( enabled) } ,
102130 pasteboard: NSPasteboard = . general,
103131 toastHandler: ( ( String , String ) -> Void ) ? = nil ,
104132 watchFile: Bool = true
@@ -110,6 +138,8 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
110138 self . configLoader = configLoader
111139 self . statusWriter = statusWriter
112140 self . sessionDeleter = sessionDeleter
141+ self . launchAtLoginGetter = launchAtLoginGetter
142+ self . launchAtLoginSetter = launchAtLoginSetter
113143 self . pasteboard = pasteboard
114144 self . toastHandler = toastHandler
115145
@@ -167,8 +197,15 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
167197 emptyItem. isEnabled = false
168198 menu. addItem ( emptyItem)
169199 } else {
170- for session in pending {
171- menu. addItem ( makeSessionItem ( session, isDone: false ) )
200+ let projectGroups = StatusMenuLogic . groupedByProject ( from: pending)
201+ let useGroupHeaders = projectGroups. count > 1
202+ for group in projectGroups {
203+ if useGroupHeaders {
204+ menu. addItem ( makeProjectHeader ( group. label) )
205+ }
206+ for session in group. sessions {
207+ menu. addItem ( makeSessionItem ( session, isDone: false ) )
208+ }
172209 }
173210 }
174211
@@ -193,6 +230,15 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
193230
194231 menu. addItem ( NSMenuItem . separator ( ) )
195232
233+ let launchAtLoginItem = NSMenuItem (
234+ title: " Launch at Login " ,
235+ action: #selector( toggleLaunchAtLogin ( _: ) ) ,
236+ keyEquivalent: " "
237+ )
238+ launchAtLoginItem. target = self
239+ launchAtLoginItem. state = launchAtLoginGetter ( ) ? . on : . off
240+ menu. addItem ( launchAtLoginItem)
241+
196242 let configItem = NSMenuItem (
197243 title: " Open Config " ,
198244 action: #selector( openConfig) ,
@@ -219,6 +265,19 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
219265 menu. addItem ( quitItem)
220266 }
221267
268+ private func makeProjectHeader( _ label: String ) -> NSMenuItem {
269+ let item = NSMenuItem ( title: label, action: nil , keyEquivalent: " " )
270+ item. isEnabled = false
271+ item. attributedTitle = NSAttributedString (
272+ string: label,
273+ attributes: [
274+ . font: NSFont . systemFont ( ofSize: 11 , weight: . medium) ,
275+ . foregroundColor: NSColor . secondaryLabelColor
276+ ]
277+ )
278+ return item
279+ }
280+
222281 private func makeSessionItem( _ session: Session , isDone: Bool ) -> NSMenuItem {
223282 let time = StatusMenuLogic . relativeTime ( from: session. createdAt)
224283 let item = NSMenuItem (
@@ -363,8 +422,18 @@ public class StatusMenuController: NSObject, NSMenuDelegate {
363422 rebuildMenu ( )
364423 }
365424
425+ @objc func toggleLaunchAtLogin( _ sender: NSMenuItem ) {
426+ let nextValue = sender. state != . on
427+ do {
428+ try launchAtLoginSetter ( nextValue)
429+ sender. state = nextValue ? . on : . off
430+ } catch {
431+ showCopiedToast ( title: " Could Not Update Launch at Login " , command: error. localizedDescription)
432+ }
433+ }
434+
366435 func loadStatusIcon( ) -> NSImage ? {
367- if let url = Bundle . module. url ( forResource: " perch-logo-2 " , withExtension: " svg " ) ,
436+ if let url = Bundle . module. url ( forResource: " perch-menubar " , withExtension: " svg " ) ,
368437 let image = NSImage ( contentsOf: url) {
369438 image. size = NSSize ( width: 18 , height: 18 )
370439 image. isTemplate = true
0 commit comments