mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
- Remove all uses of deprecated highlight() method in CustomMenuWindow - Consistently use state property for NSStatusBarButton management - Update StatusBarMenuManager to reset button state when menu state is .none - Fix concurrency issues in CustomMenuWindow frame observer - Ensure button state is properly managed throughout menu lifecycle This fixes the issue where the button could display inconsistent visual states or get stuck due to conflicting approaches between highlight() and state.
445 lines
17 KiB
Swift
445 lines
17 KiB
Swift
import SwiftUI
|
|
|
|
/// Compact new session form designed for the popover
|
|
struct NewSessionForm: View {
|
|
@Binding var isPresented: Bool
|
|
@Environment(ServerManager.self) private var serverManager
|
|
@Environment(SessionMonitor.self) private var sessionMonitor
|
|
@Environment(SessionService.self) private var sessionService
|
|
|
|
// Form fields
|
|
@State private var command = "zsh"
|
|
@State private var sessionName = ""
|
|
@State private var workingDirectory = "~/"
|
|
@State private var spawnWindow = true
|
|
@State private var titleMode: TitleMode = .dynamic
|
|
|
|
// UI state
|
|
@State private var isCreating = false
|
|
@State private var showError = false
|
|
@State private var errorMessage = ""
|
|
@FocusState private var focusedField: Field?
|
|
|
|
enum Field: Hashable {
|
|
case command
|
|
case name
|
|
case directory
|
|
}
|
|
|
|
enum TitleMode: String, CaseIterable {
|
|
case none = "none"
|
|
case filter = "filter"
|
|
case `static` = "static"
|
|
case dynamic = "dynamic"
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .none: "None"
|
|
case .filter: "Filter"
|
|
case .static: "Static"
|
|
case .dynamic: "Dynamic"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Quick commands synced with frontend
|
|
private let quickCommands = [
|
|
("claude", "✨"),
|
|
("gemini", "✨"),
|
|
("zsh", nil),
|
|
("python3", nil),
|
|
("node", nil),
|
|
("pnpm run dev", nil)
|
|
]
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header with back button
|
|
HStack {
|
|
Button(action: {
|
|
isPresented = false
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "chevron.left")
|
|
.font(.system(size: 11, weight: .medium))
|
|
Text("Sessions")
|
|
.font(.system(size: 12, weight: .medium))
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
|
|
Spacer()
|
|
|
|
Text("New Session")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
|
Spacer()
|
|
|
|
// Balance the back button
|
|
Color.clear
|
|
.frame(width: 60)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
Color(NSColor.controlBackgroundColor).opacity(0.6),
|
|
Color(NSColor.controlBackgroundColor).opacity(0.3)
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
|
|
Divider()
|
|
|
|
// Form content
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
// Name field (first)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Name")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("(optional)", text: $sessionName)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focusedField, equals: .name)
|
|
}
|
|
|
|
// Command field (second)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Command")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
TextField("claude", text: $command)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focusedField, equals: .command)
|
|
.onChange(of: command) { _, newValue in
|
|
// Auto-select dynamic title mode for AI tools
|
|
if newValue.lowercased().contains("claude") ||
|
|
newValue.lowercased().contains("gemini")
|
|
{
|
|
titleMode = .dynamic
|
|
}
|
|
}
|
|
}
|
|
|
|
// Working Directory (third)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Working Directory")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
HStack(spacing: 8) {
|
|
TextField("~/", text: $workingDirectory)
|
|
.textFieldStyle(.roundedBorder)
|
|
.focused($focusedField, equals: .directory)
|
|
|
|
Button(action: selectDirectory) {
|
|
Image(systemName: "folder")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help("Choose directory")
|
|
}
|
|
}
|
|
|
|
// Quick Start
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Quick Start")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
LazyVGrid(columns: [
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible()),
|
|
GridItem(.flexible())
|
|
], spacing: 8) {
|
|
ForEach(quickCommands, id: \.0) { cmd in
|
|
Button(action: {
|
|
command = cmd.0
|
|
sessionName = ""
|
|
}) {
|
|
HStack(spacing: 4) {
|
|
if let emoji = cmd.1 {
|
|
Text(emoji)
|
|
.font(.system(size: 12))
|
|
}
|
|
Text(cmd.0)
|
|
.font(.system(size: 11))
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.primary.opacity(0.05))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
.padding(.vertical, 4)
|
|
|
|
// Options
|
|
VStack(spacing: 16) {
|
|
// Title Mode with combo box - right aligned
|
|
HStack {
|
|
Text("Title Mode")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
Menu {
|
|
ForEach(TitleMode.allCases, id: \.self) { mode in
|
|
Button(action: { titleMode = mode }) {
|
|
HStack {
|
|
Text(mode.displayName)
|
|
if mode == titleMode {
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(titleMode.displayName)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.primary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 8, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.primary.opacity(0.05))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
|
)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.menuIndicator(.hidden)
|
|
.fixedSize()
|
|
}
|
|
|
|
// Open in Terminal
|
|
HStack {
|
|
Text("Terminal")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Open in native terminal window")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary.opacity(0.8))
|
|
|
|
Spacer()
|
|
|
|
Toggle("", isOn: $spawnWindow)
|
|
.toggleStyle(.switch)
|
|
.scaleEffect(0.8)
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 16)
|
|
}
|
|
.frame(maxHeight: 400)
|
|
|
|
Divider()
|
|
|
|
// Create button with improved styling
|
|
HStack {
|
|
Spacer()
|
|
|
|
Button(action: createSession) {
|
|
if isCreating {
|
|
HStack(spacing: 4) {
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
.controlSize(.small)
|
|
Text("Creating...")
|
|
}
|
|
.frame(minWidth: 80)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
} else {
|
|
Text("Create")
|
|
.font(.system(size: 13, weight: .medium))
|
|
.frame(minWidth: 80)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(.white)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(command.isEmpty || workingDirectory.isEmpty ? Color.gray.opacity(0.4) : Color(
|
|
red: 0.2,
|
|
green: 0.6,
|
|
blue: 0.3
|
|
))
|
|
)
|
|
.disabled(isCreating || command.isEmpty || workingDirectory.isEmpty)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.frame(width: 384)
|
|
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
.onAppear {
|
|
loadPreferences()
|
|
focusedField = .name
|
|
}
|
|
.alert("Error", isPresented: $showError) {
|
|
Button("OK") {}
|
|
} message: {
|
|
Text(errorMessage)
|
|
}
|
|
.compositingGroup() // Render the entire form as a single composited layer
|
|
}
|
|
|
|
private func selectDirectory() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = false
|
|
panel.canChooseDirectories = true
|
|
panel.allowsMultipleSelection = false
|
|
panel.directoryURL = URL(fileURLWithPath: NSString(string: workingDirectory).expandingTildeInPath)
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
let path = url.path
|
|
let homeDir = NSHomeDirectory()
|
|
if path.hasPrefix(homeDir) {
|
|
workingDirectory = "~" + path.dropFirst(homeDir.count)
|
|
} else {
|
|
workingDirectory = path
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createSession() {
|
|
guard !command.isEmpty && !workingDirectory.isEmpty else { return }
|
|
|
|
isCreating = true
|
|
savePreferences()
|
|
|
|
Task {
|
|
do {
|
|
// Parse command into array
|
|
let commandArray = parseCommand(command.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
|
// Expand tilde in working directory
|
|
let expandedWorkingDir = NSString(string: workingDirectory).expandingTildeInPath
|
|
|
|
// Create session using SessionService
|
|
let sessionId = try await sessionService.createSession(
|
|
command: commandArray,
|
|
workingDir: expandedWorkingDir,
|
|
name: sessionName.isEmpty ? nil : sessionName.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
titleMode: titleMode.rawValue,
|
|
spawnTerminal: spawnWindow
|
|
)
|
|
|
|
// If not spawning window, open in browser
|
|
if !spawnWindow {
|
|
if let webURL = URL(string: "http://127.0.0.1:\(serverManager.port)/?sessionId=\(sessionId)") {
|
|
NSWorkspace.shared.open(webURL)
|
|
}
|
|
}
|
|
|
|
await MainActor.run {
|
|
isPresented = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isCreating = false
|
|
errorMessage = error.localizedDescription
|
|
showError = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func parseCommand(_ cmd: String) -> [String] {
|
|
// Simple command parsing that respects quotes
|
|
var result: [String] = []
|
|
var current = ""
|
|
var inQuotes = false
|
|
var quoteChar: Character?
|
|
|
|
for char in cmd {
|
|
if !inQuotes && (char == "\"" || char == "'") {
|
|
inQuotes = true
|
|
quoteChar = char
|
|
} else if inQuotes && char == quoteChar {
|
|
inQuotes = false
|
|
quoteChar = nil
|
|
} else if !inQuotes && char == " " {
|
|
if !current.isEmpty {
|
|
result.append(current)
|
|
current = ""
|
|
}
|
|
} else {
|
|
current.append(char)
|
|
}
|
|
}
|
|
|
|
if !current.isEmpty {
|
|
result.append(current)
|
|
}
|
|
|
|
return result.isEmpty ? ["zsh"] : result
|
|
}
|
|
|
|
// MARK: - Preferences
|
|
|
|
private func loadPreferences() {
|
|
if let savedCommand = UserDefaults.standard.string(forKey: "NewSession.command") {
|
|
command = savedCommand
|
|
}
|
|
if let savedDir = UserDefaults.standard.string(forKey: "NewSession.workingDirectory") {
|
|
workingDirectory = savedDir
|
|
}
|
|
|
|
// Check if spawn window preference has been explicitly set
|
|
if UserDefaults.standard.object(forKey: "NewSession.spawnWindow") != nil {
|
|
spawnWindow = UserDefaults.standard.bool(forKey: "NewSession.spawnWindow")
|
|
} else {
|
|
// Default to true if never set
|
|
spawnWindow = true
|
|
}
|
|
|
|
if let savedMode = UserDefaults.standard.string(forKey: "NewSession.titleMode"),
|
|
let mode = TitleMode(rawValue: savedMode)
|
|
{
|
|
titleMode = mode
|
|
}
|
|
}
|
|
|
|
private func savePreferences() {
|
|
UserDefaults.standard.set(command, forKey: "NewSession.command")
|
|
UserDefaults.standard.set(workingDirectory, forKey: "NewSession.workingDirectory")
|
|
UserDefaults.standard.set(spawnWindow, forKey: "NewSession.spawnWindow")
|
|
UserDefaults.standard.set(titleMode.rawValue, forKey: "NewSession.titleMode")
|
|
}
|
|
}
|