Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions Sources/TBDApp/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import TBDShared
import os

private let logger = Logger(subsystem: "com.tbd.app", category: "AppState")
private let tmuxResolutionLogger = Logger(
subsystem: "com.tbd.app",
category: "tmux"
)
/// Spec C §11.3 — log-only shadow-compare diagnostic. Dedicated category so
/// it can be streamed/filtered independently of the rest of AppState.
private let shadowCompareLogger = Logger(subsystem: "com.tbd.app", category: "panelShadow")
Expand Down Expand Up @@ -41,6 +45,41 @@ struct ControlModePaneKey: Hashable {
let paneID: String
}

enum TmuxStartupResolutionDiagnostic: Equatable {
case path(String)
case savedFallback(String)
case unavailable

init(resolution: TmuxExecutableResolution?) {
switch resolution {
case .some(let resolution):
switch resolution.source {
case .path:
self = .path(resolution.path)
case .savedFallback:
self = .savedFallback(resolution.path)
}
case .none:
self = .unavailable
}
}

func log() {
switch self {
case .path(let path):
tmuxResolutionLogger.notice(
"startup resolution source=PATH path=\(path, privacy: .public)"
)
case .savedFallback(let path):
tmuxResolutionLogger.notice(
"startup resolution source=saved-fallback path=\(path, privacy: .public)"
)
case .unavailable:
tmuxResolutionLogger.error("startup resolution source=unavailable")
}
}
}

@MainActor
final class AppState: ObservableObject {
/// Reference to the global appearance settings, wired by `TBDAppMain`
Expand Down Expand Up @@ -1040,10 +1079,16 @@ final class AppState: ObservableObject {
@Published var alertMessage: String? = nil
@Published var alertIsError: Bool = false

@Published private(set) var tmuxExecutableResolution: TmuxExecutableResolution?
@Published private(set) var savedTmuxExecutablePath: String?
@Published private(set) var isTmuxLocationPromptPresented = false
private var hasCheckedTmuxAvailabilityAtStartup = false

let themeStore = ThemeStore()

let daemonClient = DaemonClient()
let tmuxBridge = TmuxBridge()
let tmuxExecutableResolver: TmuxExecutableResolver
let tmuxBridge: TmuxBridge
/// App-scoped owner of control-mode stream readers (Phase 2 FD vending).
/// Lives here — not on any view — so SwiftUI view destruction cannot tear
/// down an active reader. Keyed by `FDVendHeader.routingKey`.
Expand Down Expand Up @@ -1214,8 +1259,15 @@ final class AppState: ObservableObject {
/// so they never clobber the developer's running app preferences.
let userDefaults: UserDefaults

init(userDefaults: UserDefaults = .standard) {
init(
userDefaults: UserDefaults = .standard,
tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver()
) {
self.userDefaults = userDefaults
self.tmuxExecutableResolver = tmuxExecutableResolver
self.tmuxBridge = TmuxBridge(tmuxExecutableResolver: tmuxExecutableResolver)
self.tmuxExecutableResolution = tmuxExecutableResolver.resolve()
self.savedTmuxExecutablePath = tmuxExecutableResolver.savedPath
restoreLayouts()
restorePaneHistories()
restoreRemoteSessionDisplayNames()
Expand Down Expand Up @@ -1252,6 +1304,34 @@ final class AppState: ObservableObject {
}
}

func refreshTmuxExecutableState() {
savedTmuxExecutablePath = tmuxExecutableResolver.savedPath
tmuxExecutableResolution = tmuxExecutableResolver.resolve()
}

func checkTmuxAvailabilityAtStartup() {
guard !hasCheckedTmuxAvailabilityAtStartup else { return }
hasCheckedTmuxAvailabilityAtStartup = true
refreshTmuxExecutableState()
TmuxStartupResolutionDiagnostic(resolution: tmuxExecutableResolution).log()
isTmuxLocationPromptPresented = tmuxExecutableResolution == nil
}

func dismissTmuxLocationPrompt() {
isTmuxLocationPromptPresented = false
}

func saveTmuxExecutableFallback(_ path: String) throws {
try tmuxExecutableResolver.save(path)
refreshTmuxExecutableState()
isTmuxLocationPromptPresented = false
}

func clearTmuxExecutableFallback() throws {
try tmuxExecutableResolver.clear()
refreshTmuxExecutableState()
}

/// True when this process is a SwiftPM / XCTest test harness. Detected by
/// looking for a `.xctest` bundle path in the process arguments, which
/// both XCTest and Swift Testing (via `swiftpm-testing-helper`) pass.
Expand Down
40 changes: 40 additions & 0 deletions Sources/TBDApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,29 @@ struct ContentView: View {
} message: {
Text(appState.alertMessage ?? "")
}
.alert(
"tmux Not Found",
isPresented: Binding(
get: { appState.isTmuxLocationPromptPresented },
set: { presented in
if !presented {
appState.dismissTmuxLocationPrompt()
}
}
)
) {
Button("Locate tmux…") {
appState.dismissTmuxLocationPrompt()
locateTmuxExecutable()
}
Button("Not Now", role: .cancel) {
appState.dismissTmuxLocationPrompt()
}
} message: {
Text("TBD could not find tmux in PATH and no saved fallback is available. Locate the tmux executable to use TBD terminals.")
}
.onAppear {
appState.checkTmuxAvailabilityAtStartup()
// Keep-alive: seed recentlyVisitedWorktreeIDs with the initially-restored
// selection so the ZStack renders the right SingleWorktreeView on first frame.
if appState.selectedWorktreeIDs.count == 1, let id = appState.selectedWorktreeIDs.first {
Expand All @@ -298,6 +320,24 @@ struct ContentView: View {
}
}

private func locateTmuxExecutable() {
let panel = NSOpenPanel()
panel.title = "Locate tmux"
panel.message = "Choose the tmux executable."
panel.prompt = "Choose"
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }

do {
try appState.saveTmuxExecutableFallback(url.path)
} catch {
appState.alertIsError = true
appState.alertMessage = error.localizedDescription
}
}

private func markSelectedWorktreesAsRead(_ selection: Set<UUID>) {
for worktreeID in selection {
appState.unreadByWorktree[worktreeID] = nil
Expand Down
131 changes: 131 additions & 0 deletions Sources/TBDApp/Settings/TerminalSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ import UniformTypeIdentifiers

private typealias SwiftUIColor = SwiftUI.Color

struct TmuxConfigurationPathPresentation: Equatable {
let fullPath: String
let displayPath: String

init(
configurationURL: URL = TBDConstants.tmuxExecutablePathFile,
homeDirectory: String = NSHomeDirectory()
) {
fullPath = configurationURL.path
let home = homeDirectory.hasSuffix("/")
? String(homeDirectory.dropLast())
: homeDirectory
if !home.isEmpty, fullPath == home {
displayPath = "~"
} else if !home.isEmpty, fullPath.hasPrefix(home + "/") {
displayPath = "~" + fullPath.dropFirst(home.count)
} else {
displayPath = fullPath
}
}
}

struct TerminalSettingsView: View {
@EnvironmentObject var appearance: AppearanceSettings
@EnvironmentObject var appState: AppState
Expand All @@ -20,6 +42,7 @@ struct TerminalSettingsView: View {
@State private var pendingSchemeSwitch: String?
@State private var saveAsError: String?
@State private var showingPendingSwitchConfirm = false
@State private var tmuxFallbackDraft = ""

var body: some View {
Form {
Expand Down Expand Up @@ -133,6 +156,66 @@ struct TerminalSettingsView: View {
.pickerStyle(.menu)
}

Section {
LabeledContent("Active executable") {
if let resolution = appState.tmuxExecutableResolution {
VStack(alignment: .trailing, spacing: 2) {
Text(resolution.path)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
Text(resolution.source == .path ? "From PATH" : "Saved fallback")
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("Not found")
.foregroundStyle(.secondary)
}
}

TextField(
"Fallback executable",
text: $tmuxFallbackDraft,
prompt: Text("/absolute/path/to/tmux")
)
.onSubmit { saveTmuxFallback() }

HStack {
Button("Save") { saveTmuxFallback() }
Button("Choose…") { chooseTmuxFallback() }
Button("Clear") { clearTmuxFallback() }
.disabled(appState.savedTmuxExecutablePath == nil)
}

LabeledContent("Fallback file") {
HStack(spacing: 4) {
let path = TmuxConfigurationPathPresentation()
Text(path.displayPath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)

Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(path.fullPath, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.font(.caption)
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("Copy full path")
}
}
} header: {
Text("tmux")
} footer: {
Text("TBD uses tmux from PATH when available. The saved executable is a fallback for app launches whose PATH does not contain tmux.")
.font(.caption)
.foregroundStyle(.secondary)
}

Section {
Toggle("Auto-resize tmux windows to match the app pane (WIP)", isOn: $enableTerminalAutoResize)
.help("When on, TBD broadcasts the live pane size to the daemon and resizes every tmux window on app resize. Currently unstable — can leave panes smaller than the visible area and clip the bottom rows.")
Expand Down Expand Up @@ -160,6 +243,13 @@ struct TerminalSettingsView: View {
}
.formStyle(.grouped)
.padding()
.onAppear {
appState.refreshTmuxExecutableState()
tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? ""
}
.onChange(of: appState.savedTmuxExecutablePath) { _, savedPath in
tmuxFallbackDraft = savedPath ?? ""
}
.sheet(isPresented: $showingSaveAsDialog, onDismiss: {
pendingSchemeSwitch = nil
saveAsError = nil
Expand Down Expand Up @@ -260,6 +350,47 @@ struct TerminalSettingsView: View {

// MARK: - Actions

private func saveTmuxFallback() {
do {
try appState.saveTmuxExecutableFallback(tmuxFallbackDraft)
tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? ""
} catch {
showTmuxError(error)
}
}

private func chooseTmuxFallback() {
let panel = NSOpenPanel()
panel.title = "Locate tmux"
panel.message = "Choose the tmux executable."
panel.prompt = "Choose"
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url else { return }

do {
try appState.saveTmuxExecutableFallback(url.path)
tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? ""
} catch {
showTmuxError(error)
}
}

private func clearTmuxFallback() {
do {
try appState.clearTmuxExecutableFallback()
tmuxFallbackDraft = ""
} catch {
showTmuxError(error)
}
}

private func showTmuxError(_ error: any Error) {
errorTitle = "Couldn’t save tmux"
importError = error.localizedDescription
}

private func performSave() {
do {
let theme = editorVM.snapshot(id: appearance.schemeID)
Expand Down
16 changes: 4 additions & 12 deletions Sources/TBDApp/Terminal/TerminalPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -528,11 +528,11 @@ struct TerminalPanelRepresentable: NSViewRepresentable {
guard let tmuxBridge,
let processGeneration = beginGroupedViewerAttachmentConfirmation() else { return }
let server = tmuxServer
let sessionName = TmuxBridge.sessionName(for: panelID)
let panelID = panelID
Task { [weak self] in
let attached = await tmuxBridge.hasAttachedClient(
server: server,
sessionName: sessionName
panelID: panelID,
server: server
)
let shouldRetry = self?.groupedViewerAttachmentProbeDidComplete(
clientAttached: attached,
Expand Down Expand Up @@ -709,7 +709,7 @@ struct TerminalPanelRepresentable: NSViewRepresentable {
prepared = value
}

let tmuxPath = findExecutable(prepared.executablePath)
let tmuxPath = prepared.executablePath
let processArgs = prepared.arguments

debugLog("PANEL: Starting: \(tmuxPath) \(processArgs.joined(separator: " "))")
Expand Down Expand Up @@ -1404,13 +1404,5 @@ struct TerminalPanelRepresentable: NSViewRepresentable {
func iTermContent(source: TerminalView, content: ArraySlice<UInt8>) {}
func rangeChanged(source: TerminalView, startY: Int, endY: Int) {}

// MARK: - Helpers

private func findExecutable(_ name: String) -> String {
for path in ["/opt/homebrew/bin/\(name)", "/usr/local/bin/\(name)", "/usr/bin/\(name)"] {
if FileManager.default.isExecutableFile(atPath: path) { return path }
}
return "/usr/bin/env"
}
}
}
Loading
Loading