Improve release scripts and add git app integration (#208)

This commit is contained in:
Peter Steinberger 2025-07-03 12:40:09 +01:00 committed by GitHub
parent 74a364d1ba
commit 45d8f97a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1338 additions and 302 deletions

View file

@ -1,5 +1,52 @@
# Changelog
## [1.0.0-beta.7] - 2025-07-03
### ✨ New Features
#### **Git App Integration** 🆕
- **Preferred Git App Selection** - Choose your favorite Git app (Tower, Fork, GitHub Desktop, SourceTree, Sublime Merge, GitUp, Visual Studio Code, Cursor, or Windsurf) in Settings
- **Quick Repository Access** - Open any session's working directory in your preferred Git app directly from the context menu
- **Smart Auto-Detection** - VibeTunnel automatically detects installed Git apps and pre-selects the best one based on popularity
- **Unified Apps Section** - Terminal and Git app preferences are now organized in a single, clean "Apps" section in Settings
#### **Session Name Management**
- **Server-Side Unique Names** - Session names are now automatically made unique by the server, preventing duplicate names that could cause tab selection issues
- **Smart Suffixes** - When renaming a session to an existing name, the server automatically adds a suffix like "(2)" to ensure uniqueness
- **Consistent Experience** - Both web and native clients respect server-assigned names for a unified experience
### 🎨 Improvements
#### **UI Polish**
- **Enhanced Menu Design** - Improved visual hierarchy with better spacing, subtle background tints, and refined hover effects
- **Git Status Display** - Git branch and change indicators now have better alignment and visual clarity in session rows
- **Path Display** - Working directory paths are now more readable with improved truncation and clickable folder icons
- **Settings Organization** - Cleaner settings layout with constants for maintainable code
#### **Menu Bar Enhancements**
- **Server Status Clarity** - Menu bar icon opacity now only indicates server running status, not session count
- **Expanded Menu Height** - Increased maximum menu height from 400 to 600 pixels for better visibility with many sessions
- **Consistent Context Menus** - Unified context menu design across all UI elements
### 🛠️ Technical Improvements
- **Code Quality** - Removed legacy Tower 2 support for cleaner, more maintainable code
- **Build System** - Enhanced release scripts with better error handling and recovery mechanisms
- **Type Safety** - Fixed TypeScript and Swift type issues for more robust code
- **Formatting** - Applied consistent code formatting with Biome for better readability
### 🐛 Bug Fixes
- Fixed Test button sizing to match Terminal picker height for visual consistency
- Removed invalid "System Default" option from Git app picker (no system default exists for Git apps)
- Fixed CI pipeline issues with proper code formatting
- Improved window detection logic for more reliable session tracking
### 📦 Dependencies
- Updated to version 1.0.0-beta.7 across all components
- Maintained compatibility with macOS 14.0+
## [1.0.0-beta.6] - 2025-07-03
### ✨ New Features

View file

@ -66,14 +66,14 @@ final class SessionMonitor {
/// Reference to GitRepositoryMonitor for pre-caching
weak var gitRepositoryMonitor: GitRepositoryMonitor?
/// Timer for periodic refresh
private var refreshTimer: Timer?
private init() {
let port = UserDefaults.standard.integer(forKey: "serverPort")
self.serverPort = port > 0 ? port : 4_020
// Start periodic refresh
startPeriodicRefresh()
}
@ -172,12 +172,12 @@ final class SessionMonitor {
self.lastFetch = Date() // Still update timestamp to avoid hammering
}
}
/// Start periodic refresh of sessions
private func startPeriodicRefresh() {
// Clean up any existing timer
refreshTimer?.invalidate()
// Create a new timer that fires every 3 seconds
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in

View file

@ -76,16 +76,16 @@ final class WindowFocuser {
private func focusiTerm2Window(_ windowInfo: WindowEnumerator.WindowInfo) {
// iTerm2 has its own tab system that doesn't use standard macOS tabs
// We need to use AppleScript to find and select the correct tab
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
let workingDir = sessionInfo?.workingDir ?? ""
let dirName = (workingDir as NSString).lastPathComponent
// Try to find and focus the tab with matching content
let script = """
tell application "iTerm2"
activate
-- Look through all windows
repeat with w in windows
-- Look through all tabs in the window
@ -94,7 +94,7 @@ final class WindowFocuser {
repeat with s in sessions of t
-- Check if the session's name or working directory matches
set sessionName to name of s
-- Try to match by session content
if sessionName contains "\(windowInfo.sessionID)" or sessionName contains "\(dirName)" then
-- Found it! Select this tab and window
@ -106,7 +106,7 @@ final class WindowFocuser {
end repeat
end repeat
end repeat
-- If we have a window ID, at least focus that window
if "\(windowInfo.tabID ?? "")" is not "" then
try
@ -128,6 +128,27 @@ final class WindowFocuser {
}
}
/// Get the first tab group in a window (improved approach based on screenshot)
private func getTabGroup(from window: AXUIElement) -> AXUIElement? {
var childrenRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
window,
kAXChildrenAttribute as CFString,
&childrenRef
) == .success,
let children = childrenRef as? [AXUIElement]
else {
return nil
}
// Find the first element with role kAXTabGroupRole
return children.first { elem in
var roleRef: CFTypeRef?
AXUIElementCopyAttributeValue(elem, kAXRoleAttribute as CFString, &roleRef)
return (roleRef as? String) == kAXTabGroupRole as String
}
}
/// Select the correct tab in a window that uses macOS standard tabs
private func selectTab(
tabs: [AXUIElement],
@ -135,21 +156,31 @@ final class WindowFocuser {
sessionInfo: ServerSessionInfo?
) {
logger.debug("Attempting to select tab for session \(windowInfo.sessionID) from \(tabs.count) tabs")
// Try to find the correct tab
if let matchingTab = windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) {
// Found matching tab - select it
// Found matching tab - select it using kAXPressAction (most reliable)
let result = AXUIElementPerformAction(matchingTab, kAXPressAction as CFString)
if result == .success {
logger.info("Successfully selected matching tab for session \(windowInfo.sessionID)")
} else {
logger.warning("Failed to select tab, error: \(result.rawValue)")
logger.warning("Failed to select tab with kAXPressAction, error: \(result.rawValue)")
// Try alternative selection method - set as selected
var selectedValue: CFTypeRef?
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) == .success {
AXUIElementSetAttributeValue(matchingTab, kAXSelectedAttribute as CFString, true as CFTypeRef)
logger.info("Selected tab using AXSelected attribute")
if AXUIElementCopyAttributeValue(matchingTab, kAXSelectedAttribute as CFString, &selectedValue) ==
.success
{
let setResult = AXUIElementSetAttributeValue(
matchingTab,
kAXSelectedAttribute as CFString,
true as CFTypeRef
)
if setResult == .success {
logger.info("Selected tab using AXSelected attribute")
} else {
logger.error("Failed to set AXSelected attribute, error: \(setResult.rawValue)")
}
}
}
} else if tabs.count == 1 {
@ -158,19 +189,42 @@ final class WindowFocuser {
logger.info("Selected the only available tab")
} else {
// Multiple tabs but no match - try to find by index or select first
logger.warning("Multiple tabs (\(tabs.count)) but could not identify correct one for session \(windowInfo.sessionID)")
logger
.warning(
"Multiple tabs (\(tabs.count)) but could not identify correct one for session \(windowInfo.sessionID)"
)
// Log tab titles for debugging
for (index, tab) in tabs.enumerated() {
var titleValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tab, kAXTitleAttribute as CFString, &titleValue) == .success,
let title = titleValue as? String {
let title = titleValue as? String
{
logger.debug(" Tab \(index): \(title)")
}
}
}
}
/// Select a tab by index in a tab group (helper method from screenshot)
private func selectTab(at index: Int, in group: AXUIElement) -> Bool {
var tabsRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
group,
"AXTabs" as CFString,
&tabsRef
) == .success,
let tabs = tabsRef as? [AXUIElement],
index < tabs.count
else {
logger.warning("Could not get tabs from group or index out of bounds")
return false
}
let result = AXUIElementPerformAction(tabs[index], kAXPressAction as CFString)
return result == .success
}
/// Focuses a window using Accessibility APIs.
private func focusWindowUsingAccessibility(_ windowInfo: WindowEnumerator.WindowInfo) {
// First bring the application to front
@ -197,14 +251,14 @@ final class WindowFocuser {
// Get session info for tab matching
let sessionInfo = SessionMonitor.shared.sessions[windowInfo.sessionID]
// First, try to find window with matching tab content
var foundWindowWithTab = false
for (index, window) in windows.enumerated() {
// Check different window ID attributes (different apps use different ones)
var windowMatches = false
// Try _AXWindowNumber (used by many apps)
var windowIDValue: CFTypeRef?
if AXUIElementCopyAttributeValue(window, "_AXWindowNumber" as CFString, &windowIDValue) == .success,
@ -213,42 +267,70 @@ final class WindowFocuser {
windowMatches = (axWindowID == windowInfo.windowID)
logger.debug("Window \(index) _AXWindowNumber: \(axWindowID), matches: \(windowMatches)")
}
// Check if this window has tabs
var tabsValue: CFTypeRef?
let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue)
if hasTabsResult == .success,
let tabs = tabsValue as? [AXUIElement],
!tabs.isEmpty
{
logger.info("Window \(index) has \(tabs.count) tabs")
// Try to find matching tab
if windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) != nil {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Make window main and focused
// Try the improved approach: get tab group first
if let tabGroup = getTabGroup(from: window) {
// Get tabs from the tab group
var tabsValue: CFTypeRef?
if AXUIElementCopyAttributeValue(tabGroup, "AXTabs" as CFString, &tabsValue) == .success,
let tabs = tabsValue as? [AXUIElement],
!tabs.isEmpty
{
logger.info("Window \(index) has tab group with \(tabs.count) tabs")
// Try to find matching tab
if windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) != nil {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Make window main and focused
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
return
}
}
} else {
// Fallback: Try direct tabs attribute (older approach)
var tabsValue: CFTypeRef?
let hasTabsResult = AXUIElementCopyAttributeValue(window, kAXTabsAttribute as CFString, &tabsValue)
if hasTabsResult == .success,
let tabs = tabsValue as? [AXUIElement],
!tabs.isEmpty
{
logger.info("Window \(index) has \(tabs.count) tabs (direct attribute)")
// Try to find matching tab
if windowMatcher.findMatchingTab(tabs: tabs, sessionInfo: sessionInfo) != nil {
// Found the tab! Focus the window and select the tab
logger.info("Found matching tab in window \(index)")
// Make window main and focused
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
return
}
} else if windowMatches {
// Window matches by ID but has no tabs (or tabs not accessible)
logger.info("Window \(index) matches by ID but has no accessible tabs")
// Focus the window anyway
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
// Select the tab
selectTab(tabs: tabs, windowInfo: windowInfo, sessionInfo: sessionInfo)
foundWindowWithTab = true
logger.info("Focused window \(windowInfo.windowID) without tab selection")
return
}
} else if windowMatches {
// Window matches by ID but has no tabs (or tabs not accessible)
logger.info("Window \(index) matches by ID but has no accessible tabs")
// Focus the window anyway
AXUIElementSetAttributeValue(window, kAXMainAttribute as CFString, true as CFTypeRef)
AXUIElementSetAttributeValue(window, kAXFocusedAttribute as CFString, true as CFTypeRef)
logger.info("Focused window \(windowInfo.windowID) without tab selection")
return
}
}

View file

@ -127,7 +127,7 @@ final class WindowMatcher {
// First try to find window by process PID traversal
if let sessionPID = sessionInfo.pid {
logger.debug("Scanning for window by process PID: \(sessionPID) for session \(sessionID)")
// Log the process tree for debugging
processTracker.logProcessTree(for: pid_t(sessionPID))
@ -135,7 +135,7 @@ final class WindowMatcher {
var currentPID = pid_t(sessionPID)
var depth = 0
let maxDepth = 20 // Increased depth for deeply nested sessions
while depth < maxDepth {
// Check if any window is owned by this PID
if let matchingWindow = allWindows.first(where: { window in
@ -144,7 +144,7 @@ final class WindowMatcher {
logger.info("Found window by PID \(currentPID) at depth \(depth) for session \(sessionID)")
return matchingWindow
}
// Move up to parent process
if let parentPID = processTracker.getParentProcessID(of: currentPID) {
if parentPID == 0 || parentPID == 1 {
@ -157,14 +157,14 @@ final class WindowMatcher {
break
}
}
logger.debug("Process traversal completed at depth \(depth) without finding window")
}
// Fallback: Find by working directory
let workingDir = sessionInfo.workingDir
let dirName = (workingDir as NSString).lastPathComponent
logger.debug("Trying to match by directory: \(dirName) or full path: \(workingDir)")
// Look for windows whose title contains the directory name
@ -185,7 +185,7 @@ final class WindowMatcher {
// Try to match by activity status (for sessions with specific activities)
if let activity = sessionInfo.activityStatus?.specificStatus?.status, !activity.isEmpty {
logger.debug("Trying to match by activity: \(activity)")
if let matchingWindow = allWindows.first(where: { window in
if let title = window.title {
return title.contains(activity)
@ -200,9 +200,12 @@ final class WindowMatcher {
logger.warning("Could not find window for session \(sessionID) after all attempts")
logger.debug("Available windows: \(allWindows.count)")
for (index, window) in allWindows.enumerated() {
logger.debug(" Window \(index): PID=\(window.ownerPID), Terminal=\(window.terminalApp.rawValue), Title=\(window.title ?? "<no title>")")
logger
.debug(
" Window \(index): PID=\(window.ownerPID), Terminal=\(window.terminalApp.rawValue), Title=\(window.title ?? "<no title>")"
)
}
return nil
}
@ -215,7 +218,7 @@ final class WindowMatcher {
let sessionID = sessionInfo.id
let activityStatus = sessionInfo.activityStatus?.specificStatus?.status
let sessionName = sessionInfo.name
logger.debug("Looking for tab matching session \(sessionID) in \(tabs.count) tabs")
logger.debug(" Working dir: \(workingDir)")
logger.debug(" Dir name: \(dirName)")
@ -228,13 +231,13 @@ final class WindowMatcher {
let title = titleValue as? String
{
logger.debug("Tab \(index) title: \(title)")
// Check for session ID match first (most precise)
if title.contains(sessionID) || title.contains("TTY_SESSION_ID=\(sessionID)") {
logger.info("Found tab by session ID match at index \(index)")
return tab
}
// Check for session name match
if let name = sessionName, !name.isEmpty, title.contains(name) {
logger.info("Found tab by session name match: \(name) at index \(index)")
@ -251,12 +254,12 @@ final class WindowMatcher {
let titleLower = title.lowercased()
let dirNameLower = dirName.lowercased()
let workingDirLower = workingDir.lowercased()
if titleLower.contains(dirNameLower) || titleLower.contains(workingDirLower) {
logger.info("Found tab by directory match at index \(index)")
return tab
}
// Check if the tab title ends with the directory name (common pattern)
if title.hasSuffix(dirName) || title.hasSuffix(" - \(dirName)") {
logger.info("Found tab by directory suffix match at index \(index)")
@ -266,7 +269,7 @@ final class WindowMatcher {
logger.debug("Tab \(index): Could not get title")
}
}
logger.warning("No matching tab found for session \(sessionID)")
return nil
}

View file

@ -435,6 +435,11 @@ struct CustomMenuContainer<Content: View>: View {
var body: some View {
content
.fixedSize()
.background {
// First layer: tinted background for better readability
SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
.fill(backgroundTint)
}
.background(backgroundMaterial, in: SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius))
.overlay(
SideRoundedRectangle(cornerRadius: DesignConstants.menuCornerRadius)
@ -442,6 +447,19 @@ struct CustomMenuContainer<Content: View>: View {
)
}
private var backgroundTint: Color {
switch colorScheme {
case .dark:
// Black tint at 25% opacity for better text readability
Color.black.opacity(0.25)
case .light:
// White tint at 45% opacity for better contrast
Color.white.opacity(0.45)
@unknown default:
Color.black.opacity(0.25)
}
}
private var borderColor: Color {
switch colorScheme {
case .dark:

View file

@ -10,8 +10,19 @@ struct GitRepositoryRow: View {
@Environment(\.colorScheme)
private var colorScheme
private var gitAppName: String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp)
{
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
private var branchInfo: some View {
HStack(spacing: 2) {
HStack(spacing: 1) {
Image(systemName: "arrow.branch")
.font(.system(size: 9))
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
@ -21,33 +32,52 @@ struct GitRepositoryRow: View {
.foregroundColor(AppColors.Fallback.gitBranch(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: 60)
}
}
private var changeIndicators: some View {
Group {
if repository.hasChanges {
HStack(spacing: 2) {
HStack(spacing: 3) {
if repository.modifiedCount > 0 {
Text("M:\(repository.modifiedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitModified(for: colorScheme))
HStack(spacing: 1) {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90")
.font(.system(size: 8))
.foregroundColor(AppColors.Fallback.gitModified(for: colorScheme))
Text("\(repository.modifiedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitModified(for: colorScheme))
}
}
if repository.addedCount > 0 {
Text("A:\(repository.addedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitAdded(for: colorScheme))
HStack(spacing: 1) {
Image(systemName: "plus")
.font(.system(size: 8, weight: .medium))
.foregroundColor(AppColors.Fallback.gitAdded(for: colorScheme))
Text("\(repository.addedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitAdded(for: colorScheme))
}
}
if repository.deletedCount > 0 {
Text("D:\(repository.deletedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitDeleted(for: colorScheme))
HStack(spacing: 1) {
Image(systemName: "minus")
.font(.system(size: 8, weight: .medium))
.foregroundColor(AppColors.Fallback.gitDeleted(for: colorScheme))
Text("\(repository.deletedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitDeleted(for: colorScheme))
}
}
if repository.untrackedCount > 0 {
Text("U:\(repository.untrackedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitUntracked(for: colorScheme))
HStack(spacing: 1) {
Image(systemName: "questionmark")
.font(.system(size: 8))
.foregroundColor(AppColors.Fallback.gitUntracked(for: colorScheme))
Text("\(repository.untrackedCount)")
.font(.system(size: 9, weight: .medium))
.foregroundColor(AppColors.Fallback.gitUntracked(for: colorScheme))
}
}
}
}
@ -60,8 +90,13 @@ struct GitRepositoryRow: View {
}
private var backgroundFillColor: Color {
// Only show background on hover - very subtle
isHovering ? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15) : Color.clear
// Show background on hover - stronger in light mode
if isHovering {
return colorScheme == .light
? AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.25)
: AppColors.Fallback.controlBackground(for: colorScheme).opacity(0.15)
}
return Color.clear
}
private var borderView: some View {
@ -70,23 +105,31 @@ struct GitRepositoryRow: View {
}
private var borderColor: Color {
// Only show border on hover
isHovering ? AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.2) : Color.clear
// Show border on hover - stronger in light mode
if isHovering {
return colorScheme == .light
? AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.3)
: AppColors.Fallback.gitBorder(for: colorScheme).opacity(0.2)
}
return Color.clear
}
var body: some View {
HStack(spacing: 3) {
HStack(spacing: 2) {
// Branch info
branchInfo
if repository.hasChanges {
Text("")
.font(.system(size: 8))
.foregroundColor(.secondary.opacity(0.5))
changeIndicators
}
changeIndicators
Spacer()
}
.padding(.horizontal, 6)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(backgroundView)
.overlay(borderView)
@ -96,63 +139,11 @@ struct GitRepositoryRow: View {
.onTapGesture {
openInGitApp()
}
.help("Open in Git app")
.contextMenu {
Button("Open in Tower") {
openInGitApp()
}
Button("Open Repository in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repository.path)
}
if repository.githubURL != nil {
Button("Open on GitHub") {
if let url = repository.githubURL {
NSWorkspace.shared.open(url)
}
}
}
Divider()
Button("Copy Branch Name") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repository.currentBranch ?? "detached", forType: .string)
}
Button("Copy Repository Path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repository.path, forType: .string)
}
}
.help("Open in \(gitAppName)")
.animation(.easeInOut(duration: 0.15), value: isHovering)
}
private func openInGitApp() {
// Try to open in Tower first, fall back to SourceTree, then GitKraken
let gitApps = [
"com.fournova.Tower3",
"com.fournova.Tower2",
"com.torusknot.SourceTreeNotMAS",
"com.axosoft.gitkraken"
]
let url = URL(fileURLWithPath: repository.path)
// Try each app in order
for appIdentifier in gitApps {
if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: appIdentifier) {
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
return
}
}
// If no git app found, open in Finder as fallback
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repository.path)
GitAppLauncher.shared.openRepository(at: repository.path)
}
}

View file

@ -30,7 +30,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringNewSession ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringNewSession ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringNewSession)
@ -62,7 +62,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringSettings ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringSettings ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringSettings)
@ -96,7 +96,7 @@ struct MenuActionBar: View {
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHoveringQuit ? AppColors.Fallback.controlBackground(for: colorScheme)
.opacity(0.15) : Color.clear
.opacity(colorScheme == .light ? 0.25 : 0.15) : Color.clear
)
.scaleEffect(isHoveringQuit ? 1.1 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHoveringQuit)

View file

@ -102,6 +102,7 @@ struct ServerAddressRow: View {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
.frame(width: 14, alignment: .center)
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)

View file

@ -27,25 +27,24 @@ struct SessionRow: View {
@State private var isTerminating = false
@State private var isEditing = false
@State private var editedName = ""
@State private var gitRepository: GitRepository?
@State private var isHoveringFolder = false
@FocusState private var isEditFieldFocused: Bool
// Computed property that reads directly from the monitor's cache
// This will automatically update when the monitor refreshes
private var gitRepository: GitRepository? {
gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
}
var body: some View {
Button(action: handleTap) {
content
}
.buttonStyle(PlainButtonStyle())
.onAppear {
// First, try to get cached data synchronously
if gitRepository == nil {
gitRepository = gitRepositoryMonitor.getCachedRepository(for: session.value.workingDir)
}
}
.task(id: session.value.workingDir) {
// Then fetch fresh data asynchronously (this will update the cache)
if let freshData = await gitRepositoryMonitor.findRepository(for: session.value.workingDir) {
gitRepository = freshData
// Fetch repository data if not already cached
if gitRepository == nil {
_ = await gitRepositoryMonitor.findRepository(for: session.value.workingDir)
}
}
}
@ -125,7 +124,7 @@ struct SessionRow: View {
// Second row: Path, Git info, Duration and X button
HStack(alignment: .center, spacing: 6) {
// Left side: Path and git info
HStack(alignment: .center, spacing: 6) {
HStack(alignment: .center, spacing: 4) {
// Folder icon and path - clickable as one unit
Button(action: {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
@ -139,7 +138,7 @@ struct SessionRow: View {
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.truncationMode(.head)
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
@ -249,14 +248,20 @@ struct SessionRow: View {
openWindow(id: "session-detail", value: session.key)
}
Divider()
Button("Show in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: session.value.workingDir)
}
// Add git repository options if available
if let repo = gitRepository {
Button("Open Git Repository in Finder") {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: repo.path)
Divider()
// Open in Git app
let gitAppName = getGitAppName()
Button("Open in \(gitAppName)") {
GitAppLauncher.shared.openRepository(at: repo.path)
}
if repo.githubURL != nil {
@ -266,8 +271,22 @@ struct SessionRow: View {
}
}
}
Divider()
Button("Copy Branch Name") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.currentBranch ?? "detached", forType: .string)
}
Button("Copy Repository Path") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(repo.path, forType: .string)
}
}
Divider()
Button("Rename Session...") {
startEditing()
}
@ -300,6 +319,16 @@ struct SessionRow: View {
}
}
private func getGitAppName() -> String {
if let preferredApp = UserDefaults.standard.string(forKey: "preferredGitApp"),
!preferredApp.isEmpty,
let gitApp = GitApp(rawValue: preferredApp) {
return gitApp.displayName
}
// Return first installed git app or default
return GitApp.installed.first?.displayName ?? "Git App"
}
private func terminateSession() {
isTerminating = true

View file

@ -136,8 +136,8 @@ final class StatusBarController: NSObject {
func updateStatusItemDisplay() {
guard let button = statusItem?.button else { return }
// Update icon based on server and network status
let iconName = (serverManager.isRunning && hasNetworkAccess) ? "menubar" : "menubar.inactive"
// Update icon based on server status only
let iconName = serverManager.isRunning ? "menubar" : "menubar.inactive"
if let image = NSImage(named: iconName) {
image.isTemplate = true
button.image = image
@ -146,7 +146,7 @@ final class StatusBarController: NSObject {
if let image = NSImage(named: "menubar") {
image.isTemplate = true
button.image = image
button.alphaValue = (serverManager.isRunning && hasNetworkAccess) ? 1.0 : 0.5
button.alphaValue = serverManager.isRunning ? 1.0 : 0.5
}
}

View file

@ -148,9 +148,17 @@ final class StatusBarMenuManager: NSObject {
customWindow = nil
customWindow = CustomMenuWindow(contentView: containerView)
// Set up callback to reset state when window hides
// Set up callbacks for window show/hide
customWindow?.onShow = { [weak self] in
// Start monitoring git repositories for updates every 5 seconds
self?.gitRepositoryMonitor?.startMonitoring()
}
customWindow?.onHide = { [weak self] in
self?.statusBarButton?.highlight(false)
// Stop monitoring git repositories when menu closes
self?.gitRepositoryMonitor?.stopMonitoring()
// Ensure state is reset on main thread
Task { @MainActor in

View file

@ -92,7 +92,7 @@ struct VibeTunnelMenuView: View {
}
)
}
.frame(maxHeight: 400)
.frame(maxHeight: 600)
Divider()

View file

@ -1,5 +1,7 @@
import os
import SwiftUI
@preconcurrency import ScreenCaptureKit
import ApplicationServices
/// View displaying detailed information about a specific terminal session.
///
@ -9,75 +11,192 @@ import SwiftUI
struct SessionDetailView: View {
let session: ServerSessionInfo
@State private var windowTitle = ""
@State private var windowInfo: WindowEnumerator.WindowInfo?
@State private var windowScreenshot: NSImage?
@State private var isCapturingScreenshot = false
@State private var hasScreenCapturePermission = false
@State private var isFindingWindow = false
@State private var windowSearchAttempted = false
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "SessionDetailView")
var body: some View {
VStack(alignment: .leading, spacing: 20) {
// Session Header
VStack(alignment: .leading, spacing: 8) {
Text("Session Details")
.font(.largeTitle)
.fontWeight(.bold)
HStack(spacing: 30) {
// Left side: Session Information
VStack(alignment: .leading, spacing: 20) {
// Session Header
VStack(alignment: .leading, spacing: 8) {
Text("Session Details")
.font(.largeTitle)
.fontWeight(.bold)
HStack {
if let pid = session.pid {
Label("PID: \(pid)", systemImage: "number.circle.fill")
.font(.title3)
} else {
Label("PID: N/A", systemImage: "number.circle.fill")
.font(.title3)
}
Spacer()
StatusBadge(isRunning: session.isRunning)
}
}
.padding(.bottom, 10)
// Session Information
VStack(alignment: .leading, spacing: 16) {
DetailRow(label: "Session ID", value: session.id)
DetailRow(label: "Command", value: session.command.joined(separator: " "))
DetailRow(label: "Working Directory", value: session.workingDir)
DetailRow(label: "Status", value: session.status.capitalized)
DetailRow(label: "Started At", value: formatDate(session.startedAt))
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
HStack {
if let pid = session.pid {
Label("PID: \(pid)", systemImage: "number.circle.fill")
.font(.title3)
} else {
Label("PID: N/A", systemImage: "number.circle.fill")
.font(.title3)
DetailRow(label: "Process ID", value: "\(pid)")
}
Spacer()
StatusBadge(isRunning: session.isRunning)
if let exitCode = session.exitCode {
DetailRow(label: "Exit Code", value: "\(exitCode)")
}
}
}
.padding(.bottom, 10)
// Session Information
VStack(alignment: .leading, spacing: 16) {
DetailRow(label: "Session ID", value: session.id)
DetailRow(label: "Command", value: session.command.joined(separator: " "))
DetailRow(label: "Working Directory", value: session.workingDir)
DetailRow(label: "Status", value: session.status.capitalized)
DetailRow(label: "Started At", value: formatDate(session.startedAt))
DetailRow(label: "Last Modified", value: formatDate(session.lastModified))
if let pid = session.pid {
DetailRow(label: "Process ID", value: "\(pid)")
}
if let exitCode = session.exitCode {
DetailRow(label: "Exit Code", value: "\(exitCode)")
}
}
Spacer()
// Action Buttons
HStack {
Button("Open in Terminal") {
openInTerminal()
}
.controlSize(.large)
Spacer()
if session.isRunning {
Button("Terminate Session") {
terminateSession()
// Action Buttons
HStack {
Button("Open in Terminal") {
openInTerminal()
}
.controlSize(.large)
.foregroundColor(.red)
Spacer()
if session.isRunning {
Button("Terminate Session") {
terminateSession()
}
.controlSize(.large)
.foregroundColor(.red)
}
}
}
.frame(minWidth: 400)
Divider()
// Right side: Window Information and Screenshot
VStack(alignment: .leading, spacing: 20) {
Text("Window Information")
.font(.title2)
.fontWeight(.semibold)
if let windowInfo = windowInfo {
VStack(alignment: .leading, spacing: 12) {
DetailRow(label: "Window ID", value: "\(windowInfo.windowID)")
DetailRow(label: "Terminal App", value: windowInfo.terminalApp.displayName)
DetailRow(label: "Owner PID", value: "\(windowInfo.ownerPID)")
if let bounds = windowInfo.bounds {
DetailRow(label: "Position", value: "X: \(Int(bounds.origin.x)), Y: \(Int(bounds.origin.y))")
DetailRow(label: "Size", value: "\(Int(bounds.width)) × \(Int(bounds.height))")
}
if let title = windowInfo.title {
DetailRow(label: "Window Title", value: title)
}
HStack {
Button("Focus Window") {
focusWindow()
}
.controlSize(.regular)
Button("Capture Screenshot") {
Task {
await captureWindowScreenshot()
}
}
.controlSize(.regular)
.disabled(isCapturingScreenshot)
}
}
// Window Screenshot
if let screenshot = windowScreenshot {
VStack(alignment: .leading, spacing: 8) {
Text("Window Preview")
.font(.headline)
.foregroundColor(.secondary)
Image(nsImage: screenshot)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 400, maxHeight: 300)
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
} else if !hasScreenCapturePermission {
VStack(alignment: .leading, spacing: 12) {
Text("Screen Recording Permission Required")
.font(.headline)
.foregroundColor(.orange)
Text("VibeTunnel needs Screen Recording permission to capture window screenshots.")
.font(.caption)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Open System Settings") {
openScreenRecordingSettings()
}
.controlSize(.small)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
}
} else {
VStack(alignment: .leading, spacing: 12) {
if windowSearchAttempted {
Label("No window found", systemImage: "exclamationmark.triangle")
.foregroundColor(.orange)
.font(.headline)
Text("Could not find a terminal window for this session. The window may have been closed or the session was started outside VibeTunnel.")
.foregroundColor(.secondary)
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("No window information available")
.foregroundColor(.secondary)
}
Button(isFindingWindow ? "Searching..." : "Find Window") {
findWindow()
}
.controlSize(.regular)
.disabled(isFindingWindow)
}
.padding(.vertical, 20)
}
Spacer()
}
.frame(minWidth: 400)
}
.padding(30)
.frame(minWidth: 600, minHeight: 450)
.frame(minWidth: 900, minHeight: 450)
.onAppear {
updateWindowTitle()
findWindow()
}
.background(WindowAccessor(title: $windowTitle))
}
@ -114,6 +233,188 @@ struct SessionDetailView: View {
// TODO: Implement session termination
logger.info("Terminating session \(session.id)")
}
private func findWindow() {
isFindingWindow = true
windowSearchAttempted = true
Task { @MainActor in
defer {
isFindingWindow = false
}
logger.info("Looking for window associated with session \(session.id)")
// First, check if WindowTracker already has window info for this session
if let trackedWindow = WindowTracker.shared.windowInfo(for: session.id) {
logger.info("Found tracked window for session \(session.id): windowID=\(trackedWindow.windowID), terminal=\(trackedWindow.terminalApp.rawValue)")
self.windowInfo = trackedWindow
return
}
logger.info("No tracked window found for session \(session.id), attempting to find it...")
// Get all terminal windows for debugging
let allWindows = WindowEnumerator.getAllTerminalWindows()
logger.info("Found \(allWindows.count) terminal windows currently open")
// Log details about each window for debugging
for (index, window) in allWindows.enumerated() {
logger.debug("Window \(index): terminal=\(window.terminalApp.rawValue), windowID=\(window.windowID), ownerPID=\(window.ownerPID), title=\(window.title ?? "<no title>")")
}
// Log session details for debugging
logger.info("Session details: id=\(session.id), pid=\(session.pid ?? -1), workingDir=\(session.workingDir), attachedViaVT=\(session.attachedViaVT ?? false)")
// Try to match by various criteria
if let pid = session.pid {
logger.info("Looking for window with PID \(pid)...")
if let window = allWindows.first(where: { $0.ownerPID == pid }) {
logger.info("Found window by PID match: windowID=\(window.windowID)")
self.windowInfo = window
// Register this window with WindowTracker for future use
WindowTracker.shared.registerWindow(
for: session.id,
terminalApp: window.terminalApp,
tabReference: nil,
tabID: nil
)
return
} else {
logger.warning("No window found with PID \(pid)")
}
}
// Try to find by window title containing working directory
let workingDirName = URL(fileURLWithPath: session.workingDir).lastPathComponent
logger.info("Looking for window with title containing '\(workingDirName)'...")
if let window = allWindows.first(where: { window in
if let title = window.title {
return title.contains(workingDirName) || title.contains(session.id)
}
return false
}) {
logger.info("Found window by title match: windowID=\(window.windowID), title=\(window.title ?? "")")
self.windowInfo = window
// Register this window with WindowTracker for future use
WindowTracker.shared.registerWindow(
for: session.id,
terminalApp: window.terminalApp,
tabReference: nil,
tabID: nil
)
return
}
logger.warning("Could not find window for session \(session.id) after checking all \(allWindows.count) terminal windows")
logger.warning("Session may not have an associated terminal window or window detection failed")
}
}
private func focusWindow() {
// Use WindowTracker's existing focus logic which handles all the complexity
logger.info("Attempting to focus window for session \(session.id)")
// First ensure we have window info
if windowInfo == nil {
logger.info("No window info cached, trying to find window first...")
findWindow()
}
if let windowInfo = windowInfo {
logger.info("Using WindowTracker to focus window: windowID=\(windowInfo.windowID), terminal=\(windowInfo.terminalApp.rawValue)")
WindowTracker.shared.focusWindow(for: session.id)
} else {
logger.error("Cannot focus window - no window found for session \(session.id)")
}
}
private func captureWindowScreenshot() async {
guard let windowInfo = windowInfo else {
logger.warning("No window info available for screenshot")
return
}
await MainActor.run {
isCapturingScreenshot = true
}
defer {
Task { @MainActor in
isCapturingScreenshot = false
}
}
// Check for screen recording permission
let hasPermission = await checkScreenCapturePermission()
await MainActor.run {
hasScreenCapturePermission = hasPermission
}
guard hasPermission else {
logger.warning("No screen capture permission")
return
}
do {
// Get available content
let availableContent = try await SCShareableContent.current
// Find the window
guard let window = availableContent.windows.first(where: { $0.windowID == windowInfo.windowID }) else {
logger.warning("Window not found in shareable content")
return
}
// Create content filter for this specific window
let filter = SCContentFilter(desktopIndependentWindow: window)
// Configure the capture
let config = SCStreamConfiguration()
config.width = Int(window.frame.width * 2) // Retina resolution
config.height = Int(window.frame.height * 2)
config.scalesToFit = true
config.showsCursor = false
config.captureResolution = .best
// Capture the screenshot
let screenshot = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config
)
// Convert CGImage to NSImage
let nsImage = NSImage(cgImage: screenshot, size: NSSize(width: screenshot.width, height: screenshot.height))
await MainActor.run {
self.windowScreenshot = nsImage
}
logger.info("Successfully captured window screenshot")
} catch {
logger.error("Failed to capture screenshot: \(error)")
}
}
private func checkScreenCapturePermission() async -> Bool {
// Check if we have screen recording permission
let hasPermission = CGPreflightScreenCaptureAccess()
if !hasPermission {
// Request permission
return CGRequestScreenCaptureAccess()
}
return true
}
private func openScreenRecordingSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Supporting Views

View file

@ -22,7 +22,7 @@ struct AdvancedSettingsView: View {
var body: some View {
NavigationStack {
Form {
// Terminal preference section
// Apps preference section
TerminalPreferenceSection()
// Integration section
@ -201,40 +201,22 @@ struct AdvancedSettingsView: View {
private struct TerminalPreferenceSection: View {
@AppStorage("preferredTerminal")
private var preferredTerminal = Terminal.terminal.rawValue
@AppStorage("preferredGitApp")
private var preferredGitApp = ""
@State private var terminalLauncher = TerminalLauncher.shared
@State private var gitAppLauncher = GitAppLauncher.shared
@State private var showingError = false
@State private var errorMessage = ""
@State private var errorTitle = "Terminal Launch Failed"
var body: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 12) {
// Terminal selector row
HStack {
Text("Preferred Terminal")
Spacer()
Picker("", selection: $preferredTerminal) {
ForEach(Terminal.installed, id: \.rawValue) { terminal in
HStack {
if let icon = terminal.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(terminal.displayName)
}
.tag(terminal.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
Text("Select which application to use when creating new sessions")
.font(.caption)
.foregroundStyle(.secondary)
// Test button
HStack {
Text("Test Terminal")
Spacer()
Button("Test Open Terminal") {
Button("Test") {
Task {
do {
try terminalLauncher.launchCommand("echo 'VibeTunnel Terminal Test: Success!'")
@ -297,18 +279,49 @@ private struct TerminalPreferenceSection: View {
}
}
}
.buttonStyle(.bordered)
.buttonStyle(.plain)
.foregroundColor(.secondary)
Picker("", selection: $preferredTerminal) {
ForEach(Terminal.installed, id: \.rawValue) { terminal in
HStack {
if let icon = terminal.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(terminal.displayName)
}
.tag(terminal.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
// Git app selector row
HStack {
Text("Preferred Git App")
Spacer()
Picker("", selection: gitAppBinding) {
ForEach(GitApp.installed, id: \.rawValue) { gitApp in
HStack {
if let icon = gitApp.appIcon {
Image(nsImage: icon.resized(to: NSSize(width: 16, height: 16)))
}
Text(gitApp.displayName)
}
.tag(gitApp.rawValue)
}
}
.pickerStyle(.menu)
.labelsHidden()
}
Text("Opens a new terminal window with a test command")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Terminal")
Text("Apps")
.font(.headline)
} footer: {
Text(
"VibeTunnel will use this terminal when launching new terminal sessions."
"Configure which applications VibeTunnel uses for terminal sessions and Git repositories."
)
.font(.caption)
.frame(maxWidth: .infinity)
@ -329,4 +342,19 @@ private struct TerminalPreferenceSection: View {
Text(errorMessage)
}
}
private var gitAppBinding: Binding<String> {
Binding(
get: {
// If no preference or invalid preference, use first installed app
if preferredGitApp.isEmpty || GitApp(rawValue: preferredGitApp) == nil {
return GitApp.installed.first?.rawValue ?? ""
}
return preferredGitApp
},
set: { newValue in
preferredGitApp = newValue
}
)
}
}

View file

@ -11,13 +11,20 @@ struct SettingsView: View {
@AppStorage("debugMode")
private var debugMode = false
// MARK: - Constants
private enum Layout {
static let defaultTabSize = CGSize(width: 500, height: 620)
static let fallbackTabSize = CGSize(width: 500, height: 400)
}
/// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [
.general: CGSize(width: 500, height: 570),
.dashboard: CGSize(width: 500, height: 570),
.advanced: CGSize(width: 500, height: 570),
.debug: CGSize(width: 500, height: 570),
.about: CGSize(width: 500, height: 570)
.general: Layout.defaultTabSize,
.dashboard: Layout.defaultTabSize,
.advanced: Layout.defaultTabSize,
.debug: Layout.defaultTabSize,
.about: Layout.defaultTabSize
]
var body: some View {
@ -61,10 +68,10 @@ struct SettingsView: View {
}
}
.onChange(of: selectedTab) { _, newTab in
contentSize = tabSizes[newTab] ?? CGSize(width: 500, height: 400)
contentSize = tabSizes[newTab] ?? Layout.fallbackTabSize
}
.onAppear {
contentSize = tabSizes[selectedTab] ?? CGSize(width: 500, height: 400)
contentSize = tabSizes[selectedTab] ?? Layout.fallbackTabSize
}
.onChange(of: debugMode) { _, _ in
// If debug mode is disabled and we're on the debug tab, switch to general

View file

@ -0,0 +1,159 @@
import AppKit
import Foundation
import Observation
import os.log
import SwiftUI
/// Supported Git GUI applications.
enum GitApp: String, CaseIterable {
case cursor = "Cursor"
case fork = "Fork"
case githubDesktop = "GitHub Desktop"
case gitup = "GitUp"
case sourcetree = "SourceTree"
case sublimeMerge = "Sublime Merge"
case tower = "Tower"
case vscode = "Visual Studio Code"
case windsurf = "Windsurf"
var bundleIdentifier: String {
switch self {
case .cursor:
"com.todesktop.230313mzl4w4u92"
case .fork:
"com.DanPristupov.Fork"
case .githubDesktop:
"com.github.GitHubClient"
case .gitup:
"co.gitup.mac"
case .sourcetree:
"com.torusknot.SourceTreeNotMAS"
case .sublimeMerge:
"com.sublimemerge"
case .tower:
"com.fournova.Tower3"
case .vscode:
"com.microsoft.VSCode"
case .windsurf:
"com.codeiumapp.windsurf"
}
}
/// Priority for auto-detection (higher is better, based on popularity)
var detectionPriority: Int {
switch self {
case .cursor: 70
case .fork: 75
case .githubDesktop: 90
case .gitup: 60
case .sourcetree: 80
case .sublimeMerge: 85
case .tower: 100
case .vscode: 95
case .windsurf: 65
}
}
var displayName: String {
rawValue
}
var isInstalled: Bool {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil
}
var appIcon: NSImage? {
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else {
return nil
}
return NSWorkspace.shared.icon(forFile: appURL.path)
}
static var installed: [Self] {
allCases.filter(\.isInstalled)
}
/// Get the actual bundle identifier to use
var actualBundleIdentifier: String? {
isInstalled ? bundleIdentifier : nil
}
}
/// Manages launching Git applications with repository paths.
@MainActor
@Observable
final class GitAppLauncher {
static let shared = GitAppLauncher()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "GitAppLauncher")
private init() {
performFirstRunAutoDetection()
}
func openRepository(at path: String) {
let gitApp = getValidGitApp()
let url = URL(fileURLWithPath: path)
if let bundleId = gitApp.actualBundleIdentifier,
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId)
{
NSWorkspace.shared.open(
[url],
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration()
)
} else {
// Fallback to Finder
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
}
}
func verifyPreferredGitApp() {
let currentPreference = UserDefaults.standard.string(forKey: "preferredGitApp")
if let preference = currentPreference,
let gitApp = GitApp(rawValue: preference),
!gitApp.isInstalled
{
// If the preferred app is no longer installed, clear the preference
UserDefaults.standard.removeObject(forKey: "preferredGitApp")
}
}
// MARK: - Private Methods
private func performFirstRunAutoDetection() {
// Check if git app preference has already been set
let hasSetPreference = UserDefaults.standard.object(forKey: "preferredGitApp") != nil
if !hasSetPreference {
logger.info("First run detected, auto-detecting preferred Git app")
// Check installed git apps
let installedGitApps = GitApp.installed
if let bestGitApp = installedGitApps.max(by: { $0.detectionPriority < $1.detectionPriority }) {
UserDefaults.standard.set(bestGitApp.rawValue, forKey: "preferredGitApp")
logger.info("Auto-detected and set preferred Git app to: \(bestGitApp.rawValue)")
}
}
}
private func getValidGitApp() -> GitApp {
// Read the current preference
if let currentPreference = UserDefaults.standard.string(forKey: "preferredGitApp"),
!currentPreference.isEmpty,
let gitApp = GitApp(rawValue: currentPreference),
gitApp.isInstalled
{
return gitApp
}
// No valid preference, try to find any installed Git app
let installedGitApps = GitApp.installed
if let bestGitApp = installedGitApps.max(by: { $0.detectionPriority < $1.detectionPriority }) {
return bestGitApp
}
// Default to Tower (even if not installed, we'll fall back to Finder)
return .tower
}
}

View file

@ -621,10 +621,10 @@ final class TerminalLauncher {
tell application "Terminal"
activate
set newTab to do script "\(config.appleScriptEscapedCommand)"
-- Set custom title that includes session ID for easier matching
set custom title of newTab to "Session \(sessionId)"
-- newTab is already a tab reference, get its window's ID
set tabWindows to windows whose tabs contains newTab
if (count of tabWindows) > 0 then

View file

@ -1,7 +1,7 @@
// VibeTunnel Version Configuration
// This file contains the version and build number for the app
MARKETING_VERSION = 1.0.0-beta.6
MARKETING_VERSION = 1.0.0-beta.7
CURRENT_PROJECT_VERSION = 160
// Domain and GitHub configuration

View file

@ -1,16 +1,15 @@
import Testing
import Foundation
import Testing
@testable import VibeTunnel
/// Tests to verify that the race condition in GitHub URL fetching is fixed
@MainActor
struct GitRepositoryMonitorRaceConditionTests {
@Test("Concurrent GitHub URL fetches don't cause duplicate Git operations")
func testConcurrentGitHubURLFetches() async throws {
func concurrentGitHubURLFetches() async throws {
let monitor = GitRepositoryMonitor()
let testRepoPath = "/test/repo/path"
// Create a mock repository
let mockRepo = GitRepository(
path: testRepoPath,
@ -20,20 +19,21 @@ struct GitRepositoryMonitorRaceConditionTests {
untrackedCount: 0,
currentBranch: "main"
)
// Use reflection to access private properties for testing
let mirror = Mirror(reflecting: monitor)
// Find the githubURLFetchesInProgress property
var inProgressSet: Set<String>?
for child in mirror.children {
if child.label == "githubURLFetchesInProgress",
let set = child.value as? Set<String> {
let set = child.value as? Set<String>
{
inProgressSet = set
break
}
}
// Simulate multiple concurrent requests for the same repository
let concurrentTasks = (0..<10).map { _ in
Task {
@ -42,53 +42,53 @@ struct GitRepositoryMonitorRaceConditionTests {
_ = await monitor.findRepository(for: testRepoPath)
}
}
// Wait a bit to allow tasks to start
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
// Verify that in-progress tracking is working
// Note: This is a simplified test since we can't easily access private properties
// In a real scenario, we'd need to refactor for better testability
// Wait for all tasks to complete
for task in concurrentTasks {
_ = await task.value
}
// Clear cache to clean up
monitor.clearCache()
}
@Test("GitHub URL fetch completes even on failure")
func testGitHubURLFetchFailureHandling() async throws {
func gitHubURLFetchFailureHandling() async throws {
let monitor = GitRepositoryMonitor()
let invalidRepoPath = "/this/is/not/a/git/repo"
// This should not throw and should handle the failure gracefully
let result = await monitor.findRepository(for: invalidRepoPath)
// Should return nil for invalid repo
#expect(result == nil)
// Clear cache to clean up
monitor.clearCache()
}
@Test("Clear cache removes in-progress fetches")
func testClearCacheRemovesInProgressFetches() async throws {
func clearCacheRemovesInProgressFetches() async throws {
let monitor = GitRepositoryMonitor()
// Start a fetch (simulated through public API)
let testPath = "/test/path"
Task {
_ = await monitor.findRepository(for: testPath)
}
// Clear cache immediately
monitor.clearCache()
// Verify cache is cleared (through public API)
let cached = monitor.getCachedRepository(for: testPath)
#expect(cached == nil)
}
}
}

View file

@ -361,10 +361,18 @@ main() {
trap "rm -rf $temp_dir" EXIT
# Fetch all releases from GitHub with error handling
print_info "Fetching releases from GitHub..."
print_info "Fetching releases from GitHub repository: $GITHUB_REPO_FULL"
local releases
if ! releases=$(gh api "repos/$GITHUB_REPO_FULL/releases" --paginate 2>/dev/null); then
print_error "Failed to fetch releases from GitHub. Please check your GitHub CLI authentication and network connection."
local gh_error
if ! releases=$(gh api "repos/$GITHUB_REPO_FULL/releases" --paginate 2>&1); then
gh_error=$?
print_error "Failed to fetch releases from GitHub (exit code: $gh_error)"
print_error "Repository: $GITHUB_REPO_FULL"
print_error "Error output: $releases"
print_info "Checking GitHub CLI status..."
gh auth status 2>&1 | while IFS= read -r line; do
print_info " $line"
done
exit 1
fi

193
mac/scripts/release-resume.sh Executable file
View file

@ -0,0 +1,193 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Release Resume Script
# =============================================================================
#
# This script resumes a failed release process from where it left off.
# It detects the current state and continues from the appropriate step.
#
# USAGE:
# ./scripts/release-resume.sh <type> [number]
#
# ARGUMENTS:
# type Release type: stable, beta, alpha, rc
# number Pre-release number (required for beta/alpha/rc)
#
# =============================================================================
set -euo pipefail
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Parse arguments
RELEASE_TYPE="${1:-}"
PRERELEASE_NUMBER="${2:-}"
if [[ -z "$RELEASE_TYPE" ]]; then
echo -e "${RED}❌ Error: Release type required (stable, beta, alpha, rc)${NC}"
echo "Usage: $0 <type> [number]"
exit 1
fi
# Load version information
VERSION_CONFIG="$PROJECT_ROOT/VibeTunnel/version.xcconfig"
MARKETING_VERSION=$(grep "MARKETING_VERSION" "$VERSION_CONFIG" | cut -d' ' -f3)
BUILD_NUMBER=$(grep "CURRENT_PROJECT_VERSION" "$VERSION_CONFIG" | cut -d' ' -f3)
echo -e "${BLUE}🔄 VibeTunnel Release Resume${NC}"
echo "============================"
echo "Version: $MARKETING_VERSION"
echo "Build: $BUILD_NUMBER"
echo "Type: $RELEASE_TYPE"
echo ""
# Determine release version and tag
RELEASE_VERSION="$MARKETING_VERSION"
TAG_NAME="v$RELEASE_VERSION"
# Check what's already done
echo -e "${BLUE}🔍 Checking release state...${NC}"
# Check 1: Is the app built and notarized?
APP_PATH="$PROJECT_ROOT/build/Build/Products/Release/VibeTunnel.app"
if [[ -d "$APP_PATH" ]] && xcrun stapler validate "$APP_PATH" 2>&1 | grep -q "The validate action worked"; then
echo "✅ App is built and notarized"
APP_DONE=true
else
echo "❌ App needs to be built/notarized"
APP_DONE=false
fi
# Check 2: Does DMG exist and is it notarized?
DMG_PATH="$PROJECT_ROOT/build/VibeTunnel-$RELEASE_VERSION.dmg"
if [[ -f "$DMG_PATH" ]] && xcrun stapler validate "$DMG_PATH" 2>&1 | grep -q "The validate action worked"; then
echo "✅ DMG exists and is notarized"
DMG_DONE=true
else
echo "❌ DMG needs to be created/notarized"
DMG_DONE=false
fi
# Check 3: Does ZIP exist?
ZIP_PATH="$PROJECT_ROOT/build/VibeTunnel-$RELEASE_VERSION.zip"
if [[ -f "$ZIP_PATH" ]]; then
echo "✅ ZIP exists"
ZIP_DONE=true
else
echo "❌ ZIP needs to be created"
ZIP_DONE=false
fi
# Check 4: Is GitHub release created?
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "✅ GitHub release exists"
GITHUB_DONE=true
else
echo "❌ GitHub release needs to be created"
GITHUB_DONE=false
fi
# Check 5: Is appcast updated?
APPCAST_FILE="$PROJECT_ROOT/../appcast-prerelease.xml"
if [[ "$RELEASE_TYPE" == "stable" ]]; then
APPCAST_FILE="$PROJECT_ROOT/../appcast.xml"
fi
if grep -q "<sparkle:version>$BUILD_NUMBER</sparkle:version>" "$APPCAST_FILE" 2>/dev/null; then
echo "✅ Appcast is updated"
APPCAST_DONE=true
else
echo "❌ Appcast needs to be updated"
APPCAST_DONE=false
fi
echo ""
# Resume from appropriate step
if [[ "$APP_DONE" == "false" ]]; then
echo -e "${RED}❌ App not built/notarized. Please run the full release script.${NC}"
exit 1
fi
if [[ "$DMG_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating and notarizing DMG...${NC}"
"$SCRIPT_DIR/create-dmg.sh" "$APP_PATH" "$DMG_PATH"
"$SCRIPT_DIR/notarize-dmg.sh" "$DMG_PATH"
echo -e "${GREEN}✅ DMG created and notarized${NC}"
fi
if [[ "$ZIP_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating ZIP...${NC}"
"$SCRIPT_DIR/create-zip.sh" "$APP_PATH" "$ZIP_PATH"
echo -e "${GREEN}✅ ZIP created${NC}"
fi
if [[ "$GITHUB_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Creating GitHub release...${NC}"
# Create tag if it doesn't exist
if ! git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
git tag -a "$TAG_NAME" -m "Release $RELEASE_VERSION (build $BUILD_NUMBER)"
git push origin "$TAG_NAME"
fi
# Sign DMG for Sparkle
echo "🔐 Signing DMG for Sparkle..."
DMG_SIGNATURE=$(sign_update "$DMG_PATH" --account VibeTunnel | grep "sparkle:edSignature" | cut -d'"' -f2)
echo " Signature: $DMG_SIGNATURE"
# Create release
if [[ "$RELEASE_TYPE" == "stable" ]]; then
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes-file "$PROJECT_ROOT/CHANGELOG.md" \
"$DMG_PATH" \
"$ZIP_PATH"
else
gh release create "$TAG_NAME" \
--title "VibeTunnel $RELEASE_VERSION" \
--notes-file "$PROJECT_ROOT/CHANGELOG.md" \
--prerelease \
"$DMG_PATH" \
"$ZIP_PATH"
fi
echo -e "${GREEN}✅ GitHub release created${NC}"
fi
if [[ "$APPCAST_DONE" == "false" ]]; then
echo -e "${BLUE}📋 Updating appcast...${NC}"
export SPARKLE_ACCOUNT="VibeTunnel"
"$SCRIPT_DIR/generate-appcast.sh"
# Commit and push appcast
git add "$PROJECT_ROOT/../appcast*.xml" 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "Update appcast for $RELEASE_VERSION"
git push origin main
fi
echo -e "${GREEN}✅ Appcast updated${NC}"
fi
echo ""
echo -e "${GREEN}🎉 Release Resume Complete!${NC}"
echo "========================="
echo ""
echo -e "${GREEN}✅ Successfully completed release of VibeTunnel $RELEASE_VERSION${NC}"
echo ""
echo "Release details:"
echo " - Version: $RELEASE_VERSION"
echo " - Build: $BUILD_NUMBER"
echo " - Tag: $TAG_NAME"
echo " - GitHub: https://github.com/amantus-ai/vibetunnel/releases/tag/$TAG_NAME"

View file

@ -15,6 +15,11 @@
# type Release type: stable, beta, alpha, rc
# number Pre-release number (required for beta/alpha/rc)
#
# IMPORTANT NOTES:
# - This script can take 10-15 minutes due to notarization
# - If running from Claude or other tools with timeouts, use a longer timeout
# - If the script fails partway, use release-resume.sh to continue
#
# FEATURES:
# - Complete build and release automation
# - Automatic IS_PRERELEASE_BUILD flag handling

113
mac/scripts/update-appcast.sh Executable file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# =============================================================================
# VibeTunnel Appcast Update Script
# =============================================================================
#
# This script updates the appcast files after a release has been created.
# It fetches release information from GitHub and regenerates the appcast XML.
#
# USAGE:
# ./scripts/update-appcast.sh
#
# DEPENDENCIES:
# - GitHub CLI (gh) authenticated
# - Sparkle tools (sign_update) in ~/.local/bin
# - generate-appcast.sh script
#
# =============================================================================
set -euo pipefail
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
echo -e "${BLUE}🔄 VibeTunnel Appcast Update${NC}"
echo "============================"
# Check GitHub CLI authentication
if ! gh auth status &>/dev/null; then
echo -e "${RED}❌ Error: GitHub CLI not authenticated${NC}"
echo "Run: gh auth login"
exit 1
fi
# Check for Sparkle tools
if ! command -v sign_update &>/dev/null; then
export PATH="$HOME/.local/bin:$PATH"
if ! command -v sign_update &>/dev/null; then
echo -e "${RED}❌ Error: sign_update not found in PATH${NC}"
echo "Please install Sparkle tools to ~/.local/bin/"
exit 1
fi
fi
# Set Sparkle account
export SPARKLE_ACCOUNT="VibeTunnel"
echo "Using Sparkle account: $SPARKLE_ACCOUNT"
# Run generate-appcast.sh
echo ""
echo -e "${BLUE}📋 Generating appcast files...${NC}"
if "$SCRIPT_DIR/generate-appcast.sh"; then
echo -e "${GREEN}✅ Appcast generation completed${NC}"
else
echo -e "${RED}❌ Appcast generation failed${NC}"
exit 1
fi
# Verify appcast files exist
APPCAST_STABLE="$PROJECT_ROOT/../appcast.xml"
APPCAST_PRERELEASE="$PROJECT_ROOT/../appcast-prerelease.xml"
if [[ ! -f "$APPCAST_STABLE" ]]; then
echo -e "${YELLOW}⚠️ Warning: appcast.xml not found${NC}"
fi
if [[ ! -f "$APPCAST_PRERELEASE" ]]; then
echo -e "${YELLOW}⚠️ Warning: appcast-prerelease.xml not found${NC}"
fi
# Check if there are changes to commit
cd "$PROJECT_ROOT/.."
if git diff --quiet appcast*.xml 2>/dev/null; then
echo ""
echo " No changes to appcast files"
else
echo ""
echo -e "${BLUE}📤 Committing appcast changes...${NC}"
# Show what changed
echo "Changes detected:"
git diff --stat appcast*.xml
# Add and commit
git add appcast*.xml
git commit -m "Update appcast files"
# Push changes
echo "Pushing changes..."
git push origin main
echo -e "${GREEN}✅ Appcast changes committed and pushed${NC}"
fi
# Run verification
echo ""
echo -e "${BLUE}🔍 Verifying appcast files...${NC}"
if "$SCRIPT_DIR/verify-appcast.sh"; then
echo -e "${GREEN}✅ Appcast verification passed${NC}"
else
echo -e "${YELLOW}⚠️ Some appcast issues detected${NC}"
fi
echo ""
echo -e "${GREEN}✅ Appcast update complete!${NC}"

View file

@ -1,6 +1,6 @@
{
"name": "@vibetunnel/vibetunnel-cli",
"version": "1.0.0-beta.6",
"version": "1.0.0-beta.7",
"description": "Web frontend for terminal multiplexer",
"main": "dist/server.js",
"bin": {

View file

@ -824,19 +824,23 @@ export class SessionView extends LitElement {
throw new Error(`Rename failed: ${response.status}`);
}
// Update the local session object
this.session = { ...this.session, name: newName };
// Get the actual name from the server response
const result = await response.json();
const actualName = result.name || newName;
// Dispatch event to notify parent components
// Update the local session object with the server-assigned name
this.session = { ...this.session, name: actualName };
// Dispatch event to notify parent components with the actual name
this.dispatchEvent(
new CustomEvent('session-renamed', {
detail: { sessionId, newName },
detail: { sessionId, newName: actualName },
bubbles: true,
composed: true,
})
);
logger.log(`Session ${sessionId} renamed to: ${newName}`);
logger.log(`Session ${sessionId} renamed to: ${actualName}`);
} catch (error) {
logger.error('Error renaming session', { error, sessionId });

View file

@ -1019,21 +1019,21 @@ export class PtyManager extends EventEmitter {
/**
* Update session name
*/
updateSessionName(sessionId: string, name: string): void {
updateSessionName(sessionId: string, name: string): string {
logger.debug(
`[PtyManager] updateSessionName called for session ${sessionId} with name: ${name}`
);
// Update in session manager (persisted storage)
// Update in session manager (persisted storage) - get the unique name back
logger.debug(`[PtyManager] Calling sessionManager.updateSessionName`);
this.sessionManager.updateSessionName(sessionId, name);
const uniqueName = this.sessionManager.updateSessionName(sessionId, name);
// Update in-memory session if it exists
const memorySession = this.sessions.get(sessionId);
if (memorySession?.sessionInfo) {
logger.debug(`[PtyManager] Found in-memory session, updating...`);
const oldName = memorySession.sessionInfo.name;
memorySession.sessionInfo.name = name;
memorySession.sessionInfo.name = uniqueName;
logger.debug(`[PtyManager] Session info after update:`, {
sessionId: memorySession.id,
@ -1054,7 +1054,9 @@ export class PtyManager extends EventEmitter {
this.updateTerminalTitleForSessionName(memorySession);
}
logger.log(`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${name}"`);
logger.log(
`[PtyManager] Updated session ${sessionId} name from "${oldName}" to "${uniqueName}"`
);
} else {
logger.debug(`[PtyManager] No in-memory session found for ${sessionId}`, {
sessionsMapSize: this.sessions.size,
@ -1063,9 +1065,11 @@ export class PtyManager extends EventEmitter {
}
// Emit event for clients to refresh their session data
this.trackAndEmit('sessionNameChanged', sessionId, name);
this.trackAndEmit('sessionNameChanged', sessionId, uniqueName);
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${name}`);
logger.log(`[PtyManager] Updated session ${sessionId} name to: ${uniqueName}`);
return uniqueName;
}
/**

View file

@ -194,10 +194,36 @@ export class SessionManager {
);
}
/**
* Ensure a session name is unique by adding a suffix if necessary
*/
private ensureUniqueName(desiredName: string, excludeSessionId?: string): string {
const sessions = this.listSessions();
let finalName = desiredName;
let suffix = 2;
// Keep checking until we find a unique name
while (true) {
const nameExists = sessions.some(
(session) => session.name === finalName && session.id !== excludeSessionId
);
if (!nameExists) {
break;
}
// Add or increment suffix
finalName = `${desiredName} (${suffix})`;
suffix++;
}
return finalName;
}
/**
* Update session name
*/
updateSessionName(sessionId: string, name: string): void {
updateSessionName(sessionId: string, name: string): string {
logger.debug(
`[SessionManager] updateSessionName called for session ${sessionId} with name: ${name}`
);
@ -210,13 +236,22 @@ export class SessionManager {
logger.debug(`[SessionManager] Current session info: ${JSON.stringify(sessionInfo)}`);
sessionInfo.name = name;
// Ensure the name is unique
const uniqueName = this.ensureUniqueName(name, sessionId);
if (uniqueName !== name) {
logger.log(`[SessionManager] Name "${name}" already exists, using "${uniqueName}" instead`);
}
sessionInfo.name = uniqueName;
logger.debug(`[SessionManager] Updated session info: ${JSON.stringify(sessionInfo)}`);
logger.debug(`[SessionManager] Calling saveSessionInfo`);
this.saveSessionInfo(sessionId, sessionInfo);
logger.log(`[SessionManager] session ${sessionId} name updated to: ${name}`);
logger.log(`[SessionManager] session ${sessionId} name updated to: ${uniqueName}`);
return uniqueName;
}
/**

View file

@ -1091,10 +1091,10 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Update the session name
logger.debug(`[PATCH] Calling ptyManager.updateSessionName(${sessionId}, ${name})`);
ptyManager.updateSessionName(sessionId, name);
logger.log(chalk.green(`[PATCH] Session ${sessionId} name updated to: ${name}`));
const uniqueName = ptyManager.updateSessionName(sessionId, name);
logger.log(chalk.green(`[PATCH] Session ${sessionId} name updated to: ${uniqueName}`));
res.json({ success: true, name });
res.json({ success: true, name: uniqueName });
} catch (error) {
logger.error('error updating session name:', error);
if (error instanceof PtyError) {