mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +00:00
Improve release scripts and add git app integration (#208)
This commit is contained in:
parent
74a364d1ba
commit
45d8f97a30
28 changed files with 1338 additions and 302 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ struct VibeTunnelMenuView: View {
|
|||
}
|
||||
)
|
||||
}
|
||||
.frame(maxHeight: 400)
|
||||
.frame(maxHeight: 600)
|
||||
|
||||
Divider()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
159
mac/VibeTunnel/Utilities/GitAppLauncher.swift
Normal file
159
mac/VibeTunnel/Utilities/GitAppLauncher.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
193
mac/scripts/release-resume.sh
Executable 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"
|
||||
|
|
@ -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
113
mac/scripts/update-appcast.sh
Executable 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}"
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue