mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix inconsistent button state management
- Remove all uses of deprecated highlight() method in CustomMenuWindow - Consistently use state property for NSStatusBarButton management - Update StatusBarMenuManager to reset button state when menu state is .none - Fix concurrency issues in CustomMenuWindow frame observer - Ensure button state is properly managed throughout menu lifecycle This fixes the issue where the button could display inconsistent visual states or get stuck due to conflicting approaches between highlight() and state.
This commit is contained in:
parent
920d96207b
commit
42021bb514
10 changed files with 166 additions and 137 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [1.0.0-beta.6] - 2025-01-01
|
## [1.0.0-beta.6] - 2025-07-02
|
||||||
|
|
||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
- **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions
|
- **Sleep Prevention** - Mac now stays awake when VibeTunnel is running terminal sessions
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ final class WindowTracker {
|
||||||
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "WindowTracker")
|
let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "WindowTracker")
|
||||||
|
|
||||||
return windowList.compactMap { windowDict in
|
return windowList.compactMap { windowDict in
|
||||||
|
|
@ -149,9 +149,9 @@ final class WindowTracker {
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log suspicious window IDs for debugging
|
// Log suspicious window IDs for debugging
|
||||||
if windowID < 1000 && windowID == CGWindowID(ownerPID) {
|
if windowID < 1_000 && windowID == CGWindowID(ownerPID) {
|
||||||
logger.warning("Suspicious window ID \(windowID) matches PID for \(ownerName)")
|
logger.warning("Suspicious window ID \(windowID) matches PID for \(ownerName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -215,7 +215,7 @@ final class WindowTracker {
|
||||||
// First try to find window by process PID traversal
|
// First try to find window by process PID traversal
|
||||||
if let sessionInfo = getSessionInfo(for: sessionID), let sessionPID = sessionInfo.pid {
|
if let sessionInfo = getSessionInfo(for: sessionID), let sessionPID = sessionInfo.pid {
|
||||||
logger.debug("Attempting to find window by process PID: \(sessionPID)")
|
logger.debug("Attempting to find window by process PID: \(sessionPID)")
|
||||||
|
|
||||||
// For debugging: log the process tree
|
// For debugging: log the process tree
|
||||||
logProcessTree(for: pid_t(sessionPID))
|
logProcessTree(for: pid_t(sessionPID))
|
||||||
|
|
||||||
|
|
@ -248,7 +248,10 @@ final class WindowTracker {
|
||||||
if let matchingWindow = terminalWindows.first(where: { window in
|
if let matchingWindow = terminalWindows.first(where: { window in
|
||||||
window.ownerPID == grandParentPID
|
window.ownerPID == grandParentPID
|
||||||
}) {
|
}) {
|
||||||
logger.info("Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Found window by ancestor process match: PID \(grandParentPID) at depth \(depth + 2)"
|
||||||
|
)
|
||||||
return createWindowInfo(
|
return createWindowInfo(
|
||||||
from: matchingWindow,
|
from: matchingWindow,
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
|
|
@ -391,7 +394,7 @@ final class WindowTracker {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get process info including name
|
/// Get process info including name
|
||||||
private func getProcessInfo(for pid: pid_t) -> (name: String, ppid: pid_t)? {
|
private func getProcessInfo(for pid: pid_t) -> (name: String, ppid: pid_t)? {
|
||||||
var info = kinfo_proc()
|
var info = kinfo_proc()
|
||||||
|
|
@ -409,13 +412,13 @@ final class WindowTracker {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Log the process tree for debugging
|
/// Log the process tree for debugging
|
||||||
private func logProcessTree(for pid: pid_t) {
|
private func logProcessTree(for pid: pid_t) {
|
||||||
var currentPID = pid
|
var currentPID = pid
|
||||||
var depth = 0
|
var depth = 0
|
||||||
var processPath: [String] = []
|
var processPath: [String] = []
|
||||||
|
|
||||||
while depth < 15 {
|
while depth < 15 {
|
||||||
if let processInfo = getProcessInfo(for: currentPID) {
|
if let processInfo = getProcessInfo(for: currentPID) {
|
||||||
processPath.append("\(processInfo.name):\(currentPID)")
|
processPath.append("\(processInfo.name):\(currentPID)")
|
||||||
|
|
@ -428,7 +431,7 @@ final class WindowTracker {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("Process tree: \(processPath.joined(separator: " <- "))")
|
logger.debug("Process tree: \(processPath.joined(separator: " <- "))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -673,8 +676,11 @@ final class WindowTracker {
|
||||||
|
|
||||||
/// Focuses a Ghostty window with macOS standard tabs.
|
/// Focuses a Ghostty window with macOS standard tabs.
|
||||||
private func focusGhosttyWindow(_ windowInfo: WindowInfo) {
|
private func focusGhosttyWindow(_ windowInfo: WindowInfo) {
|
||||||
logger.info("Attempting to focus Ghostty window - windowID: \(windowInfo.windowID), ownerPID: \(windowInfo.ownerPID), sessionID: \(windowInfo.sessionID)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Attempting to focus Ghostty window - windowID: \(windowInfo.windowID), ownerPID: \(windowInfo.ownerPID), sessionID: \(windowInfo.sessionID)"
|
||||||
|
)
|
||||||
|
|
||||||
// First bring the application to front
|
// First bring the application to front
|
||||||
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
if let app = NSRunningApplication(processIdentifier: windowInfo.ownerPID) {
|
||||||
app.activate()
|
app.activate()
|
||||||
|
|
@ -683,13 +689,13 @@ final class WindowTracker {
|
||||||
// Ghostty uses macOS standard tabs, so we need to:
|
// Ghostty uses macOS standard tabs, so we need to:
|
||||||
// 1. Focus the window
|
// 1. Focus the window
|
||||||
// 2. Find and select the correct tab
|
// 2. Find and select the correct tab
|
||||||
|
|
||||||
// Use Accessibility API to handle tab selection
|
// Use Accessibility API to handle tab selection
|
||||||
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
|
let axApp = AXUIElementCreateApplication(windowInfo.ownerPID)
|
||||||
|
|
||||||
var windowsValue: CFTypeRef?
|
var windowsValue: CFTypeRef?
|
||||||
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
|
let result = AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &windowsValue)
|
||||||
|
|
||||||
guard result == .success,
|
guard result == .success,
|
||||||
let windows = windowsValue as? [AXUIElement],
|
let windows = windowsValue as? [AXUIElement],
|
||||||
!windows.isEmpty
|
!windows.isEmpty
|
||||||
|
|
@ -698,17 +704,17 @@ final class WindowTracker {
|
||||||
focusWindowUsingAccessibility(windowInfo)
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the matching window
|
// Find the matching window
|
||||||
logger.debug("Looking for Ghostty window with ID \(windowInfo.windowID) among \(windows.count) windows")
|
logger.debug("Looking for Ghostty window with ID \(windowInfo.windowID) among \(windows.count) windows")
|
||||||
|
|
||||||
// If we have a very low window ID that matches PID, it's likely wrong
|
// If we have a very low window ID that matches PID, it's likely wrong
|
||||||
if windowInfo.windowID < 1000 && windowInfo.windowID == CGWindowID(windowInfo.ownerPID) {
|
if windowInfo.windowID < 1_000 && windowInfo.windowID == CGWindowID(windowInfo.ownerPID) {
|
||||||
logger.warning("Window ID \(windowInfo.windowID) suspiciously matches PID, will try alternative matching")
|
logger.warning("Window ID \(windowInfo.windowID) suspiciously matches PID, will try alternative matching")
|
||||||
|
|
||||||
// In this case, we need to find the correct window by tab content
|
// In this case, we need to find the correct window by tab content
|
||||||
let sessionInfo = getSessionInfo(for: windowInfo.sessionID)
|
let sessionInfo = getSessionInfo(for: windowInfo.sessionID)
|
||||||
|
|
||||||
for (windowIndex, window) in windows.enumerated() {
|
for (windowIndex, window) in windows.enumerated() {
|
||||||
// Check tabs in this window
|
// Check tabs in this window
|
||||||
var tabsValue: CFTypeRef?
|
var tabsValue: CFTypeRef?
|
||||||
|
|
@ -721,40 +727,40 @@ final class WindowTracker {
|
||||||
// Found the window with matching tab
|
// Found the window with matching tab
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
// Select the correct tab
|
// Select the correct tab
|
||||||
selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
||||||
|
|
||||||
logger.info("Focused Ghostty window \(windowIndex) by tab content match")
|
logger.info("Focused Ghostty window \(windowIndex) by tab content match")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no matching tab found, use first window as fallback
|
// If no matching tab found, use first window as fallback
|
||||||
if !windows.isEmpty {
|
if !windows.isEmpty {
|
||||||
let window = windows[0]
|
let window = windows[0]
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
logger.info("Focused first Ghostty window as final fallback")
|
logger.info("Focused first Ghostty window as final fallback")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try matching by window ID, but also prepare for tab-based matching
|
// Try matching by window ID, but also prepare for tab-based matching
|
||||||
var windowWithMatchingTab: (window: AXUIElement, tabs: [AXUIElement])?
|
var windowWithMatchingTab: (window: AXUIElement, tabs: [AXUIElement])?
|
||||||
let sessionInfo = getSessionInfo(for: windowInfo.sessionID)
|
let sessionInfo = getSessionInfo(for: windowInfo.sessionID)
|
||||||
|
|
||||||
for (windowIndex, window) in windows.enumerated() {
|
for (windowIndex, window) in windows.enumerated() {
|
||||||
var windowIDValue: CFTypeRef?
|
var windowIDValue: CFTypeRef?
|
||||||
let windowIDResult = AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue)
|
let windowIDResult = AXUIElementCopyAttributeValue(window, kAXWindowAttribute as CFString, &windowIDValue)
|
||||||
|
|
||||||
// Also get window title for debugging
|
// Also get window title for debugging
|
||||||
var titleValue: CFTypeRef?
|
var titleValue: CFTypeRef?
|
||||||
let titleResult = AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue)
|
let titleResult = AXUIElementCopyAttributeValue(window, kAXTitleAttribute as CFString, &titleValue)
|
||||||
let windowTitle = titleResult == .success ? (titleValue as? String ?? "no title") : "failed to get title"
|
let windowTitle = titleResult == .success ? (titleValue as? String ?? "no title") : "failed to get title"
|
||||||
|
|
||||||
// Check tabs in this window for content matching
|
// Check tabs in this window for content matching
|
||||||
var tabsValue: CFTypeRef?
|
var tabsValue: CFTypeRef?
|
||||||
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
||||||
|
|
@ -767,59 +773,66 @@ final class WindowTracker {
|
||||||
logger.debug("Window \(windowIndex) has matching tab for session \(windowInfo.sessionID)")
|
logger.debug("Window \(windowIndex) has matching tab for session \(windowInfo.sessionID)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if windowIDResult == .success {
|
if windowIDResult == .success {
|
||||||
if let windowNumber = windowIDValue as? Int {
|
if let windowNumber = windowIDValue as? Int {
|
||||||
logger.debug("Window \(windowIndex): AX window ID = \(windowNumber), title = '\(windowTitle)', looking for \(windowInfo.windowID)")
|
logger
|
||||||
|
.debug(
|
||||||
|
"Window \(windowIndex): AX window ID = \(windowNumber), title = '\(windowTitle)', looking for \(windowInfo.windowID)"
|
||||||
|
)
|
||||||
|
|
||||||
if windowNumber == windowInfo.windowID {
|
if windowNumber == windowInfo.windowID {
|
||||||
// Found the window by ID, make it main and focused
|
// Found the window by ID, make it main and focused
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
// Now select the correct tab
|
// Now select the correct tab
|
||||||
var tabsValue2: CFTypeRef?
|
var tabsValue2: CFTypeRef?
|
||||||
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue2) == .success,
|
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue2) == .success,
|
||||||
let tabs = tabsValue2 as? [AXUIElement],
|
let tabs = tabsValue2 as? [AXUIElement],
|
||||||
!tabs.isEmpty
|
!tabs.isEmpty
|
||||||
{
|
{
|
||||||
// Use the helper method to select the correct tab
|
// Use the helper method to select the correct tab
|
||||||
selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
selectGhosttyTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Focused Ghostty window by ID match")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Focused Ghostty window by ID match")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Window \(windowIndex): AX window ID value is not an Int: \(String(describing: windowIDValue))")
|
logger
|
||||||
|
.debug(
|
||||||
|
"Window \(windowIndex): AX window ID value is not an Int: \(String(describing: windowIDValue))"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Window \(windowIndex): Failed to get AX window ID, error code: \(windowIDResult.rawValue)")
|
logger
|
||||||
|
.debug("Window \(windowIndex): Failed to get AX window ID, error code: \(windowIDResult.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we couldn't find by window ID but found a window with matching tab content, use that
|
// If we couldn't find by window ID but found a window with matching tab content, use that
|
||||||
if let matchingWindow = windowWithMatchingTab {
|
if let matchingWindow = windowWithMatchingTab {
|
||||||
AXUIElementSetAttributeValue(matchingWindow.window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(matchingWindow.window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(matchingWindow.window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(matchingWindow.window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
// Select the correct tab
|
// Select the correct tab
|
||||||
selectGhosttyTab(tabs: matchingWindow.tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
selectGhosttyTab(tabs: matchingWindow.tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
|
||||||
|
|
||||||
logger.info("Focused Ghostty window by tab content match (no window ID match)")
|
logger.info("Focused Ghostty window by tab content match (no window ID match)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback if we couldn't find the specific window
|
// Fallback if we couldn't find the specific window
|
||||||
logger.warning("Could not find matching Ghostty window with ID \(windowInfo.windowID), using fallback")
|
logger.warning("Could not find matching Ghostty window with ID \(windowInfo.windowID), using fallback")
|
||||||
|
|
||||||
// Log additional debugging info
|
// Log additional debugging info
|
||||||
if windows.isEmpty {
|
if windows.isEmpty {
|
||||||
logger.error("No Ghostty windows found at all")
|
logger.error("No Ghostty windows found at all")
|
||||||
} else {
|
} else {
|
||||||
logger.debug("Ghostty windows found but none matched ID \(windowInfo.windowID)")
|
logger.debug("Ghostty windows found but none matched ID \(windowInfo.windowID)")
|
||||||
}
|
}
|
||||||
|
|
||||||
focusWindowUsingAccessibility(windowInfo)
|
focusWindowUsingAccessibility(windowInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -855,7 +868,7 @@ final class WindowTracker {
|
||||||
// Found the matching window, make it main and focused
|
// Found the matching window, make it main and focused
|
||||||
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
|
||||||
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
|
||||||
|
|
||||||
// For terminals that use macOS standard tabs, try to select the correct tab
|
// For terminals that use macOS standard tabs, try to select the correct tab
|
||||||
var tabsValue: CFTypeRef?
|
var tabsValue: CFTypeRef?
|
||||||
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
if AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue) == .success,
|
||||||
|
|
@ -863,45 +876,55 @@ final class WindowTracker {
|
||||||
!tabs.isEmpty
|
!tabs.isEmpty
|
||||||
{
|
{
|
||||||
logger.info("Terminal has \(tabs.count) tabs, attempting to find correct one")
|
logger.info("Terminal has \(tabs.count) tabs, attempting to find correct one")
|
||||||
|
|
||||||
// Try to find the tab with matching session info
|
// Try to find the tab with matching session info
|
||||||
if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) {
|
if let sessionInfo = getSessionInfo(for: windowInfo.sessionID) {
|
||||||
let workingDir = sessionInfo.workingDir
|
let workingDir = sessionInfo.workingDir
|
||||||
let dirName = (workingDir as NSString).lastPathComponent
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
let sessionID = windowInfo.sessionID
|
let sessionID = windowInfo.sessionID
|
||||||
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
||||||
|
|
||||||
// Try multiple matching strategies
|
// Try multiple matching strategies
|
||||||
for (index, tab) in tabs.enumerated() {
|
for (index, tab) in tabs.enumerated() {
|
||||||
var titleValue: CFTypeRef?
|
var titleValue: CFTypeRef?
|
||||||
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) ==
|
||||||
let title = titleValue as? String
|
.success,
|
||||||
|
let title = titleValue as? String
|
||||||
{
|
{
|
||||||
// Check for session ID match first (most precise)
|
// Check for session ID match first (most precise)
|
||||||
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
||||||
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
logger.info("Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Selected tab \(index) by session ID for terminal \(windowInfo.terminalApp.rawValue)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for activity status match (unique for dynamic activities)
|
// Check for activity status match (unique for dynamic activities)
|
||||||
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
||||||
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
logger.info("Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Selected tab \(index) by activity '\(activity)' for terminal \(windowInfo.terminalApp.rawValue)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for directory match
|
// Check for directory match
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
logger.info("Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)")
|
logger
|
||||||
|
.info(
|
||||||
|
"Selected tab \(index) by directory for terminal \(windowInfo.terminalApp.rawValue)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Focused window using Accessibility API")
|
logger.info("Focused window using Accessibility API")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -1162,16 +1185,16 @@ final class WindowTracker {
|
||||||
SystemPermissionManager.shared.requestPermission(.accessibility)
|
SystemPermissionManager.shared.requestPermission(.accessibility)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find a tab that matches the session
|
/// Find a tab that matches the session
|
||||||
private func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? {
|
private func findMatchingTab(tabs: [AXUIElement], sessionInfo: ServerSessionInfo?) -> AXUIElement? {
|
||||||
guard let sessionInfo = sessionInfo else { return nil }
|
guard let sessionInfo else { return nil }
|
||||||
|
|
||||||
let workingDir = sessionInfo.workingDir
|
let workingDir = sessionInfo.workingDir
|
||||||
let dirName = (workingDir as NSString).lastPathComponent
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
let sessionID = sessionInfo.id
|
let sessionID = sessionInfo.id
|
||||||
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
||||||
|
|
||||||
for tab in tabs {
|
for tab in tabs {
|
||||||
var titleValue: CFTypeRef?
|
var titleValue: CFTypeRef?
|
||||||
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
|
||||||
|
|
@ -1181,34 +1204,34 @@ final class WindowTracker {
|
||||||
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
|
||||||
return tab
|
return tab
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Activity status with directory
|
// Priority 2: Activity status with directory
|
||||||
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
return tab
|
return tab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Command with directory
|
// Priority 3: Command with directory
|
||||||
if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) {
|
if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) {
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
return tab
|
return tab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: Directory only
|
// Priority 4: Directory only
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
return tab
|
return tab
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to select the correct Ghostty tab
|
/// Helper to select the correct Ghostty tab
|
||||||
private func selectGhosttyTab(tabs: [AXUIElement], windowInfo: WindowInfo, sessionInfo: ServerSessionInfo?) {
|
private func selectGhosttyTab(tabs: [AXUIElement], windowInfo: WindowInfo, sessionInfo: ServerSessionInfo?) {
|
||||||
guard let sessionInfo = sessionInfo else {
|
guard let sessionInfo else {
|
||||||
// No session info, select last tab as fallback
|
// No session info, select last tab as fallback
|
||||||
if let lastTab = tabs.last {
|
if let lastTab = tabs.last {
|
||||||
AXUIElementPerformAction(lastTab, kAXPressAction as CFString)
|
AXUIElementPerformAction(lastTab, kAXPressAction as CFString)
|
||||||
|
|
@ -1216,12 +1239,12 @@ final class WindowTracker {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let workingDir = sessionInfo.workingDir
|
let workingDir = sessionInfo.workingDir
|
||||||
let dirName = (workingDir as NSString).lastPathComponent
|
let dirName = (workingDir as NSString).lastPathComponent
|
||||||
let sessionID = windowInfo.sessionID
|
let sessionID = windowInfo.sessionID
|
||||||
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
|
||||||
|
|
||||||
// Try multiple matching strategies as in the main method
|
// Try multiple matching strategies as in the main method
|
||||||
for (index, tab) in tabs.enumerated() {
|
for (index, tab) in tabs.enumerated() {
|
||||||
var titleValue: CFTypeRef?
|
var titleValue: CFTypeRef?
|
||||||
|
|
@ -1234,7 +1257,7 @@ final class WindowTracker {
|
||||||
logger.info("Selected Ghostty tab \(index) by session ID")
|
logger.info("Selected Ghostty tab \(index) by session ID")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Activity status
|
// Priority 2: Activity status
|
||||||
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
if let activity = activityStatus, !activity.isEmpty, title.contains(activity) {
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
|
@ -1243,7 +1266,7 @@ final class WindowTracker {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Command
|
// Priority 3: Command
|
||||||
if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) {
|
if let command = sessionInfo.command.first, !command.isEmpty, title.contains(command) {
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
|
|
@ -1252,7 +1275,7 @@ final class WindowTracker {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: Directory only
|
// Priority 4: Directory only
|
||||||
if title.contains(dirName) || title.contains(workingDir) {
|
if title.contains(dirName) || title.contains(workingDir) {
|
||||||
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
AXUIElementPerformAction(tab, kAXPressAction as CFString)
|
||||||
|
|
@ -1261,7 +1284,7 @@ final class WindowTracker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: select last tab
|
// Fallback: select last tab
|
||||||
if let lastTab = tabs.last {
|
if let lastTab = tabs.last {
|
||||||
AXUIElementPerformAction(lastTab, kAXPressAction as CFString)
|
AXUIElementPerformAction(lastTab, kAXPressAction as CFString)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import SwiftUI
|
||||||
/// Provides a dropdown-style window for the menu bar application
|
/// Provides a dropdown-style window for the menu bar application
|
||||||
/// without the standard macOS popover arrow. Handles automatic positioning below
|
/// without the standard macOS popover arrow. Handles automatic positioning below
|
||||||
/// the status item, click-outside dismissal, and proper window management.
|
/// the status item, click-outside dismissal, and proper window management.
|
||||||
|
private enum DesignConstants {
|
||||||
|
static let menuCornerRadius: CGFloat = 12
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CustomMenuWindow: NSPanel {
|
final class CustomMenuWindow: NSPanel {
|
||||||
private var eventMonitor: Any?
|
private var eventMonitor: Any?
|
||||||
|
|
@ -15,6 +19,8 @@ final class CustomMenuWindow: NSPanel {
|
||||||
private var targetFrame: NSRect?
|
private var targetFrame: NSRect?
|
||||||
private weak var statusBarButton: NSStatusBarButton?
|
private weak var statusBarButton: NSStatusBarButton?
|
||||||
private var _isWindowVisible = false
|
private var _isWindowVisible = false
|
||||||
|
private var frameObserver: Any?
|
||||||
|
private var lastBounds: CGRect = .zero
|
||||||
|
|
||||||
/// Closure to be called when window hides
|
/// Closure to be called when window hides
|
||||||
var onHide: (() -> Void)?
|
var onHide: (() -> Void)?
|
||||||
|
|
@ -49,7 +55,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
isMovableByWindowBackground = false
|
isMovableByWindowBackground = false
|
||||||
hidesOnDeactivate = false
|
hidesOnDeactivate = false
|
||||||
isReleasedWhenClosed = false
|
isReleasedWhenClosed = false
|
||||||
|
|
||||||
// Allow the window to become key but not main
|
// Allow the window to become key but not main
|
||||||
// This helps maintain button highlight state
|
// This helps maintain button highlight state
|
||||||
acceptsMouseMovedEvents = false
|
acceptsMouseMovedEvents = false
|
||||||
|
|
@ -66,19 +72,29 @@ final class CustomMenuWindow: NSPanel {
|
||||||
|
|
||||||
// Create a custom mask layer for side-rounded corners
|
// Create a custom mask layer for side-rounded corners
|
||||||
let maskLayer = CAShapeLayer()
|
let maskLayer = CAShapeLayer()
|
||||||
maskLayer.path = createSideRoundedPath(in: contentView.bounds, cornerRadius: 12)
|
maskLayer.path = createSideRoundedPath(
|
||||||
|
in: contentView.bounds,
|
||||||
|
cornerRadius: DesignConstants.menuCornerRadius
|
||||||
|
)
|
||||||
contentView.layer?.mask = maskLayer
|
contentView.layer?.mask = maskLayer
|
||||||
|
lastBounds = contentView.bounds
|
||||||
|
|
||||||
// Update mask when bounds change
|
// Update mask when bounds change
|
||||||
contentView.postsFrameChangedNotifications = true
|
contentView.postsFrameChangedNotifications = true
|
||||||
NotificationCenter.default.addObserver(
|
self.frameObserver = NotificationCenter.default.addObserver(
|
||||||
forName: NSView.frameDidChangeNotification,
|
forName: NSView.frameDidChangeNotification,
|
||||||
object: contentView,
|
object: contentView,
|
||||||
queue: .main
|
queue: .main
|
||||||
) { [weak self, weak contentView] _ in
|
) { [weak self, weak contentView] _ in
|
||||||
guard let self = self, let contentView = contentView else { return }
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
maskLayer.path = self.createSideRoundedPath(in: contentView.bounds, cornerRadius: 12)
|
guard let self, let contentView else { return }
|
||||||
|
let currentBounds = contentView.bounds
|
||||||
|
guard currentBounds != self.lastBounds else { return }
|
||||||
|
self.lastBounds = currentBounds
|
||||||
|
maskLayer.path = self.createSideRoundedPath(
|
||||||
|
in: currentBounds,
|
||||||
|
cornerRadius: DesignConstants.menuCornerRadius
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,9 +107,8 @@ final class CustomMenuWindow: NSPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
func show(relativeTo statusItemButton: NSStatusBarButton) {
|
||||||
// Store button reference and ensure it stays highlighted
|
// Store button reference (state should already be set by StatusBarMenuManager)
|
||||||
self.statusBarButton = statusItemButton
|
self.statusBarButton = statusItemButton
|
||||||
statusItemButton.highlight(true)
|
|
||||||
|
|
||||||
// First, make sure the SwiftUI hierarchy has laid itself out
|
// First, make sure the SwiftUI hierarchy has laid itself out
|
||||||
hostingController.view.layoutSubtreeIfNeeded()
|
hostingController.view.layoutSubtreeIfNeeded()
|
||||||
|
|
@ -154,18 +169,14 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Set all visual properties at once
|
// Set all visual properties at once
|
||||||
alphaValue = 1.0
|
alphaValue = 1.0
|
||||||
|
|
||||||
// Ensure button remains highlighted
|
// Button state is managed by StatusBarMenuManager, don't change it here
|
||||||
statusBarButton?.highlight(true)
|
|
||||||
|
|
||||||
// Show window without activating the app aggressively
|
// Show window without activating the app aggressively
|
||||||
// This helps maintain the button's highlight state
|
// This helps maintain the button's highlight state
|
||||||
orderFront(nil)
|
orderFront(nil)
|
||||||
makeKey()
|
makeKey()
|
||||||
|
|
||||||
// Force button highlight update again after window is shown
|
// Button state is managed by StatusBarMenuManager
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.statusBarButton?.highlight(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set first responder after window is visible
|
// Set first responder after window is visible
|
||||||
makeFirstResponder(self)
|
makeFirstResponder(self)
|
||||||
|
|
@ -259,8 +270,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Mark window as not visible
|
// Mark window as not visible
|
||||||
_isWindowVisible = false
|
_isWindowVisible = false
|
||||||
|
|
||||||
// Reset button highlight when hiding
|
// Button state will be reset by StatusBarMenuManager via onHide callback
|
||||||
statusBarButton?.highlight(false)
|
|
||||||
orderOut(nil)
|
orderOut(nil)
|
||||||
teardownEventMonitoring()
|
teardownEventMonitoring()
|
||||||
onHide?()
|
onHide?()
|
||||||
|
|
@ -272,8 +282,7 @@ final class CustomMenuWindow: NSPanel {
|
||||||
// Mark window as not visible
|
// Mark window as not visible
|
||||||
_isWindowVisible = false
|
_isWindowVisible = false
|
||||||
|
|
||||||
// Reset button highlight when window is ordered out
|
// Button state will be reset by StatusBarMenuManager via onHide callback
|
||||||
statusBarButton?.highlight(false)
|
|
||||||
onHide?()
|
onHide?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,6 +334,9 @@ final class CustomMenuWindow: NSPanel {
|
||||||
deinit {
|
deinit {
|
||||||
MainActor.assumeIsolated {
|
MainActor.assumeIsolated {
|
||||||
teardownEventMonitoring()
|
teardownEventMonitoring()
|
||||||
|
if let observer = frameObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,7 +396,6 @@ final class CustomMenuWindow: NSPanel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A wrapper view that applies modern SwiftUI material background to menu content.
|
/// A wrapper view that applies modern SwiftUI material background to menu content.
|
||||||
struct CustomMenuContainer<Content: View>: View {
|
struct CustomMenuContainer<Content: View>: View {
|
||||||
@ViewBuilder let content: Content
|
@ViewBuilder let content: Content
|
||||||
|
|
@ -395,9 +406,9 @@ struct CustomMenuContainer<Content: View>: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: 12))
|
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius))
|
||||||
.overlay(
|
.overlay(
|
||||||
SideRoundedRectangle(cornerRadius: 12)
|
SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
|
||||||
.stroke(borderColor, lineWidth: 1)
|
.stroke(borderColor, lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,7 +292,11 @@ struct NewSessionForm: View {
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 6)
|
RoundedRectangle(cornerRadius: 6)
|
||||||
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(red: 0.2, green: 0.6, blue: 0.3))
|
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(
|
||||||
|
red: 0.2,
|
||||||
|
green: 0.6,
|
||||||
|
blue: 0.3
|
||||||
|
))
|
||||||
)
|
)
|
||||||
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
|
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,10 @@ final class StatusBarController: NSObject {
|
||||||
button.action = #selector(handleClick(_:))
|
button.action = #selector(handleClick(_:))
|
||||||
button.target = self
|
button.target = self
|
||||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||||
|
|
||||||
// Use pushOnPushOff for proper state management
|
// Use pushOnPushOff for proper state management
|
||||||
button.setButtonType(.pushOnPushOff)
|
button.setButtonType(.pushOnPushOff)
|
||||||
|
|
||||||
// Accessibility
|
// Accessibility
|
||||||
button.setAccessibilityTitle("VibeTunnel")
|
button.setAccessibilityTitle("VibeTunnel")
|
||||||
button.setAccessibilityRole(.button)
|
button.setAccessibilityRole(.button)
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,10 @@ final class StatusBarMenuManager: NSObject {
|
||||||
statusBarButton = button
|
statusBarButton = button
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to manage highlight here since we're using button state
|
// Reset button state when no menu is active
|
||||||
|
if newState == .none {
|
||||||
|
statusBarButton?.state = .off
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Left-Click Custom Window Management
|
// MARK: - Left-Click Custom Window Management
|
||||||
|
|
@ -144,20 +147,11 @@ final class StatusBarMenuManager: NSObject {
|
||||||
|
|
||||||
// Show the custom window
|
// Show the custom window
|
||||||
customWindow?.show(relativeTo: button)
|
customWindow?.show(relativeTo: button)
|
||||||
|
|
||||||
// Force immediate button highlight update after showing window
|
|
||||||
// This ensures the button stays highlighted even if there's a timing issue
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(for: .milliseconds(10))
|
|
||||||
button.highlight(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideCustomWindow() {
|
func hideCustomWindow() {
|
||||||
customWindow?.hide()
|
customWindow?.hide()
|
||||||
// Reset button state
|
// Button state will be reset by updateMenuState(.none) in the onHide callback
|
||||||
statusBarButton?.state = .off
|
|
||||||
// Note: state will be reset by the onHide callback
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isCustomWindowVisible: Bool {
|
var isCustomWindowVisible: Bool {
|
||||||
|
|
@ -189,7 +183,7 @@ final class StatusBarMenuManager: NSObject {
|
||||||
|
|
||||||
// Store status item reference
|
// Store status item reference
|
||||||
currentStatusItem = statusItem
|
currentStatusItem = statusItem
|
||||||
|
|
||||||
// Set the button's state to on for context menu
|
// Set the button's state to on for context menu
|
||||||
button.state = .on
|
button.state = .on
|
||||||
|
|
||||||
|
|
@ -356,7 +350,7 @@ extension StatusBarMenuManager: NSMenuDelegate {
|
||||||
func menuDidClose(_ menu: NSMenu) {
|
func menuDidClose(_ menu: NSMenu) {
|
||||||
// Reset button state
|
// Reset button state
|
||||||
statusBarButton?.state = .off
|
statusBarButton?.state = .off
|
||||||
|
|
||||||
// Reset menu state when context menu closes
|
// Reset menu state when context menu closes
|
||||||
updateMenuState(.none)
|
updateMenuState(.none)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -469,13 +469,13 @@ struct SessionRow: View {
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
// Show session name if available
|
// Show session name if available
|
||||||
if let name = session.value.name, !name.isEmpty {
|
if let name = session.value.name, !name.isEmpty {
|
||||||
Text("–")
|
Text("–")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary.opacity(0.6))
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
|
||||||
Text(name)
|
Text(name)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -657,10 +657,10 @@ struct SessionRow: View {
|
||||||
guard let firstCommand = session.value.command.first else {
|
guard let firstCommand = session.value.command.first else {
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract just the executable name from the path
|
// Extract just the executable name from the path
|
||||||
let executableName = (firstCommand as NSString).lastPathComponent
|
let executableName = (firstCommand as NSString).lastPathComponent
|
||||||
|
|
||||||
// Special handling for common commands
|
// Special handling for common commands
|
||||||
switch executableName {
|
switch executableName {
|
||||||
case "zsh", "bash", "sh":
|
case "zsh", "bash", "sh":
|
||||||
|
|
@ -668,7 +668,8 @@ struct SessionRow: View {
|
||||||
if session.value.command.count > 2,
|
if session.value.command.count > 2,
|
||||||
session.value.command.contains("-c"),
|
session.value.command.contains("-c"),
|
||||||
let cIndex = session.value.command.firstIndex(of: "-c"),
|
let cIndex = session.value.command.firstIndex(of: "-c"),
|
||||||
cIndex + 1 < session.value.command.count {
|
cIndex + 1 < session.value.command.count
|
||||||
|
{
|
||||||
let actualCommand = session.value.command[cIndex + 1]
|
let actualCommand = session.value.command[cIndex + 1]
|
||||||
return (actualCommand as NSString).lastPathComponent
|
return (actualCommand as NSString).lastPathComponent
|
||||||
}
|
}
|
||||||
|
|
@ -677,7 +678,7 @@ struct SessionRow: View {
|
||||||
return executableName
|
return executableName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sessionName: String {
|
private var sessionName: String {
|
||||||
// Use the session name if available, otherwise fall back to directory name
|
// Use the session name if available, otherwise fall back to directory name
|
||||||
if let name = session.value.name, !name.isEmpty {
|
if let name = session.value.name, !name.isEmpty {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ struct SideRoundedRectangle: Shape {
|
||||||
// Top edge (flat)
|
// Top edge (flat)
|
||||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||||
|
|
||||||
// Top-right corner (flat)
|
|
||||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
|
||||||
|
|
||||||
// Right edge with rounded corners
|
// Right edge with rounded corners
|
||||||
path.addArc(
|
path.addArc(
|
||||||
center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius),
|
center: CGPoint(x: rect.maxX - cornerRadius, y: rect.minY + cornerRadius),
|
||||||
|
|
@ -40,9 +37,6 @@ struct SideRoundedRectangle: Shape {
|
||||||
// Bottom edge (flat)
|
// Bottom edge (flat)
|
||||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
|
||||||
// Bottom-left corner (flat)
|
|
||||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
|
||||||
|
|
||||||
// Left edge with rounded corners
|
// Left edge with rounded corners
|
||||||
path.addArc(
|
path.addArc(
|
||||||
center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius),
|
center: CGPoint(x: rect.minX + cornerRadius, y: rect.maxY - cornerRadius),
|
||||||
|
|
@ -66,4 +60,4 @@ struct SideRoundedRectangle: Shape {
|
||||||
|
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -349,13 +349,13 @@ struct SessionRowView: View {
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
// Show session name if available
|
// Show session name if available
|
||||||
if let name = session.value.name, !name.isEmpty {
|
if let name = session.value.name, !name.isEmpty {
|
||||||
Text("–")
|
Text("–")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary.opacity(0.6))
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
|
||||||
Text(name)
|
Text(name)
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
@ -435,10 +435,10 @@ struct SessionRowView: View {
|
||||||
guard let firstCommand = session.value.command.first else {
|
guard let firstCommand = session.value.command.first else {
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract just the executable name from the path
|
// Extract just the executable name from the path
|
||||||
let executableName = (firstCommand as NSString).lastPathComponent
|
let executableName = (firstCommand as NSString).lastPathComponent
|
||||||
|
|
||||||
// Special handling for common commands
|
// Special handling for common commands
|
||||||
switch executableName {
|
switch executableName {
|
||||||
case "zsh", "bash", "sh":
|
case "zsh", "bash", "sh":
|
||||||
|
|
@ -446,7 +446,8 @@ struct SessionRowView: View {
|
||||||
if session.value.command.count > 2,
|
if session.value.command.count > 2,
|
||||||
session.value.command.contains("-c"),
|
session.value.command.contains("-c"),
|
||||||
let cIndex = session.value.command.firstIndex(of: "-c"),
|
let cIndex = session.value.command.firstIndex(of: "-c"),
|
||||||
cIndex + 1 < session.value.command.count {
|
cIndex + 1 < session.value.command.count
|
||||||
|
{
|
||||||
let actualCommand = session.value.command[cIndex + 1]
|
let actualCommand = session.value.command[cIndex + 1]
|
||||||
return (actualCommand as NSString).lastPathComponent
|
return (actualCommand as NSString).lastPathComponent
|
||||||
}
|
}
|
||||||
|
|
@ -455,7 +456,7 @@ struct SessionRowView: View {
|
||||||
return executableName
|
return executableName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sessionName: String {
|
private var sessionName: String {
|
||||||
// Extract the working directory name as the session name
|
// Extract the working directory name as the session name
|
||||||
let workingDir = session.value.workingDir
|
let workingDir = session.value.workingDir
|
||||||
|
|
|
||||||
|
|
@ -59,15 +59,16 @@ final class CLIInstaller {
|
||||||
vtTargetPath,
|
vtTargetPath,
|
||||||
"/opt/homebrew/bin/vt"
|
"/opt/homebrew/bin/vt"
|
||||||
]
|
]
|
||||||
|
|
||||||
for path in pathsToCheck {
|
for path in pathsToCheck {
|
||||||
if FileManager.default.fileExists(atPath: path) {
|
if FileManager.default.fileExists(atPath: path) {
|
||||||
// Check if it contains the correct app path reference
|
// Check if it contains the correct app path reference
|
||||||
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
|
if let content = try? String(contentsOfFile: path, encoding: .utf8) {
|
||||||
// Verify it's our wrapper script with all expected components
|
// Verify it's our wrapper script with all expected components
|
||||||
if content.contains("VibeTunnel CLI wrapper") &&
|
if content.contains("VibeTunnel CLI wrapper") &&
|
||||||
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&
|
content.contains("$TRY_PATH/Contents/Resources/vibetunnel") &&
|
||||||
content.contains("exec \"$VIBETUNNEL_BIN\" fwd") {
|
content.contains("exec \"$VIBETUNNEL_BIN\" fwd")
|
||||||
|
{
|
||||||
isCorrectlyInstalled = true
|
isCorrectlyInstalled = true
|
||||||
logger.info("CLIInstaller: Found valid vt script at \(path)")
|
logger.info("CLIInstaller: Found valid vt script at \(path)")
|
||||||
break
|
break
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue