diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b9037f77d21..f05791ad4c7 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -11,6 +11,12 @@ A5FF0007 /* SettingDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0017 /* SettingDefinition.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; A5FF0005 /* ContentView+CommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FF0015 /* ContentView+CommandPalette.swift */; }; + NRCV00000000000000000005 /* TabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000006 /* TabItemView.swift */; }; + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000004 /* FileDropOverlayView.swift */; }; + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */; }; + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000008 /* SidebarDragDrop.swift */; }; + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000010 /* SidebarVisuals.swift */; }; + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = NRCV00000000000000000012 /* CommandPaletteController.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; @@ -243,6 +249,12 @@ A5FF0017 /* SettingDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingDefinition.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A5FF0015 /* ContentView+CommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+CommandPalette.swift"; sourceTree = ""; }; + NRCV00000000000000000006 /* TabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItemView.swift; sourceTree = ""; }; + NRCV00000000000000000004 /* FileDropOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropOverlayView.swift; sourceTree = ""; }; + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngine.swift; sourceTree = ""; }; + NRCV00000000000000000008 /* SidebarDragDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarDragDrop.swift; sourceTree = ""; }; + NRCV00000000000000000010 /* SidebarVisuals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarVisuals.swift; sourceTree = ""; }; + NRCV00000000000000000012 /* CommandPaletteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteController.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; @@ -520,6 +532,12 @@ A5FF0017 /* SettingDefinition.swift */, A5001012 /* ContentView.swift */, A5FF0015 /* ContentView+CommandPalette.swift */, + NRCV00000000000000000006 /* TabItemView.swift */, + NRCV00000000000000000004 /* FileDropOverlayView.swift */, + NRCV00000000000000000002 /* CommandPaletteSearchEngine.swift */, + NRCV00000000000000000008 /* SidebarDragDrop.swift */, + NRCV00000000000000000010 /* SidebarVisuals.swift */, + NRCV00000000000000000012 /* CommandPaletteController.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -858,6 +876,12 @@ A5FF0007 /* SettingDefinition.swift in Sources */, A5001002 /* ContentView.swift in Sources */, A5FF0005 /* ContentView+CommandPalette.swift in Sources */, + NRCV00000000000000000005 /* TabItemView.swift in Sources */, + NRCV00000000000000000003 /* FileDropOverlayView.swift in Sources */, + NRCV00000000000000000001 /* CommandPaletteSearchEngine.swift in Sources */, + NRCV00000000000000000007 /* SidebarDragDrop.swift in Sources */, + NRCV00000000000000000009 /* SidebarVisuals.swift in Sources */, + NRCV00000000000000000011 /* CommandPaletteController.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/Sources/CommandPaletteController.swift b/Sources/CommandPaletteController.swift new file mode 100644 index 00000000000..c12ebba8d0f --- /dev/null +++ b/Sources/CommandPaletteController.swift @@ -0,0 +1,73 @@ +// Command-palette state ownership, extracted from ContentView.swift (nuclear-review #88). +// +// CommandPaletteController owns every @State property that used to live on +// ContentView and is exclusively used by the command palette (query, mode, +// search corpus/results, rename/workspace-description drafts, focus-restore +// targets, usage history, etc.). ContentView holds a single +// `@StateObject private var commandPaletteController` and exposes each +// property back to its existing (unqualified) call sites in its body via +// thin computed proxies — this keeps the ~4000 lines of palette orchestration +// code that reads/writes these properties unchanged while genuinely moving +// storage ownership onto the controller (no more @State duplicated per-view). +// +// The two @FocusState properties (isCommandPaletteSearchFocused, +// isCommandPaletteRenameFocused) stay on ContentView: @FocusState is a +// SwiftUI View-only property wrapper and cannot be hosted on an +// ObservableObject. +// +// Access-level widening: CommandPaletteMode, CommandPaletteRestoreFocusTarget, +// CommandPaletteTextSelectionBehavior, and CommandPaletteMultilineTextEditorRepresentable +// were `private` nested types inside ContentView; widened to internal (dropped +// the `private` modifier) so this controller (a different file) can reference +// them. No other behavior change. CommandPaletteCommand, CommandPaletteSearchResult, +// CommandPaletteListScope, CommandPalettePendingActivation, and +// CommandPaletteUsageEntry were already internal nested types; referenced here +// via `ContentView.` qualification since nested-type name lookup requires it +// from outside the enclosing type's lexical scope. + +import AppKit +import Combine +import SwiftUI + +final class CommandPaletteController: ObservableObject { + @Published var isCommandPalettePresented = false + @Published var commandPaletteQuery: String = "" + @Published var commandPaletteMode: ContentView.CommandPaletteMode = .commands + @Published var commandPaletteRenameDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionDraft: String = "" + @Published var commandPaletteWorkspaceDescriptionHeight: CGFloat = ContentView.CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight + @Published var commandPaletteSelectedResultIndex: Int = 0 + @Published var commandPaletteSelectionAnchorCommandID: String? + @Published var commandPaletteHoveredResultIndex: Int? + @Published var commandPaletteScrollTargetIndex: Int? + @Published var commandPaletteScrollTargetAnchor: UnitPoint? + @Published var commandPaletteRestoreFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] + @Published var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] + @Published var commandPaletteSearchCommandsByID: [String: ContentView.CommandPaletteCommand] = [:] + @Published var cachedCommandPaletteResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResults: [ContentView.CommandPaletteSearchResult] = [] + @Published var commandPaletteVisibleResultsScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteVisibleResultsFingerprint: Int? + @Published var cachedCommandPaletteScope: ContentView.CommandPaletteListScope? + @Published var cachedCommandPaletteFingerprint: Int? + @Published var commandPalettePendingDismissFocusTarget: ContentView.CommandPaletteRestoreFocusTarget? + @Published var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @Published var commandPalettePendingTextSelectionBehavior: ContentView.CommandPaletteTextSelectionBehavior? + @Published var commandPaletteSearchTask: Task? + @Published var commandPaletteSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchRequestID: UInt64 = 0 + @Published var commandPaletteResolvedSearchScope: ContentView.CommandPaletteListScope? + @Published var commandPaletteResolvedSearchFingerprint: Int? + @Published var commandPaletteResolvedMatchingQuery = "" + @Published var commandPaletteTerminalOpenTargetAvailability: Set = [] + @Published var isCommandPaletteSearchPending = false + @Published var commandPalettePendingActivation: ContentView.CommandPalettePendingActivation? + @Published var commandPaletteResultsRevision: UInt64 = 0 + @Published var commandPaletteUsageHistoryByCommandId: [String: ContentView.CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) + var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces + @Published var commandPaletteShouldFocusWorkspaceDescriptionEditor = false +} diff --git a/Sources/CommandPaletteSearchEngine.swift b/Sources/CommandPaletteSearchEngine.swift new file mode 100644 index 00000000000..de3444a9fab --- /dev/null +++ b/Sources/CommandPaletteSearchEngine.swift @@ -0,0 +1,993 @@ +// Command palette fuzzy-search engine, extracted from ContentView.swift (nuclear-review #94.1). +// Pure move: search metadata, indexer, fuzzy matcher, corpus types, and the search engine. +// Zero coupling to ContentView state — behavior-identical relocation. + +import Foundation + +struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { + let directories: [String] + let branches: [String] + let ports: [Int] + let description: String? + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [], + description: String? = nil + ) { + self.directories = directories + self.branches = branches + self.ports = ports + self.description = description + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + let descriptionTokens = descriptionTokensForSearch(metadata.description) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + if !descriptionTokens.isEmpty { + contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { + let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return [] } + let normalizedWhitespace = trimmed.replacingOccurrences( + of: "\\s+", + with: " ", + options: .regularExpression + ) + let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 10 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + struct PreparedQuery { + let normalizedText: String + let tokens: [String] + + var isEmpty: Bool { + tokens.isEmpty + } + } + + static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + return PreparedQuery( + normalizedText: normalizedQuery, + tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + ) + } + + static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery(query), + normalizedCandidates: candidates + .map(normalizeForSearch) + .filter { !$0.isEmpty } + ) + } + + static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = normalizeForSearch(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in preparedQuery.tokens { + if token == loweredCandidate { + matched.formUnion(0.. Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidateChars: candidateChars + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in wordSegments(candidateChars) { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: (start: Int, end: Int) + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + +struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let normalizedTitle: String + let normalizedSearchableTexts: [String] + + init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { + self.payload = payload + self.rank = rank + self.title = title + self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) + self.normalizedSearchableTexts = searchableTexts + .map(CommandPaletteFuzzyMatcher.normalizeForSearch) + .filter { !$0.isEmpty } + } +} + +struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { + let payload: Payload + let rank: Int + let title: String + let score: Int + let titleMatchIndices: Set +} + +enum CommandPaletteSearchEngine { + private static let titleMatchBonus = 2000 + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: nil + ) + } + + static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: @escaping () -> Bool + ) -> [CommandPaletteSearchCorpusResult] { + search( + entries: entries, + query: query, + historyBoost: historyBoost, + shouldCancel: Optional(shouldCancel) + ) + } + + private static func search( + entries: [CommandPaletteSearchCorpusEntry], + query: String, + historyBoost: (Payload, Bool) -> Int, + shouldCancel: (() -> Bool)? + ) -> [CommandPaletteSearchCorpusResult] { + let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) + let queryIsEmpty = preparedQuery.isEmpty + var results: [CommandPaletteSearchCorpusResult] = [] + results.reserveCapacity(entries.count) + + func shouldCancelSearch(at index: Int) -> Bool { + guard let shouldCancel else { return false } + return index % 16 == 0 && shouldCancel() + } + + if queryIsEmpty { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: historyBoost(entry.payload, true), + titleMatchIndices: [] + ) + ) + } + } else { + for (index, entry) in entries.enumerated() { + if shouldCancelSearch(at: index) { return [] } + guard let fuzzyScore = weightedScore( + preparedQuery: preparedQuery, + entry: entry + ) else { + continue + } + results.append( + CommandPaletteSearchCorpusResult( + payload: entry.payload, + rank: entry.rank, + title: entry.title, + score: fuzzyScore + historyBoost(entry.payload, false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + preparedQuery: preparedQuery, + candidate: entry.title + ) + ) + ) + } + } + + if shouldCancel?() == true { return [] } + + return results.sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + + private static func weightedScore( + preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, + entry: CommandPaletteSearchCorpusEntry + ) -> Int? { + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: entry.normalizedSearchableTexts + ) else { + return nil + } + guard !entry.normalizedTitle.isEmpty, + let titleScore = CommandPaletteFuzzyMatcher.score( + preparedQuery: preparedQuery, + normalizedCandidates: [entry.normalizedTitle] + ) else { + return fuzzyScore + } + return max(fuzzyScore, titleScore + titleMatchBonus) + } +} + diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e17214dc0e9..cfc56e5321f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7,7 +7,7 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit -private extension Color { +extension Color { init?(hex: String) { let hex = hex.trimmingCharacters(in: .init(charactersIn: "#")) guard hex.count == 6, let value = UInt64(hex, radix: 16) else { return nil } @@ -19,7 +19,7 @@ private extension Color { } } -private func coloredCircleImage(color: NSColor) -> NSImage { +func coloredCircleImage(color: NSColor) -> NSImage { let size = NSSize(width: 14, height: 14) let image = NSImage(size: size, flipped: false) { rect in color.setFill() @@ -370,589 +370,6 @@ enum SidebarResizeInteraction { } } -// MARK: - File Drop Overlay - -enum DragOverlayRoutingPolicy { - static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") - static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(bonsplitTabTransferType) - } - - static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(sidebarTabReorderType) - } - - static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { - guard let pasteboardTypes else { return false } - return pasteboardTypes.contains(.fileURL) - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - hasLocalDraggingSource: Bool - ) -> Bool { - // Local file drags (e.g. in-app draggable folder views) are valid drop - // inputs; rely on explicit non-file drag types below to avoid hijacking - // Bonsplit/sidebar drags. - _ = hasLocalDraggingSource - guard hasFileURL(pasteboardTypes) else { return false } - - // Prefer explicit non-file drag types so stale fileURL entries cannot hijack - // Bonsplit tab drags or sidebar tab reorder drags. - if hasBonsplitTabTransfer(pasteboardTypes) { return false } - if hasSidebarTabReorder(pasteboardTypes) { return false } - return true - } - - static func shouldCaptureFileDropDestination( - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureFileDropDestination( - pasteboardTypes: pasteboardTypes, - hasLocalDraggingSource: false - ) - } - - static func shouldCaptureFileDropOverlay( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } - guard isDragMouseEvent(eventType) else { return false } - return true - } - - static func shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: Bool, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard hasSidebarDragState else { return false } - return hasSidebarTabReorder(pasteboardTypes) - } - - static func shouldCaptureSidebarExternalOverlay( - draggedTabId: UUID?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - shouldCaptureSidebarExternalOverlay( - hasSidebarDragState: draggedTabId != nil, - pasteboardTypes: pasteboardTypes - ) - } - - static func shouldPassThroughPortalHitTesting( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType? - ) -> Bool { - guard isPortalDragEvent(eventType) else { return false } - return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) - } - - private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { - eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - } - - private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { - // Restrict portal pass-through to explicit drag-motion events so stale - // NSPasteboard(name: .drag) types cannot hijack normal pointer input. - guard let eventType else { return false } - switch eventType { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - return true - default: - return false - } - } -} - -/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to -/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's -/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply -/// embedded terminal views. This overlay sits above the entire content view hierarchy and -/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. -/// -/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, -/// scrolls, and other interactions pass through normally. -final class FileDropOverlayView: NSView { - /// Fallback handler when no terminal is found under the drop point. - var onDrop: (([URL]) -> Bool)? - private var isForwardingMouseEvent = false - private weak var forwardedMouseDragTarget: NSView? - private var forwardedMouseDragButton: ForwardedMouseDragButton? - /// The WKWebView currently receiving forwarded drag events, so we can - /// synthesize draggingExited/draggingEntered as the cursor moves. - private weak var activeDragWebView: WKWebView? - /// The WKWebView that accepted prepareForDragOperation so conclude can be - /// delivered to the same browser target after the drop completes. - private weak var preparedDragWebView: WKWebView? - private var lastHitTestLogSignature: String? - private var lastDragRouteLogSignatureByPhase: [String: String] = [:] - - override var acceptsFirstResponder: Bool { false } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - registerForDraggedTypes([.fileURL]) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } - - private enum ForwardedMouseDragButton: Equatable { - case left - case right - case other(Int) - } - - private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { - switch event.type { - case .leftMouseDown, .leftMouseUp, .leftMouseDragged: - return .left - case .rightMouseDown, .rightMouseUp, .rightMouseDragged: - return .right - case .otherMouseDown, .otherMouseUp, .otherMouseDragged: - return .other(Int(event.buttonNumber)) - default: - return nil - } - } - - private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - return true - default: - return false - } - } - - private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { - switch eventType { - case .leftMouseUp, .rightMouseUp, .otherMouseUp: - return true - default: - return false - } - } - - // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so - // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. - - override func hitTest(_ point: NSPoint) -> NSView? { - let pb = NSPasteboard(name: .drag) - let eventType = NSApp.currentEvent?.type - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( - pasteboardTypes: pb.types, - eventType: eventType - ) -#if DEBUG - logHitTestDecision( - pasteboardTypes: pb.types, - eventType: eventType, - shouldCapture: shouldCapture - ) -#endif - guard shouldCapture else { return nil } - - return super.hitTest(point) - } - - // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard - // data causes hitTest to return self when no drag is actually active. - // We hit-test contentView directly and dispatch to the target rather than using - // window.sendEvent(), which caches the mouse target and causes infinite recursion. - - private func forwardEvent(_ event: NSEvent) { - guard !isForwardingMouseEvent else { return } - guard let window, let contentView = window.contentView else { return } - let eventButton = dragButton(for: event) - - isForwardingMouseEvent = true - isHidden = true - defer { - isHidden = false - isForwardingMouseEvent = false - } - - let target: NSView? - if let eventButton, - forwardedMouseDragButton == eventButton, - let activeTarget = forwardedMouseDragTarget, - activeTarget.window != nil { - // Preserve normal AppKit mouse-delivery semantics: once a drag starts, - // keep routing dragged/up events to the original mouseDown target. - target = activeTarget - } else { - let point = contentView.convert(event.locationInWindow, from: nil) - target = contentView.hitTest(point) - } - - guard let target, target !== self else { - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - return - } - - if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { - forwardedMouseDragTarget = target - forwardedMouseDragButton = eventButton - } - - switch event.type { - case .leftMouseDown: target.mouseDown(with: event) - case .leftMouseUp: target.mouseUp(with: event) - case .leftMouseDragged: target.mouseDragged(with: event) - case .rightMouseDown: target.rightMouseDown(with: event) - case .rightMouseUp: target.rightMouseUp(with: event) - case .rightMouseDragged: target.rightMouseDragged(with: event) - case .otherMouseDown: target.otherMouseDown(with: event) - case .otherMouseUp: target.otherMouseUp(with: event) - case .otherMouseDragged: target.otherMouseDragged(with: event) - case .scrollWheel: target.scrollWheel(with: event) - default: break - } - - if shouldTrackForwardedMouseDragEnd(for: event.type), - let eventButton, - forwardedMouseDragButton == eventButton { - forwardedMouseDragTarget = nil - forwardedMouseDragButton = nil - } - } - - override func mouseDown(with event: NSEvent) { forwardEvent(event) } - override func mouseUp(with event: NSEvent) { forwardEvent(event) } - override func mouseDragged(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } - override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } - override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } - override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } - override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } - override func scrollWheel(with event: NSEvent) { forwardEvent(event) } - - // MARK: NSDraggingDestination – accept file drops over terminal and browser views. - // - // AppKit sends draggingEntered once when the drag enters this overlay, then - // draggingUpdated as the cursor moves within it. We track which WKWebView (if - // any) is under the cursor and synthesize enter/exit calls so the browser's - // HTML5 drag events (dragenter, dragleave, drop) fire correctly. - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "entered") - } - - override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - return updateDragTarget(sender, phase: "updated") - } - - override func draggingExited(_ sender: (any NSDraggingInfo)?) { - preparedDragWebView = nil - if let prev = activeDragWebView { - prev.draggingExited(sender) - activeDragWebView = nil - } - } - - override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "prepare", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.prepareForDragOperation(sender) - } - preparedDragWebView = nil - return hasTerminalTarget - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture - ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) - : nil - let terminal = terminalUnderPoint(sender.draggingLocation) - let hasTerminalTarget = terminal != nil -#if DEBUG - logDragRouteDecision( - phase: "perform", - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture else { - preparedDragWebView = nil - activeDragWebView = nil - return false - } - if let webView { - preparedDragWebView = webView - return webView.performDragOperation(sender) - } - preparedDragWebView = nil - activeDragWebView = nil - guard let terminal else { return false } - return terminal.performDragOperation(sender) - } - - override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { - defer { - preparedDragWebView = nil - activeDragWebView = nil - } - guard let sender else { return } - let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) - webView?.concludeDragOperation(sender) - } - - private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { - let loc = sender.draggingLocation - let hasLocalDraggingSource = sender.draggingSource != nil - let types = sender.draggingPasteboard.types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( - pasteboardTypes: types, - hasLocalDraggingSource: hasLocalDraggingSource - ) - let webView = shouldCapture ? webViewUnderPoint(loc) : nil - - if let prev = activeDragWebView, prev !== webView { - prev.draggingExited(sender) - activeDragWebView = nil - } - - if let webView { - if activeDragWebView !== webView { - activeDragWebView = webView - return webView.draggingEntered(sender) - } - return webView.draggingUpdated(sender) - } - - let hasTerminalTarget = terminalUnderPoint(loc) != nil -#if DEBUG - logDragRouteDecision( - phase: phase, - pasteboardTypes: types, - shouldCapture: shouldCapture, - hasLocalDraggingSource: hasLocalDraggingSource, - hasTerminalTarget: hasTerminalTarget - ) -#endif - guard shouldCapture, hasTerminalTarget else { return [] } - return .copy - } - - private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { - guard let types, !types.isEmpty else { return "-" } - return types.map(\.rawValue).joined(separator: ",") - } - - /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. - func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { - if let window, - let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { - return portalWebView - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let webView = view as? WKWebView { return webView } - current = view.superview - } - return nil - } - - private func debugTopHitViewForCurrentEvent() -> String { - guard let window, - let currentEvent = NSApp.currentEvent, - let contentView = window.contentView, - let themeFrame = contentView.superview else { return "-" } - - let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) - // Don't toggle isHidden here — it triggers setNeedsDisplay which can - // exceed AppKit's display-pass limit during cursor-update display cycles. - guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } - var chain: [String] = [] - var current: NSView? = hit - var depth = 0 - while let view = current, depth < 6 { - chain.append(debugHitViewDescriptor(view)) - current = view.superview - depth += 1 - } - return chain.joined(separator: "->") - } - - private func debugHitViewDescriptor(_ view: NSView) -> String { - let className = String(describing: type(of: view)) - let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) - let dragTypes = debugRegisteredDragTypes(view) - return "\(className)@\(ptr){dragTypes=\(dragTypes)}" - } - - private func debugRegisteredDragTypes(_ view: NSView) -> String { - let types = view.registeredDraggedTypes - guard !types.isEmpty else { return "-" } - - let interestingTypes = types.filter { type in - let raw = type.rawValue - return raw == NSPasteboard.PasteboardType.fileURL.rawValue - || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue - || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue - || raw.contains("public.text") - || raw.contains("public.url") - || raw.contains("public.data") - } - let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes - let rendered = selected.map(\.rawValue).joined(separator: ",") - if selected.count < types.count { - return "\(rendered),+\(types.count - selected.count)" - } - return rendered - } - - private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { - guard let types else { return false } - return types.contains(.fileURL) - || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) - || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) - } - - private func debugEventName(_ eventType: NSEvent.EventType?) -> String { - guard let eventType else { return "none" } - switch eventType { - case .cursorUpdate: return "cursorUpdate" - case .appKitDefined: return "appKitDefined" - case .systemDefined: return "systemDefined" - case .applicationDefined: return "applicationDefined" - case .periodic: return "periodic" - case .mouseMoved: return "mouseMoved" - case .mouseEntered: return "mouseEntered" - case .mouseExited: return "mouseExited" - case .flagsChanged: return "flagsChanged" - case .leftMouseDown: return "leftMouseDown" - case .leftMouseUp: return "leftMouseUp" - case .leftMouseDragged: return "leftMouseDragged" - case .rightMouseDown: return "rightMouseDown" - case .rightMouseUp: return "rightMouseUp" - case .rightMouseDragged: return "rightMouseDragged" - case .otherMouseDown: return "otherMouseDown" - case .otherMouseUp: return "otherMouseUp" - case .otherMouseDragged: return "otherMouseDragged" - case .scrollWheel: return "scrollWheel" - default: return "other(\(eventType.rawValue))" - } - } - -#if DEBUG - private func logHitTestDecision( - pasteboardTypes: [NSPasteboard.PasteboardType]?, - eventType: NSEvent.EventType?, - shouldCapture: Bool - ) { - let isDragEvent = eventType == .leftMouseDragged - || eventType == .rightMouseDragged - || eventType == .otherMouseDragged - guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } - - let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" - guard lastHitTestLogSignature != signature else { return } - lastHitTestLogSignature = signature - dlog( - "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + - "event=\(debugEventName(eventType)) " + - "topHit=\(debugTopHitViewForCurrentEvent()) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } - - private func logDragRouteDecision( - phase: String, - pasteboardTypes: [NSPasteboard.PasteboardType]?, - shouldCapture: Bool, - hasLocalDraggingSource: Bool, - hasTerminalTarget: Bool - ) { - guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } - let signature = [ - shouldCapture ? "1" : "0", - hasLocalDraggingSource ? "1" : "0", - hasTerminalTarget ? "1" : "0", - debugPasteboardTypes(pasteboardTypes) - ].joined(separator: "|") - guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } - lastDragRouteLogSignatureByPhase[phase] = signature - dlog( - "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + - "localSource=\(hasLocalDraggingSource ? 1 : 0) " + - "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + - "types=\(debugPasteboardTypes(pasteboardTypes))" - ) - } -#endif - /// Hit-tests the window to find the GhosttyNSView under the cursor. - func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { - if let window, - let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { - return portalTerminal - } - - guard let window, let contentView = window.contentView else { return nil } - isHidden = true - defer { isHidden = false } - let point = contentView.convert(windowPoint, from: nil) - let hitView = contentView.hitTest(point) - - var current: NSView? = hitView - while let view = current { - if let terminal = view as? GhosttyNSView { return terminal } - current = view.superview - } - return nil - } -} - var fileDropOverlayKey: UInt8 = 0 private var commandPaletteWindowOverlayKey: UInt8 = 0 private var tmuxWorkspacePaneWindowOverlayKey: UInt8 = 0 @@ -1018,7 +435,7 @@ private func debugCommandPaletteKeyEventSummary(_ event: NSEvent) -> String { "chars=\(chars) charsIgnoring=\(charsIgnoring)" } -private func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { +func debugCommandPaletteTextPreview(_ text: String, limit: Int = 120) -> String { let escaped = text .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\n", with: "\\n") @@ -1823,50 +1240,193 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? - @State private var isCommandPalettePresented = false - @State private var commandPaletteQuery: String = "" - @State private var commandPaletteMode: CommandPaletteMode = .commands - @State private var commandPaletteRenameDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionDraft: String = "" - @State private var commandPaletteWorkspaceDescriptionHeight: CGFloat = CommandPaletteMultilineTextEditorRepresentable.defaultMinimumHeight - @State private var commandPaletteSelectedResultIndex: Int = 0 - @State private var commandPaletteSelectionAnchorCommandID: String? - @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteScrollTargetIndex: Int? - @State private var commandPaletteScrollTargetAnchor: UnitPoint? - @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] = [] - @State private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] = [:] - @State private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] = [:] - @State private var cachedCommandPaletteResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResults: [CommandPaletteSearchResult] = [] - @State private var commandPaletteVisibleResultsScope: CommandPaletteListScope? - @State private var commandPaletteVisibleResultsFingerprint: Int? - @State private var cachedCommandPaletteScope: CommandPaletteListScope? - @State private var cachedCommandPaletteFingerprint: Int? - @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? - @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? - @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? - @State private var commandPaletteSearchTask: Task? - @State private var commandPaletteSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 - @State private var commandPaletteResolvedSearchScope: CommandPaletteListScope? - @State private var commandPaletteResolvedSearchFingerprint: Int? - @State private var commandPaletteResolvedMatchingQuery = "" - @State private var commandPaletteTerminalOpenTargetAvailability: Set = [] - @State private var isCommandPaletteSearchPending = false - @State private var commandPalettePendingActivation: CommandPalettePendingActivation? - @State private var commandPaletteResultsRevision: UInt64 = 0 - @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] - @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) - private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus - @AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey) - private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces - @State private var commandPaletteShouldFocusWorkspaceDescriptionEditor = false + @StateObject private var commandPaletteController = CommandPaletteController() + private var isCommandPalettePresented: Bool { + get { commandPaletteController.isCommandPalettePresented } + nonmutating set { commandPaletteController.isCommandPalettePresented = newValue } + } + private var commandPaletteQuery: String { + get { commandPaletteController.commandPaletteQuery } + nonmutating set { commandPaletteController.commandPaletteQuery = newValue } + } + private var commandPaletteMode: CommandPaletteMode { + get { commandPaletteController.commandPaletteMode } + nonmutating set { commandPaletteController.commandPaletteMode = newValue } + } + private var commandPaletteRenameDraft: String { + get { commandPaletteController.commandPaletteRenameDraft } + nonmutating set { commandPaletteController.commandPaletteRenameDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionDraft: String { + get { commandPaletteController.commandPaletteWorkspaceDescriptionDraft } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = newValue } + } + private var commandPaletteWorkspaceDescriptionHeight: CGFloat { + get { commandPaletteController.commandPaletteWorkspaceDescriptionHeight } + nonmutating set { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = newValue } + } + private var commandPaletteSelectedResultIndex: Int { + get { commandPaletteController.commandPaletteSelectedResultIndex } + nonmutating set { commandPaletteController.commandPaletteSelectedResultIndex = newValue } + } + private var commandPaletteSelectionAnchorCommandID: String? { + get { commandPaletteController.commandPaletteSelectionAnchorCommandID } + nonmutating set { commandPaletteController.commandPaletteSelectionAnchorCommandID = newValue } + } + private var commandPaletteHoveredResultIndex: Int? { + get { commandPaletteController.commandPaletteHoveredResultIndex } + nonmutating set { commandPaletteController.commandPaletteHoveredResultIndex = newValue } + } + private var commandPaletteScrollTargetIndex: Int? { + get { commandPaletteController.commandPaletteScrollTargetIndex } + nonmutating set { commandPaletteController.commandPaletteScrollTargetIndex = newValue } + } + private var commandPaletteScrollTargetAnchor: UnitPoint? { + get { commandPaletteController.commandPaletteScrollTargetAnchor } + nonmutating set { commandPaletteController.commandPaletteScrollTargetAnchor = newValue } + } + private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPaletteRestoreFocusTarget } + nonmutating set { commandPaletteController.commandPaletteRestoreFocusTarget = newValue } + } + private var commandPaletteSearchCorpus: [CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpus } + nonmutating set { commandPaletteController.commandPaletteSearchCorpus = newValue } + } + private var commandPaletteSearchCorpusByID: [String: CommandPaletteSearchCorpusEntry] { + get { commandPaletteController.commandPaletteSearchCorpusByID } + nonmutating set { commandPaletteController.commandPaletteSearchCorpusByID = newValue } + } + private var commandPaletteSearchCommandsByID: [String: CommandPaletteCommand] { + get { commandPaletteController.commandPaletteSearchCommandsByID } + nonmutating set { commandPaletteController.commandPaletteSearchCommandsByID = newValue } + } + private var cachedCommandPaletteResults: [CommandPaletteSearchResult] { + get { commandPaletteController.cachedCommandPaletteResults } + nonmutating set { commandPaletteController.cachedCommandPaletteResults = newValue } + } + private var commandPaletteVisibleResults: [CommandPaletteSearchResult] { + get { commandPaletteController.commandPaletteVisibleResults } + nonmutating set { commandPaletteController.commandPaletteVisibleResults = newValue } + } + private var commandPaletteVisibleResultsScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteVisibleResultsScope } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsScope = newValue } + } + private var commandPaletteVisibleResultsFingerprint: Int? { + get { commandPaletteController.commandPaletteVisibleResultsFingerprint } + nonmutating set { commandPaletteController.commandPaletteVisibleResultsFingerprint = newValue } + } + private var cachedCommandPaletteScope: CommandPaletteListScope? { + get { commandPaletteController.cachedCommandPaletteScope } + nonmutating set { commandPaletteController.cachedCommandPaletteScope = newValue } + } + private var cachedCommandPaletteFingerprint: Int? { + get { commandPaletteController.cachedCommandPaletteFingerprint } + nonmutating set { commandPaletteController.cachedCommandPaletteFingerprint = newValue } + } + private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? { + get { commandPaletteController.commandPalettePendingDismissFocusTarget } + nonmutating set { commandPaletteController.commandPalettePendingDismissFocusTarget = newValue } + } + private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? { + get { commandPaletteController.commandPaletteRestoreTimeoutWorkItem } + nonmutating set { commandPaletteController.commandPaletteRestoreTimeoutWorkItem = newValue } + } + private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? { + get { commandPaletteController.commandPalettePendingTextSelectionBehavior } + nonmutating set { commandPaletteController.commandPalettePendingTextSelectionBehavior = newValue } + } + private var commandPaletteSearchTask: Task? { + get { commandPaletteController.commandPaletteSearchTask } + nonmutating set { commandPaletteController.commandPaletteSearchTask = newValue } + } + private var commandPaletteSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchRequestID: UInt64 { + get { commandPaletteController.commandPaletteResolvedSearchRequestID } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchRequestID = newValue } + } + private var commandPaletteResolvedSearchScope: CommandPaletteListScope? { + get { commandPaletteController.commandPaletteResolvedSearchScope } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchScope = newValue } + } + private var commandPaletteResolvedSearchFingerprint: Int? { + get { commandPaletteController.commandPaletteResolvedSearchFingerprint } + nonmutating set { commandPaletteController.commandPaletteResolvedSearchFingerprint = newValue } + } + private var commandPaletteResolvedMatchingQuery: String { + get { commandPaletteController.commandPaletteResolvedMatchingQuery } + nonmutating set { commandPaletteController.commandPaletteResolvedMatchingQuery = newValue } + } + private var commandPaletteTerminalOpenTargetAvailability: Set { + get { commandPaletteController.commandPaletteTerminalOpenTargetAvailability } + nonmutating set { commandPaletteController.commandPaletteTerminalOpenTargetAvailability = newValue } + } + private var isCommandPaletteSearchPending: Bool { + get { commandPaletteController.isCommandPaletteSearchPending } + nonmutating set { commandPaletteController.isCommandPaletteSearchPending = newValue } + } + private var commandPalettePendingActivation: CommandPalettePendingActivation? { + get { commandPaletteController.commandPalettePendingActivation } + nonmutating set { commandPaletteController.commandPalettePendingActivation = newValue } + } + private var commandPaletteResultsRevision: UInt64 { + get { commandPaletteController.commandPaletteResultsRevision } + nonmutating set { commandPaletteController.commandPaletteResultsRevision = newValue } + } + private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] { + get { commandPaletteController.commandPaletteUsageHistoryByCommandId } + nonmutating set { commandPaletteController.commandPaletteUsageHistoryByCommandId = newValue } + } + private var commandPaletteRenameSelectAllOnFocus: Bool { + get { commandPaletteController.commandPaletteRenameSelectAllOnFocus } + nonmutating set { commandPaletteController.commandPaletteRenameSelectAllOnFocus = newValue } + } + private var commandPaletteSearchAllSurfaces: Bool { + get { commandPaletteController.commandPaletteSearchAllSurfaces } + nonmutating set { commandPaletteController.commandPaletteSearchAllSurfaces = newValue } + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditor: Bool { + get { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor } + nonmutating set { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = newValue } + } + private var commandPaletteQueryBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteQuery }, + set: { commandPaletteController.commandPaletteQuery = $0 } + ) + } + private var commandPaletteRenameDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteRenameDraft }, + set: { commandPaletteController.commandPaletteRenameDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionDraftBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionDraft = $0 } + ) + } + private var commandPaletteWorkspaceDescriptionHeightBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight }, + set: { commandPaletteController.commandPaletteWorkspaceDescriptionHeight = $0 } + ) + } + private var commandPaletteShouldFocusWorkspaceDescriptionEditorBinding: Binding { + Binding( + get: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor }, + set: { commandPaletteController.commandPaletteShouldFocusWorkspaceDescriptionEditor = $0 } + ) + } @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { + enum CommandPaletteMode { case commands case renameInput(CommandPaletteRenameTarget) case renameConfirm(CommandPaletteRenameTarget, proposedName: String) @@ -1888,7 +1448,7 @@ struct ContentView: View { case command(commandID: String) } - private struct CommandPaletteRenameTarget: Equatable { + struct CommandPaletteRenameTarget: Equatable { enum Kind: Equatable { case workspace(workspaceId: UUID) case tab(workspaceId: UUID, panelId: UUID) @@ -1925,7 +1485,7 @@ struct ContentView: View { } } - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + struct CommandPaletteWorkspaceDescriptionTarget: Equatable { let workspaceId: UUID let currentDescription: String @@ -1944,7 +1504,7 @@ struct ContentView: View { } } - private struct CommandPaletteRestoreFocusTarget { + struct CommandPaletteRestoreFocusTarget { let workspaceId: UUID let panelId: UUID let intent: PanelFocusIntent @@ -1955,7 +1515,7 @@ struct ContentView: View { case rename } - private enum CommandPaletteTextSelectionBehavior { + enum CommandPaletteTextSelectionBehavior { case caretAtEnd case selectAll } @@ -3895,7 +3455,7 @@ struct ContentView: View { HStack(spacing: 8) { CommandPaletteSearchFieldRepresentable( placeholder: commandPaletteSearchPlaceholder, - text: $commandPaletteQuery, + text: commandPaletteQueryBinding, isFocused: Binding( get: { isCommandPaletteSearchFocused }, set: { isCommandPaletteSearchFocused = $0 } @@ -4115,7 +3675,7 @@ struct ContentView: View { onDeleteBackward: handleCommandPaletteRenameDeleteBackward(modifiers:) ), placeholder: target.placeholder, - text: $commandPaletteRenameDraft, + text: commandPaletteRenameDraftBinding, onSubmit: { _ in continueRenameFlow(target: target) }, onEscape: { dismissCommandPalette() }, onInteraction: handleCommandPaletteRenameInputInteraction @@ -4199,12 +3759,12 @@ struct ContentView: View { localized: "command.editWorkspaceDescription.title", defaultValue: "Edit Workspace Description…" ), - focus: $commandPaletteShouldFocusWorkspaceDescriptionEditor, - measuredHeight: $commandPaletteWorkspaceDescriptionHeight, + focus: commandPaletteShouldFocusWorkspaceDescriptionEditorBinding, + measuredHeight: commandPaletteWorkspaceDescriptionHeightBinding, maxHeight: maxEditorHeight ), placeholder: target.placeholder, - text: $commandPaletteWorkspaceDescriptionDraft, + text: commandPaletteWorkspaceDescriptionDraftBinding, onSubmit: { proposedDescription in applyWorkspaceDescriptionFlow(target: target, proposedDescription: proposedDescription) }, @@ -4488,7 +4048,7 @@ struct ContentView: View { override func hitTest(_ point: NSPoint) -> NSView? { nil } } - private final class CommandPaletteMultilineTextView: NSTextView { + final class CommandPaletteMultilineTextView: NSTextView { var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? var onDidBecomeFirstResponder: (() -> Void)? @@ -4616,7 +4176,7 @@ struct ContentView: View { } } - private final class CommandPaletteMultilineTextEditorView: NSView { + final class CommandPaletteMultilineTextEditorView: NSView { private static let font = NSFont.systemFont(ofSize: 13) private static let textInset = NSSize(width: 0, height: 2) static let defaultMinimumHeight: CGFloat = { @@ -4821,7 +4381,7 @@ struct ContentView: View { } } - private struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { + struct CommandPaletteMultilineTextEditorRepresentable: NSViewRepresentable { static let defaultMinimumHeight = CommandPaletteMultilineTextEditorView.defaultMinimumHeight let placeholder: String @@ -7731,1007 +7291,20 @@ struct ContentView: View { #endif } -struct CommandPaletteSwitcherSearchMetadata: Equatable, Sendable { - let directories: [String] - let branches: [String] - let ports: [Int] - let description: String? - - init( - directories: [String] = [], - branches: [String] = [], - ports: [Int] = [], - description: String? = nil - ) { - self.directories = directories - self.branches = branches - self.ports = ports - self.description = description - } -} - -enum CommandPaletteSwitcherSearchIndexer { - enum MetadataDetail { - case workspace - case surface - } - - private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") - - static func keywords( - baseKeywords: [String], - metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail = .surface - ) -> [String] { - let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) - return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) - } - - private static func metadataKeywordsForSearch( - _ metadata: CommandPaletteSwitcherSearchMetadata, - detail: MetadataDetail - ) -> [String] { - let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } - let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } - let portTokens = metadata.ports.flatMap(portTokensForSearch) - let descriptionTokens = descriptionTokensForSearch(metadata.description) - - var contextKeywords: [String] = [] - if !directoryTokens.isEmpty { - contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) - } - if !branchTokens.isEmpty { - contextKeywords.append(contentsOf: ["branch", "git"]) - } - if !portTokens.isEmpty { - contextKeywords.append(contentsOf: ["port", "ports"]) - } - if !descriptionTokens.isEmpty { - contextKeywords.append(contentsOf: ["description", "descriptions", "notes", "note"]) - } - - return contextKeywords + directoryTokens + branchTokens + portTokens + descriptionTokens - } - - private static func directoryTokensForSearch( - _ rawDirectory: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - - let standardized = (trimmed as NSString).standardizingPath - let canonical = standardized.isEmpty ? trimmed : standardized - let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath - switch detail { - case .workspace: - return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) - case .surface: - let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent - let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder( - [trimmed, canonical, abbreviated, basename] + components - ) - } - } - - private static func branchTokensForSearch( - _ rawBranch: String, - detail: MetadataDetail - ) -> [String] { - let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - switch detail { - case .workspace: - return [trimmed] - case .surface: - let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed] + components) - } - } - - private static func portTokensForSearch(_ port: Int) -> [String] { - guard (1...65535).contains(port) else { return [] } - let portText = String(port) - return [portText, ":\(portText)"] - } - - private static func descriptionTokensForSearch(_ rawDescription: String?) -> [String] { - let trimmed = rawDescription?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmed.isEmpty else { return [] } - let normalizedWhitespace = trimmed.replacingOccurrences( - of: "\\s+", - with: " ", - options: .regularExpression - ) - let components = normalizedWhitespace.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } - return uniqueNormalizedPreservingOrder([trimmed, normalizedWhitespace] + components) - } - - private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { - var result: [String] = [] - var seen: Set = [] - result.reserveCapacity(values.count) +private struct SidebarResizerAccessibilityModifier: ViewModifier { + let accessibilityIdentifier: String? - for value in values { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let normalizedKey = trimmed - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - guard seen.insert(normalizedKey).inserted else { continue } - result.append(trimmed) + @ViewBuilder + func body(content: Content) -> some View { + if let accessibilityIdentifier { + content.accessibilityIdentifier(accessibilityIdentifier) + } else { + content } - return result } } -enum CommandPaletteFuzzyMatcher { - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 10 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - struct PreparedQuery { - let normalizedText: String - let tokens: [String] - - var isEmpty: Bool { - tokens.isEmpty - } - } - - static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - return PreparedQuery( - normalizedText: normalizedQuery, - tokens: normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } - ) - } - - static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - static func score(query: String, candidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery(query), - normalizedCandidates: candidates - .map(normalizeForSearch) - .filter { !$0.isEmpty } - ) - } - - static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !normalizedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - var bestTokenScore: Int? - for candidate in normalizedCandidates { - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = normalizeForSearch(candidate) - guard !loweredCandidate.isEmpty else { return [] } - - let candidateChars = Array(loweredCandidate) - var matched: Set = [] - - for token in preparedQuery.tokens { - if token == loweredCandidate { - matched.formUnion(0.. Int? { - guard !token.isEmpty else { return 0 } - - let candidateChars = Array(candidate) - let tokenChars = Array(token) - guard tokenChars.count <= candidateChars.count else { return nil } - - if token == candidate { - return 8000 - } - if candidate.hasPrefix(token) { - return 6800 - max(0, candidate.count - token.count) - } - - var bestScore: Int? - if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { - bestScore = max(bestScore ?? wordExactScore, wordExactScore) - } - if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { - bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidateChars: candidateChars - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - - if let range = candidate.range(of: token) { - let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidate.count - token.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidateChars: [Character], - requireExactWord: Bool - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - var best: Int? - for segment in wordSegments(candidateChars) { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidateChars: [Character]) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count >= 4 else { return nil } - - let segments = wordSegments(candidateChars) - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in wordSegments(candidateChars) { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: (start: Int, end: Int) - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { - var segments: [(start: Int, end: Int)] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append((start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: String, candidate: String) -> Int? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: String, candidate: String) -> Set? { - let tokenChars = Array(token) - let candidateChars = Array(candidate) - guard !tokenChars.isEmpty else { return nil } - - let segments = wordSegments(candidateChars) - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} - -struct CommandPaletteSearchCorpusEntry: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let normalizedTitle: String - let normalizedSearchableTexts: [String] - - init(payload: Payload, rank: Int, title: String, searchableTexts: [String]) { - self.payload = payload - self.rank = rank - self.title = title - self.normalizedTitle = CommandPaletteFuzzyMatcher.normalizeForSearch(title) - self.normalizedSearchableTexts = searchableTexts - .map(CommandPaletteFuzzyMatcher.normalizeForSearch) - .filter { !$0.isEmpty } - } -} - -struct CommandPaletteSearchCorpusResult: Sendable where Payload: Sendable { - let payload: Payload - let rank: Int - let title: String - let score: Int - let titleMatchIndices: Set -} - -enum CommandPaletteSearchEngine { - private static let titleMatchBonus = 2000 - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: nil - ) - } - - static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: @escaping () -> Bool - ) -> [CommandPaletteSearchCorpusResult] { - search( - entries: entries, - query: query, - historyBoost: historyBoost, - shouldCancel: Optional(shouldCancel) - ) - } - - private static func search( - entries: [CommandPaletteSearchCorpusEntry], - query: String, - historyBoost: (Payload, Bool) -> Int, - shouldCancel: (() -> Bool)? - ) -> [CommandPaletteSearchCorpusResult] { - let preparedQuery = CommandPaletteFuzzyMatcher.preparedQuery(query) - let queryIsEmpty = preparedQuery.isEmpty - var results: [CommandPaletteSearchCorpusResult] = [] - results.reserveCapacity(entries.count) - - func shouldCancelSearch(at index: Int) -> Bool { - guard let shouldCancel else { return false } - return index % 16 == 0 && shouldCancel() - } - - if queryIsEmpty { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: historyBoost(entry.payload, true), - titleMatchIndices: [] - ) - ) - } - } else { - for (index, entry) in entries.enumerated() { - if shouldCancelSearch(at: index) { return [] } - guard let fuzzyScore = weightedScore( - preparedQuery: preparedQuery, - entry: entry - ) else { - continue - } - results.append( - CommandPaletteSearchCorpusResult( - payload: entry.payload, - rank: entry.rank, - title: entry.title, - score: fuzzyScore + historyBoost(entry.payload, false), - titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( - preparedQuery: preparedQuery, - candidate: entry.title - ) - ) - ) - } - } - - if shouldCancel?() == true { return [] } - - return results.sorted { lhs, rhs in - if lhs.score != rhs.score { return lhs.score > rhs.score } - if lhs.rank != rhs.rank { return lhs.rank < rhs.rank } - return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending - } - } - - private static func weightedScore( - preparedQuery: CommandPaletteFuzzyMatcher.PreparedQuery, - entry: CommandPaletteSearchCorpusEntry - ) -> Int? { - guard let fuzzyScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: entry.normalizedSearchableTexts - ) else { - return nil - } - guard !entry.normalizedTitle.isEmpty, - let titleScore = CommandPaletteFuzzyMatcher.score( - preparedQuery: preparedQuery, - normalizedCandidates: [entry.normalizedTitle] - ) else { - return fuzzyScore - } - return max(fuzzyScore, titleScore + titleMatchBonus) - } -} - -private struct SidebarResizerAccessibilityModifier: ViewModifier { - let accessibilityIdentifier: String? - - @ViewBuilder - func body(content: Content) -> some View { - if let accessibilityIdentifier { - content.accessibilityIdentifier(accessibilityIdentifier) - } else { - content - } - } -} - -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let sidebarShortcutHintXOffset: Double let sidebarShortcutHintYOffset: Double let alwaysShowShortcutHints: Bool @@ -9168,254 +7741,16 @@ enum DevBuildBannerDebugSettings { } } -enum SidebarDragLifecycleNotification { - static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") - static let requestClear = Notification.Name("programa.sidebarDragRequestClear") - static let tabIdKey = "tabId" - static let reasonKey = "reason" - - static func postStateDidChange(tabId: UUID?, reason: String) { - var userInfo: [AnyHashable: Any] = [reasonKey: reason] - if let tabId { - userInfo[tabIdKey] = tabId - } - NotificationCenter.default.post( - name: stateDidChange, - object: nil, - userInfo: userInfo - ) - } +private final class SidebarShortcutHintModifierMonitor: ObservableObject { + @Published private(set) var isModifierPressed = false - static func postClearRequest(reason: String) { - NotificationCenter.default.post( - name: requestClear, - object: nil, - userInfo: [reasonKey: reason] - ) - } - - static func tabId(from notification: Notification) -> UUID? { - notification.userInfo?[tabIdKey] as? UUID - } - - static func reason(from notification: Notification) -> String { - notification.userInfo?[reasonKey] as? String ?? "unknown" - } -} - -enum SidebarOutsideDropResetPolicy { - static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { - draggedTabId != nil && hasSidebarDragPayload - } -} - -enum SidebarDragFailsafePolicy { - static let clearDelay: TimeInterval = 0.15 - - static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { - isDragActive && !isLeftMouseButtonDown - } - - static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { - shouldRequestClear( - isDragActive: true, - isLeftMouseButtonDown: isLeftMouseButtonDown - ) - } - - static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { - eventType == .leftMouseUp - } -} - -@MainActor -private final class SidebarDragFailsafeMonitor: ObservableObject { - private static let escapeKeyCode: UInt16 = 53 - private var pendingClearWorkItem: DispatchWorkItem? - private var appResignObserver: NSObjectProtocol? - private var keyDownMonitor: Any? - private var localMouseMonitor: Any? - private var globalMouseMonitor: Any? - private var onRequestClear: ((String) -> Void)? - - func start(onRequestClear: @escaping (String) -> Void) { - self.onRequestClear = onRequestClear - if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( - isLeftMouseButtonDown: CGEventSource.buttonState( - .combinedSessionState, - button: .left - ) - ) { - requestClearSoon(reason: "mouse_up_failsafe") - } - if appResignObserver == nil { - appResignObserver = NotificationCenter.default.addObserver( - forName: NSApplication.didResignActiveNotification, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "app_resign_active") - } - } - } - if keyDownMonitor == nil { - keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - if event.keyCode == Self.escapeKeyCode { - self?.requestClearSoon(reason: "escape_cancel") - } - return event - } - } - if localMouseMonitor == nil { - localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - return event - } - } - if globalMouseMonitor == nil { - globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in - guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } - Task { @MainActor [weak self] in - self?.requestClearSoon(reason: "mouse_up_failsafe") - } - } - } - } - - func stop() { - pendingClearWorkItem?.cancel() - pendingClearWorkItem = nil - if let appResignObserver { - NotificationCenter.default.removeObserver(appResignObserver) - self.appResignObserver = nil - } - if let keyDownMonitor { - NSEvent.removeMonitor(keyDownMonitor) - self.keyDownMonitor = nil - } - if let localMouseMonitor { - NSEvent.removeMonitor(localMouseMonitor) - self.localMouseMonitor = nil - } - if let globalMouseMonitor { - NSEvent.removeMonitor(globalMouseMonitor) - self.globalMouseMonitor = nil - } - onRequestClear = nil - } - - private func requestClearSoon(reason: String) { - guard pendingClearWorkItem == nil else { return } -#if DEBUG - dlog("sidebar.dragFailsafe.schedule reason=\(reason)") -#endif - let workItem = DispatchWorkItem { [weak self] in -#if DEBUG - dlog("sidebar.dragFailsafe.fire reason=\(reason)") -#endif - self?.pendingClearWorkItem = nil - self?.onRequestClear?(reason) - } - pendingClearWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) - } -} - -private struct SidebarExternalDropOverlay: View { - let draggedTabId: UUID? - - var body: some View { - let dragPasteboardTypes = NSPasteboard(name: .drag).types - let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( - draggedTabId: draggedTabId, - pasteboardTypes: dragPasteboardTypes - ) - Group { - if shouldCapture { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(true) - .onDrop( - of: SidebarTabDragPayload.dropContentTypes, - delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) - ) - } else { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(false) - } - } - } -} - -private struct SidebarExternalDropDelegate: DropDelegate { - let draggedTabId: UUID? - - func validateDrop(info: DropInfo) -> Bool { - let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( - draggedTabId: draggedTabId, - hasSidebarDragPayload: hasSidebarPayload - ) -#if DEBUG - dlog( - "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + - "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" - ) -#endif - return shouldReset - } - - func dropEntered(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } -#if DEBUG - dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") -#endif - // Explicit move proposal avoids AppKit showing a copy (+) cursor. - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info) else { return false } -#if DEBUG - dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") -#endif - SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") - return true - } - - private func debugShortSidebarTabId(_ id: UUID?) -> String { - guard let id else { return "nil" } - return String(id.uuidString.prefix(5)) - } -} - -@MainActor -private final class SidebarShortcutHintModifierMonitor: ObservableObject { - @Published private(set) var isModifierPressed = false - - private weak var hostWindow: NSWindow? - private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? - private var hostWindowDidResignKeyObserver: NSObjectProtocol? - private var flagsMonitor: Any? - private var keyDownMonitor: Any? - private var appResignObserver: NSObjectProtocol? - private var pendingShowWorkItem: DispatchWorkItem? + private weak var hostWindow: NSWindow? + private var hostWindowDidBecomeKeyObserver: NSObjectProtocol? + private var hostWindowDidResignKeyObserver: NSObjectProtocol? + private var flagsMonitor: Any? + private var keyDownMonitor: Any? + private var appResignObserver: NSObjectProtocol? + private var pendingShowWorkItem: DispatchWorkItem? func setHostWindow(_ window: NSWindow?) { guard hostWindow !== window else { return } @@ -9565,4041 +7900,3 @@ private final class SidebarShortcutHintModifierMonitor: ObservableObject { } } -private struct SidebarFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { -#if DEBUG - SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) -#else - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) -#endif - } -} - -private struct SidebarFooterButtons: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - - var body: some View { - HStack(spacing: 4) { - SidebarHelpMenuButton(onSendFeedback: onSendFeedback) - UpdatePill(model: updateViewModel) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -private enum SidebarHelpMenuAction { - case importBrowserData - case keyboardShortcuts - case docs - case changelog - case github - case githubIssues - case discord - case checkForUpdates - case sendFeedback - case welcome -} - -private struct SidebarHelpMenuButton: View { - private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") - private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") - private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") - private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") - private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") - private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") - private let buttonSize: CGFloat = 22 - private let iconSize: CGFloat = 11 - @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared - - let onSendFeedback: () -> Void - - @State private var isPopoverPresented = false - - private var sendFeedbackShortcutHint: String { - let _ = keyboardShortcutSettingsObserver.revision - return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString - } - - var body: some View { - Button { - isPopoverPresented.toggle() - } label: { - Image(systemName: "questionmark.circle") - .symbolRenderingMode(.monochrome) - .font(.system(size: iconSize, weight: .medium)) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .frame(width: buttonSize, height: buttonSize, alignment: .center) - .background(ArrowlessPopoverAnchor( - isPresented: $isPopoverPresented, - preferredEdge: .maxY, - detachedGap: 4 - ) { - helpPopover - }) - .accessibilityElement(children: .ignore) - .safeHelp(helpTitle) - .accessibilityLabel(helpTitle) - .accessibilityIdentifier("SidebarHelpMenuButton") - } - - private var helpPopover: some View { - VStack(alignment: .leading, spacing: 2) { - helpOptionButton( - title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), - action: .welcome, - accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), - action: .sendFeedback, - accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", - isExternalLink: false, - shortcutHint: sendFeedbackShortcutHint, - trailingSystemImage: "bubble.left.and.text.bubble.right" - ) - helpOptionButton( - title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), - action: .keyboardShortcuts, - accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", - isExternalLink: false - ) - helpOptionButton( - title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), - action: .importBrowserData, - accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", - isExternalLink: false - ) - if docsURL != nil { - helpOptionButton( - title: String(localized: "about.docs", defaultValue: "Docs"), - action: .docs, - accessibilityIdentifier: "SidebarHelpMenuOptionDocs", - isExternalLink: true - ) - } - if changelogURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), - action: .changelog, - accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", - isExternalLink: true - ) - } - if githubURL != nil { - helpOptionButton( - title: String(localized: "about.github", defaultValue: "GitHub"), - action: .github, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", - isExternalLink: true - ) - } - if githubIssuesURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), - action: .githubIssues, - accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", - isExternalLink: true - ) - } - if discordURL != nil { - helpOptionButton( - title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), - action: .discord, - accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", - isExternalLink: true - ) - } - helpOptionButton( - title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), - action: .checkForUpdates, - accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", - isExternalLink: false - ) - } - .padding(8) - .frame(minWidth: 200) - } - - private func helpOptionButton( - title: String, - action: SidebarHelpMenuAction, - accessibilityIdentifier: String, - isExternalLink: Bool, - shortcutHint: String? = nil, - trailingSystemImage: String? = nil - ) -> some View { - Button { - isPopoverPresented = false - perform(action) - } label: { - HStack(spacing: 8) { - Text(title) - .font(.system(size: 12)) - Spacer(minLength: 0) - if let shortcutHint { - helpOptionShortcutHint(text: shortcutHint) - } - if let trailingSystemImage { - helpOptionTrailingIcon(systemName: trailingSystemImage) - } - if isExternalLink { - helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) - } - } - .padding(.horizontal, 8) - .frame(height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityIdentifier(accessibilityIdentifier) - } - - private func helpOptionShortcutHint(text: String) -> some View { - Text(text) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .regular, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { - Image(systemName: systemName) - .resizable() - .scaledToFit() - .frame(width: size, height: size) - .foregroundStyle(Color(nsColor: .secondaryLabelColor)) - } - - private func perform(_ action: SidebarHelpMenuAction) { - switch action { - case .importBrowserData: - isPopoverPresented = false - DispatchQueue.main.async { - BrowserDataImportCoordinator.shared.presentImportDialog() - } - case .keyboardShortcuts: - isPopoverPresented = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openPreferencesWindow( - debugSource: "sidebarHelpMenu.keyboardShortcuts", - navigationTarget: .keyboardShortcuts - ) - } else { - AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) - } - } - } - case .docs: - guard let docsURL else { return } - NSWorkspace.shared.open(docsURL) - case .changelog: - guard let changelogURL else { return } - NSWorkspace.shared.open(changelogURL) - case .github: - guard let githubURL else { return } - NSWorkspace.shared.open(githubURL) - case .githubIssues: - guard let githubIssuesURL else { return } - NSWorkspace.shared.open(githubIssuesURL) - case .discord: - guard let discordURL else { return } - NSWorkspace.shared.open(discordURL) - case .checkForUpdates: - Task { @MainActor in - AppDelegate.shared?.checkForUpdates(nil) - } - case .sendFeedback: - isPopoverPresented = false - onSendFeedback() - case .welcome: - isPopoverPresented = false - Task { @MainActor in - if let appDelegate = AppDelegate.shared { - appDelegate.openWelcomeWorkspace() - } - } - } - } - -} - -private struct ArrowlessPopoverAnchor: NSViewRepresentable { - @Binding var isPresented: Bool - let preferredEdge: NSRectEdge - let detachedGap: CGFloat - @ViewBuilder let content: () -> PopoverContent - - func makeNSView(context: Context) -> NSView { - let view = NSView() - context.coordinator.anchorView = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - context.coordinator.anchorView = nsView - context.coordinator.updateRootView(AnyView(content())) - - if isPresented { - context.coordinator.present( - preferredEdge: preferredEdge, - detachedGap: detachedGap - ) - } else { - context.coordinator.dismiss() - } - } - - func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) - } - - final class Coordinator: NSObject, NSPopoverDelegate { - @Binding var isPresented: Bool - - weak var anchorView: NSView? - private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) - private var popover: NSPopover? - - init(isPresented: Binding) { - _isPresented = isPresented - } - - func updateRootView(_ rootView: AnyView) { - hostingController.rootView = AnyView(rootView.fixedSize()) - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - } - - func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { - guard let anchorView else { - isPresented = false - dismiss() - return - } - - let popover = popover ?? makePopover() - if popover.isShown { - return - } - - hostingController.view.invalidateIntrinsicContentSize() - hostingController.view.layoutSubtreeIfNeeded() - let fittingSize = hostingController.view.fittingSize - if fittingSize.width > 0, fittingSize.height > 0 { - popover.contentSize = NSSize( - width: ceil(fittingSize.width), - height: ceil(fittingSize.height) - ) - } - - popover.show( - relativeTo: positioningRect( - for: anchorView.bounds, - preferredEdge: preferredEdge, - detachedGap: detachedGap - ), - of: anchorView, - preferredEdge: preferredEdge - ) - } - - func dismiss() { - popover?.performClose(nil) - popover = nil - } - - func popoverDidClose(_ notification: Notification) { - popover = nil - if isPresented { - isPresented = false - } - } - - private func makePopover() -> NSPopover { - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = hostingController - popover.delegate = self - self.popover = popover - return popover - } - - private func positioningRect( - for bounds: CGRect, - preferredEdge: NSRectEdge, - detachedGap: CGFloat - ) -> CGRect { - let hiddenArrowInset: CGFloat = 13 - let compensation = max(hiddenArrowInset - detachedGap, 0) - - switch preferredEdge { - case .maxY: - return NSRect( - x: bounds.minX, - y: bounds.maxY - compensation, - width: bounds.width, - height: compensation - ) - case .minY: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: bounds.width, - height: compensation - ) - case .maxX: - return NSRect( - x: bounds.maxX - compensation, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - case .minX: - return NSRect( - x: bounds.minX, - y: bounds.minY, - width: compensation, - height: bounds.height - ) - @unknown default: - return bounds - } - } - } -} - -private struct SidebarFooterIconButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - SidebarFooterIconButtonStyleBody(configuration: configuration) - } -} - -private struct SidebarFooterIconButtonStyleBody: View { - let configuration: SidebarFooterIconButtonStyle.Configuration - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - private var backgroundOpacity: Double { - guard isEnabled else { return 0.0 } - if configuration.isPressed { return 0.16 } - if isHovered { return 0.08 } - return 0.0 - } - - var body: some View { - configuration.label - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(backgroundOpacity)) - ) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.08), value: configuration.isPressed) - } -} - -#if DEBUG -private struct SidebarDevFooter: View { - @ObservedObject var updateViewModel: UpdateViewModel - let onSendFeedback: () -> Void - @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) - private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) - if showSidebarDevBuildBanner { - Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.red) - } - } - .padding(.leading, 6) - .padding(.trailing, 10) - .padding(.bottom, 6) - } -} -#endif - -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} - -private struct SidebarScrollViewResolver: NSViewRepresentable { - let onResolve: (NSScrollView?) -> Void - - func makeNSView(context: Context) -> SidebarScrollViewResolverView { - let view = SidebarScrollViewResolverView() - view.onResolve = onResolve - return view - } - - func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { - nsView.onResolve = onResolve - nsView.resolveScrollView() - } -} - -private final class SidebarScrollViewResolverView: NSView { - var onResolve: ((NSScrollView?) -> Void)? - - override func viewDidMoveToSuperview() { - super.viewDidMoveToSuperview() - resolveScrollView() - } - - override func viewDidMoveToWindow() { - super.viewDidMoveToWindow() - resolveScrollView() - } - - func resolveScrollView() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - onResolve?(self.enclosingScrollView) - } - } -} - -private struct SidebarEmptyArea: View { - @EnvironmentObject var tabManager: TabManager - let rowSpacing: CGFloat - @Binding var selection: SidebarSelection - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - Color.clear - .contentShape(Rectangle()) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onTapGesture(count: 2) { - tabManager.addWorkspace(placementOverride: .end) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - selection = .tabs - } - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: nil, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: nil, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .overlay(alignment: .top) { - if shouldShowTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: -(rowSpacing / 2)) - } - } - } - - private var shouldShowTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == nil { - return true - } - guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } - return indicator.tabId == lastTabId - } -} - -enum SidebarPathFormatter { - static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path - - static func shortenedPath( - _ path: String, - homeDirectoryPath: String = Self.homeDirectoryPath - ) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == homeDirectoryPath { - return "~" - } - if trimmed.hasPrefix(homeDirectoryPath + "/") { - return "~" + trimmed.dropFirst(homeDirectoryPath.count) - } - return trimmed - } -} - -enum SidebarWorkspaceShortcutHintMetrics { - private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) - private static let minimumSlotWidth: CGFloat = 28 - private static let horizontalPadding: CGFloat = 12 - private static let lock = NSLock() - private static var cachedHintWidths: [String: CGFloat] = [:] - #if DEBUG - private static var measurementCount = 0 - #endif - - static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { - guard let label else { return minimumSlotWidth } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 - return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) - } - - static func hintWidth(for label: String) -> CGFloat { - lock.lock() - if let cached = cachedHintWidths[label] { - lock.unlock() - return cached - } - lock.unlock() - - let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width - let measuredWidth = ceil(textWidth) + horizontalPadding - - lock.lock() - cachedHintWidths[label] = measuredWidth - #if DEBUG - measurementCount += 1 - #endif - lock.unlock() - return measuredWidth - } - - #if DEBUG - static func resetCacheForTesting() { - lock.lock() - cachedHintWidths.removeAll() - measurementCount = 0 - lock.unlock() - } - - static func measurementCountForTesting() -> Int { - lock.lock() - let count = measurementCount - lock.unlock() - return count - } - #endif -} - -enum SidebarTrailingAccessoryWidthPolicy { - static let closeButtonWidth: CGFloat = 16 - - static func width( - canCloseWorkspace: Bool, - showsWorkspaceShortcutHint: Bool, - workspaceShortcutLabel: String?, - debugXOffset: Double - ) -> CGFloat { - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - return SidebarWorkspaceShortcutHintMetrics.slotWidth( - label: workspaceShortcutLabel, - debugXOffset: debugXOffset - ) - } - - return canCloseWorkspace ? closeButtonWidth : 0 - } -} - -// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when -// the parent rebuilds with unchanged values. Without this, every TabManager -// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of -// main thread during typing). If you add new properties, update == below. -// Reactive workspace state inside the row must not rely on parent diffs alone: -// `.equatable()` can otherwise leave sidebar badges/details stale until an -// unrelated parent change sneaks through. Keep the workspace reference plain -// and bridge only sidebar-visible workspace changes into local state. -// Do NOT add @EnvironmentObject or new @Binding without updating ==. -// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. -private struct TabItemView: View, Equatable { - private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) - - // Closures, Bindings, and object references are excluded from == - // because they're recreated every parent eval but don't affect rendering. - nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { - lhs.tab === rhs.tab && - lhs.index == rhs.index && - lhs.isActive == rhs.isActive && - lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && - lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && - lhs.canCloseWorkspace == rhs.canCloseWorkspace && - lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && - lhs.unreadCount == rhs.unreadCount && - lhs.latestNotificationText == rhs.latestNotificationText && - lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && - lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && - lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && - lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && - lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && - lhs.settings == rhs.settings - } - - // Use plain references instead of @EnvironmentObject to avoid subscribing - // to ALL changes on these objects. Body reads use precomputed parameters; - // action handlers use the plain references without triggering re-evaluation. - let tabManager: TabManager - let notificationStore: TerminalNotificationStore - @Environment(\.colorScheme) private var colorScheme - let tab: Tab - let index: Int - let isActive: Bool - let workspaceShortcutDigit: Int? - let workspaceShortcutModifierSymbol: String - let canCloseWorkspace: Bool - let accessibilityWorkspaceCount: Int - let unreadCount: Int - let latestNotificationText: String? - let rowSpacing: CGFloat - let setSelectionToTabs: () -> Void - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let showsModifierShortcutHints: Bool - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var draggedTabId: UUID? - @Binding var dropIndicator: SidebarDropIndicator? - let contextMenuWorkspaceIds: [UUID] - let remoteContextMenuWorkspaceIds: [UUID] - let allRemoteContextMenuTargetsConnecting: Bool - let allRemoteContextMenuTargetsDisconnected: Bool - let settings: SidebarTabItemSettingsSnapshot - @State private var workspaceObservationGeneration: UInt64 = 0 - @State private var isHovering = false - @State private var rowHeight: CGFloat = 1 - // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. - // Updated only by the debounced publisher, onAppear, and settings changes — - // NOT by the immediate publisher (title keystrokes). This prevents the tree - // walk from running on every keystroke in single-panel workspaces. - @State private var cachedOrderedPanelIds: [UUID]? = nil - @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] - @State private var cachedPullRequestRows: [PullRequestDisplay] = [] - - var isMultiSelected: Bool { - selectedTabIds.contains(tab.id) - } - - private var isBeingDragged: Bool { - draggedTabId == tab.id - } - - private var sidebarShortcutHintXOffset: Double { - settings.sidebarShortcutHintXOffset - } - - private var sidebarShortcutHintYOffset: Double { - settings.sidebarShortcutHintYOffset - } - - private var alwaysShowShortcutHints: Bool { - settings.alwaysShowShortcutHints - } - - private var sidebarShowGitBranch: Bool { - settings.showsGitBranch - } - - private var sidebarBranchVerticalLayout: Bool { - settings.usesVerticalBranchLayout - } - - private var sidebarShowGitBranchIcon: Bool { - settings.showsGitBranchIcon - } - - private var sidebarShowSSH: Bool { - settings.showsSSH - } - - private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { - settings.activeTabIndicatorStyle - } - - private var sidebarSelectionColorHex: String? { - settings.selectionColorHex - } - - private var sidebarNotificationBadgeColorHex: String? { - settings.notificationBadgeColorHex - } - - private var openSidebarPullRequestLinksInProgramaBrowser: Bool { - settings.openPullRequestLinksInProgramaBrowser - } - - private var openSidebarPortLinksInProgramaBrowser: Bool { - settings.openPortLinksInProgramaBrowser - } - - private var titleFontWeight: Font.Weight { - .semibold - } - - private var showsLeadingRail: Bool { - explicitRailColor != nil - } - - private var activeBorderLineWidth: CGFloat { - switch activeTabIndicatorStyle { - case .leftRail: - return 0 - case .solidFill: - return isActive ? 1.5 : 0 - } - } - - private var activeBorderColor: Color { - guard isActive else { return .clear } - switch activeTabIndicatorStyle { - case .leftRail: - return .clear - case .solidFill: - return Color.primary.opacity(0.5) - } - } - - private var usesInvertedActiveForeground: Bool { - isActive - } - - private var activePrimaryTextColor: Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) - : .primary - } - - private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { - usesInvertedActiveForeground - ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) - : .secondary - } - - private var activeUnreadBadgeFillColor: Color { - if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { - return Color(nsColor: nsColor) - } - return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() - } - - private var activeProgressTrackColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) - } - - private var activeProgressFillColor: Color { - usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() - } - - private var shortcutHintEmphasis: Double { - usesInvertedActiveForeground ? 1.0 : 0.9 - } - - private var showCloseButton: Bool { - isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) - } - - private var workspaceShortcutLabel: String? { - guard let workspaceShortcutDigit else { return nil } - return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" - } - - private var showsWorkspaceShortcutHint: Bool { - (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil - } - - private var trailingAccessoryWidth: CGFloat { - SidebarTrailingAccessoryWidthPolicy.width( - canCloseWorkspace: canCloseWorkspace, - showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, - workspaceShortcutLabel: workspaceShortcutLabel, - debugXOffset: sidebarShortcutHintXOffset - ) - } - - private var remoteWorkspaceSidebarText: String? { - guard tab.hasActiveRemoteTerminalSessions else { return nil } - let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) - if let trimmedTarget, !trimmedTarget.isEmpty { - return trimmedTarget - } - return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") - } - - private var copyableSidebarSSHError: String? { - let fallbackTarget = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: trimmedDetail - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - if let statusValue = tab.statusEntries["remote.error"]?.value - .trimmingCharacters(in: .whitespacesAndNewlines), - !statusValue.isEmpty { - let entry = SidebarRemoteErrorCopyEntry( - workspaceTitle: tab.title, - target: fallbackTarget, - detail: statusValue - ) - return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) - } - return nil - } - - private var remoteConnectionStatusText: String { - switch tab.remoteConnectionState { - case .connected: - return String(localized: "remote.status.connected", defaultValue: "Connected") - case .connecting: - return String(localized: "remote.status.connecting", defaultValue: "Connecting") - case .error: - return String(localized: "remote.status.error", defaultValue: "Error") - case .disconnected: - return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") - } - } - - @ViewBuilder - private var remoteWorkspaceSection: some View { - if sidebarShowSSH, let remoteWorkspaceSidebarText { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(remoteWorkspaceSidebarText) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Text(remoteConnectionStatusText) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.58)) - .lineLimit(1) - } - } - .padding(.top, latestNotificationText == nil ? 1 : 2) - .safeHelp(remoteStateHelpText) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - - private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { - settings.visibleAuxiliaryDetails - } - - var body: some View { - let _ = workspaceObservationGeneration - let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") - let protectedWorkspaceTooltip = String( - localized: "sidebar.pinnedWorkspaceProtected.tooltip", - defaultValue: "Pinned workspace. Closing requires confirmation." - ) - let closeButtonTooltip = tab.isPinned - ? protectedWorkspaceTooltip - : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) - let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") - let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") - let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") - let latestNotificationSubtitle = latestNotificationText - let effectiveSubtitle = latestNotificationSubtitle - let detailVisibility = visibleAuxiliaryDetails - // Read from cache — updated only by the debounced publisher, onAppear, - // and settings changes. Title-keystroke re-renders skip this tree walk. - let orderedPanelIds: [UUID]? = cachedOrderedPanelIds - let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines - let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows - let compactGitBranchSummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - sidebarShowGitBranch, - let orderedPanelIds else { - return nil - } - return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactDirectorySummaryText: String? = { - guard detailVisibility.showsBranchDirectory, - !sidebarBranchVerticalLayout, - let orderedPanelIds else { - return nil - } - return directorySummaryText(orderedPanelIds: orderedPanelIds) - }() - let compactBranchDirectoryRow = branchDirectoryRow( - gitSummary: compactGitBranchSummaryText, - directorySummary: compactDirectorySummaryText - ) - let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } - - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - if unreadCount > 0 { - ZStack { - Circle() - .fill(activeUnreadBadgeFillColor) - Text("\(unreadCount)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.white) - } - .frame(width: 16, height: 16) - } - - if tab.isPinned { - Image(systemName: "pin.fill") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(activeSecondaryColor(0.8)) - .safeHelp(protectedWorkspaceTooltip) - } - - Text(tab.title) - .font(.system(size: 12.5, weight: titleFontWeight)) - .foregroundColor(activePrimaryTextColor) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) - - Spacer(minLength: 0) - - ZStack(alignment: .trailing) { - Button(action: { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - }) { - Image(systemName: "xmark") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activeSecondaryColor(0.7)) - } - .buttonStyle(.plain) - .safeHelp(closeButtonTooltip) - .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) - .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) - .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) - - if showsWorkspaceShortcutHint, let workspaceShortcutLabel { - Text(workspaceShortcutLabel) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundColor(activePrimaryTextColor) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) - .offset( - x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), - y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) - ) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) - .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) - } - - if let description = tab.customDescription { - SidebarWorkspaceDescriptionText( - markdown: description, - isActive: usesInvertedActiveForeground - ) - .id(description) - } - - if let subtitle = effectiveSubtitle { - Text(subtitle) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(2) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - } - - remoteWorkspaceSection - - if detailVisibility.showsMetadata { - let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() - let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() - if !metadataEntries.isEmpty { - SidebarMetadataRows( - entries: metadataEntries, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - if !metadataBlocks.isEmpty { - SidebarMetadataMarkdownBlocks( - blocks: metadataBlocks, - isActive: usesInvertedActiveForeground, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - - // Latest log entry - if detailVisibility.showsLog, let latestLog = tab.logEntries.last { - HStack(spacing: 4) { - Image(systemName: logLevelIcon(latestLog.level)) - .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) - Text(latestLog.message) - .font(.system(size: 10)) - .foregroundColor(activeSecondaryColor(0.8)) - .lineLimit(1) - .truncationMode(.tail) - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Progress bar - if detailVisibility.showsProgress, let progress = tab.progress { - VStack(alignment: .leading, spacing: 2) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(activeProgressTrackColor) - Capsule() - .fill(activeProgressFillColor) - .frame(width: max(0, geo.size.width * CGFloat(progress.value))) - } - } - .frame(height: 3) - - if let label = progress.label { - Text(label) - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - .lineLimit(1) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - - // Branch + directory row - if detailVisibility.showsBranchDirectory { - if sidebarBranchVerticalLayout { - if !branchDirectoryLines.isEmpty { - HStack(alignment: .top, spacing: 3) { - if sidebarShowGitBranchIcon, branchLinesContainBranch { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in - HStack(spacing: 3) { - if let branch = line.branch { - Text(branch) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - if line.branch != nil, line.directory != nil { - Image(systemName: "circle.fill") - .font(.system(size: 3)) - .foregroundColor(activeSecondaryColor(0.6)) - .padding(.horizontal, 1) - } - if let directory = line.directory { - Text(directory) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - } - } - } - } else if let dirRow = compactBranchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(activeSecondaryColor(0.6)) - } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - .truncationMode(.tail) - } - } - } - - // Pull request rows - if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { - VStack(alignment: .leading, spacing: 1) { - ForEach(pullRequestRows) { pullRequest in - Button(action: { - openPullRequestLink(pullRequest.url) - }) { - HStack(spacing: 4) { - PullRequestStatusIcon( - status: pullRequest.status, - color: pullRequestForegroundColor - ) - Text("\(pullRequest.label) #\(pullRequest.number)") - .underline() - .lineLimit(1) - .truncationMode(.tail) - Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) - .lineLimit(1) - Spacer(minLength: 0) - } - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(pullRequestForegroundColor) - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) - } - } - } - - // Ports row - if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { - HStack(spacing: 4) { - ForEach(tab.listeningPorts, id: \.self) { port in - Button(action: { - openPortLink(port) - }) { - Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) - .underline() - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) - } - Spacer(minLength: 0) - } - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(activeSecondaryColor(0.75)) - .lineLimit(1) - } - } - .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) - .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) - .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(backgroundColor) - .overlay { - RoundedRectangle(cornerRadius: 6) - .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) - } - .overlay(alignment: .leading) { - if showsLeadingRail { - Capsule(style: .continuous) - .fill(railColor) - .frame(width: 3) - .padding(.leading, 4) - .padding(.vertical, 5) - .offset(x: -1) - } - } - ) - .padding(.horizontal, 6) - .background { - GeometryReader { proxy in - Color.clear - .onAppear { - rowHeight = max(proxy.size.height, 1) - } - .onChange(of: proxy.size.height) { newHeight in - rowHeight = max(newHeight, 1) - } - } - } - .contentShape(Rectangle()) - .opacity(isBeingDragged ? 0.6 : 1) - .overlay { - MiddleClickCapture { - #if DEBUG - dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") - #endif - tabManager.closeWorkspaceWithConfirmation(tab) - } - } - .overlay(alignment: .top) { - if showsCenteredTopDropIndicator { - Rectangle() - .fill(programaAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) - } - } - .onReceive( - tab.sidebarImmediateObservationPublisher - .receive(on: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=immediate " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - workspaceObservationGeneration &+= 1 - } - .onReceive( - tab.sidebarObservationPublisher - .receive(on: RunLoop.main) - // Prompt-time sidebar telemetry can arrive as a short burst - // (pwd, branch, PR, shell state). Coalesce that burst so the - // row redraws once with the settled state instead of blinking. - .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) - ) { _ in -#if DEBUG - let description = tab.customDescription ?? "" - dlog( - "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + - "source=debounced " + - "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + - "descLen=\((description as NSString).length) " + - "desc=\"\(debugCommandPaletteTextPreview(description))\"" - ) -#endif - // Refresh expensive caches (tree walk, branch/dir/PR) before - // signalling a redraw. The immediate publisher intentionally does - // NOT call this so that title keystrokes skip the tree walk. - recomputeSidebarDetailCache() - workspaceObservationGeneration &+= 1 - } - .onDrag { - #if DEBUG - dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") - #endif - draggedTabId = tab.id - dropIndicator = nil - return SidebarTabDragPayload.provider(for: tab.id) - } - .internalOnlyTabDrag() - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( - targetTabId: tab.id, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - targetRowHeight: rowHeight, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( - targetWorkspaceId: tab.id, - tabManager: tabManager, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex - )) - .onTapGesture { - updateSelection() - } - .onHover { hovering in - isHovering = hovering - } - .accessibilityElement(children: .combine) - .accessibilityLabel(Text(accessibilityTitle)) - .accessibilityHint(Text(accessibilityHintText)) - .accessibilityAction(named: Text(moveUpActionText)) { - moveBy(-1) - } - .accessibilityAction(named: Text(moveDownActionText)) { - moveBy(1) - } - .onAppear { - // Prime the cache so branch/dir/PR rows appear immediately, - // before the first debounced publisher fires. - recomputeSidebarDetailCache() - } - .onChange(of: visibleAuxiliaryDetails) { _ in - // Toggling branch/PR columns changes which data we need to cache. - recomputeSidebarDetailCache() - } - .contextMenu { workspaceContextMenu } - } - - private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { - isMulti ? multi : single - } - - private func remoteContextMenuWorkspaces() -> [Workspace] { - guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } - return remoteContextMenuWorkspaceIds.compactMap { workspaceId in - tabManager.tabs.first(where: { $0.id == workspaceId }) - } - } - - @ViewBuilder - private var workspaceContextMenu: some View { - let targetIds = contextMenuWorkspaceIds - let isMulti = targetIds.count > 1 - let tabColorPalette = WorkspaceTabColorSettings.palette() - let shouldPin = !tab.isPinned - let reconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), - single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), - isMulti: isMulti) - let disconnectLabel = contextMenuLabel( - multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), - single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), - isMulti: isMulti) - let pinLabel = shouldPin - ? contextMenuLabel( - multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), - single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), - isMulti: isMulti) - : contextMenuLabel( - multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), - single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), - isMulti: isMulti) - let closeLabel = contextMenuLabel( - multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), - single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), - isMulti: isMulti) - let markReadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), - single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), - isMulti: isMulti) - let markUnreadLabel = contextMenuLabel( - multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), - single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), - isMulti: isMulti) - let clearLatestNotificationLabel = contextMenuLabel( - multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), - single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), - isMulti: isMulti) - let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) - let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) - let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) - Button(pinLabel) { - for id in targetIds { - if let tab = tabManager.tabs.first(where: { $0.id == id }) { - tabManager.setPinned(tab, pinned: shouldPin) - } - } - syncSelectionAfterMutation() - } - - if let key = renameWorkspaceShortcut.keyEquivalent { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { - promptRename() - } - } - - if tab.hasCustomTitle { - Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { - tabManager.clearCustomTitle(tabId: tab.id) - } - } - - if !isMulti { - if let key = editWorkspaceDescriptionShortcut.keyEquivalent { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) - } else { - Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { - beginWorkspaceDescriptionEditFromContextMenu() - } - } - - if tab.hasCustomDescription { - Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { - tabManager.clearCustomDescription(tabId: tab.id) - } - } - } - - if !remoteContextMenuWorkspaceIds.isEmpty { - Divider() - - Button(reconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.reconnectRemoteConnection() - } - } - .disabled(allRemoteContextMenuTargetsConnecting) - - Button(disconnectLabel) { - for workspace in remoteContextMenuWorkspaces() { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(allRemoteContextMenuTargetsDisconnected) - } - - Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { - if tab.customColor != nil { - Button { - applyTabColor(nil, targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") - } - } - - Button { - promptCustomColor(targetIds: targetIds) - } label: { - Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") - } - - if !tabColorPalette.isEmpty { - Divider() - } - - ForEach(tabColorPalette, id: \.id) { entry in - Button { - applyTabColor(entry.hex, targetIds: targetIds) - } label: { - Label { - Text(entry.name) - } icon: { - Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) - } - } - } - } - - if let copyableSidebarSSHError { - Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { - copyTextToPasteboard(copyableSidebarSSHError) - } - } - - Divider() - - Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { - moveBy(-1) - } - .disabled(index == 0) - - Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { - moveBy(1) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { - tabManager.moveTabsToTop(Set(targetIds)) - syncSelectionAfterMutation() - } - .disabled(targetIds.isEmpty) - - let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) - let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] - let moveMenuTitle = targetIds.count > 1 - ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") - : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") - Menu(moveMenuTitle) { - Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { - moveWorkspacesToNewWindow(targetIds) - } - .disabled(targetIds.isEmpty) - - if !windowMoveTargets.isEmpty { - Divider() - } - - ForEach(windowMoveTargets) { target in - Button(target.label) { - moveWorkspaces(targetIds, toWindow: target.windowId) - } - .disabled(target.isCurrentWindow || targetIds.isEmpty) - } - } - .disabled(targetIds.isEmpty) - - Divider() - - if let key = closeWorkspaceShortcut.keyEquivalent { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) - .disabled(targetIds.isEmpty) - } else { - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) - } - .disabled(targetIds.isEmpty) - } - - Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { - closeOtherTabs(targetIds) - } - .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - - Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { - closeTabsBelow(tabId: tab.id) - } - .disabled(index >= tabManager.tabs.count - 1) - - Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { - closeTabsAbove(tabId: tab.id) - } - .disabled(index == 0) - - Divider() - - Button(markReadLabel) { - markTabsRead(targetIds) - } - .disabled(!hasUnreadNotifications(in: targetIds)) - - Button(markUnreadLabel) { - markTabsUnread(targetIds) - } - .disabled(!hasReadNotifications(in: targetIds)) - - Button(clearLatestNotificationLabel) { - clearLatestNotifications(targetIds) - } - .disabled(!hasLatestNotifications(in: targetIds)) - } - - private var selectionBackgroundColor: NSColor { - if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { - return parsed - } - return programaAccentNSColor(for: colorScheme) - } - - private var backgroundColor: Color { - switch activeTabIndicatorStyle { - case .leftRail: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - case .solidFill: - if isActive { return Color(nsColor: selectionBackgroundColor) } - if let custom = resolvedCustomTabColor { - if isMultiSelected { return custom.opacity(0.35) } - return custom.opacity(0.7) - } - if isMultiSelected { return programaAccentColor().opacity(0.25) } - return Color.clear - } - } - - private var railColor: Color { - explicitRailColor ?? .clear - } - - private var explicitRailColor: Color? { - guard activeTabIndicatorStyle == .leftRail, - let custom = resolvedCustomTabColor else { - return nil - } - return custom.opacity(0.95) - } - - private var resolvedCustomTabColor: Color? { - guard let hex = tab.customColor else { return nil } - return WorkspaceTabColorSettings.displayColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) - } - - private func tabColorSwatchColor(for hex: String) -> NSColor { - WorkspaceTabColorSettings.displayNSColor( - hex: hex, - colorScheme: colorScheme, - forceBright: activeTabIndicatorStyle == .leftRail - ) ?? NSColor(hex: hex) ?? .gray - } - - private var showsCenteredTopDropIndicator: Bool { - guard draggedTabId != nil, let indicator = dropIndicator else { return false } - if indicator.tabId == tab.id && indicator.edge == .top { - return true - } - - guard indicator.edge == .bottom, - let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), - currentIndex > 0 - else { - return false - } - return tabManager.tabs[currentIndex - 1].id == indicator.tabId - } - - private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") - } - - private func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } - tabManager.selectTab(tab) - setSelectionToTabs() - } - - private func updateSelection() { - #if DEBUG - let mods = NSEvent.modifierFlags - var modStr = "" - if mods.contains(.command) { modStr += "cmd " } - if mods.contains(.shift) { modStr += "shift " } - if mods.contains(.option) { modStr += "opt " } - if mods.contains(.control) { modStr += "ctrl " } - dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") - #endif - let modifiers = NSEvent.modifierFlags - let isCommand = modifiers.contains(.command) - let isShift = modifiers.contains(.shift) - let wasSelected = tabManager.selectedTabId == tab.id - - if isShift, let lastIndex = lastSidebarSelectionIndex { - let lower = min(lastIndex, index) - let upper = max(lastIndex, index) - let rangeIds = tabManager.tabs[lower...upper].map { $0.id } - if isCommand { - selectedTabIds.formUnion(rangeIds) - } else { - selectedTabIds = Set(rangeIds) - } - } else if isCommand { - if selectedTabIds.contains(tab.id) { - selectedTabIds.remove(tab.id) - } else { - selectedTabIds.insert(tab.id) - } - } else { - selectedTabIds = [tab.id] - } - - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - if wasSelected, !isCommand, !isShift { - tabManager.dismissNotificationOnDirectInteraction( - tabId: tab.id, - surfaceId: tabManager.focusedSurfaceId(for: tab.id) - ) - } - setSelectionToTabs() - } - - private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { - tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) - syncSelectionAfterMutation() - } - - private func closeOtherTabs(_ targetIds: [UUID]) { - let keepIds = Set(targetIds) - let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsBelow(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func closeTabsAbove(tabId: UUID) { - guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } - closeTabs(idsToClose, allowPinned: true) - } - - private func markTabsRead(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markRead(forTabId: id) - } - } - - private func markTabsUnread(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.markUnread(forTabId: id) - } - } - - private func clearLatestNotifications(_ targetIds: [UUID]) { - for id in targetIds { - notificationStore.clearLatestNotification(forTabId: id) - } - } - - private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } - } - - private func hasReadNotifications(in targetIds: [UUID]) -> Bool { - let targetSet = Set(targetIds) - return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } - } - - private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { - targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } - } - - private func syncSelectionAfterMutation() { - let existingIds = Set(tabManager.tabs.map { $0.id }) - selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } - if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - } - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } - } - - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? String( - localized: "sidebar.remote.help.targetFallback", - defaultValue: "remote host" - ) - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return String( - format: String( - localized: "sidebar.remote.help.connected", - defaultValue: "SSH connected to %@" - ), - locale: .current, - target - ) - case .connecting: - return String( - format: String( - localized: "sidebar.remote.help.connecting", - defaultValue: "SSH connecting to %@" - ), - locale: .current, - target - ) - case .error: - if let detail, !detail.isEmpty { - return String( - format: String( - localized: "sidebar.remote.help.errorWithDetail", - defaultValue: "SSH error for %@: %@" - ), - locale: .current, - target, - detail - ) - } - return String( - format: String( - localized: "sidebar.remote.help.error", - defaultValue: "SSH error for %@" - ), - locale: .current, - target - ) - case .disconnected: - return String( - format: String( - localized: "sidebar.remote.help.disconnected", - defaultValue: "SSH disconnected from %@" - ), - locale: .current, - target - ) - } - } - private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard !orderedWorkspaceIds.isEmpty else { return } - - for (index, workspaceId) in orderedWorkspaceIds.enumerated() { - let shouldFocus = index == orderedWorkspaceIds.count - 1 - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { - guard let app = AppDelegate.shared else { return } - let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } - guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } - - let shouldFocusImmediately = orderedWorkspaceIds.count == 1 - guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { - return - } - - if orderedWorkspaceIds.count > 1 { - for workspaceId in orderedWorkspaceIds.dropFirst() { - _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) - } - if let finalWorkspaceId = orderedWorkspaceIds.last { - _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) - } - } - - selectedTabIds.subtract(orderedWorkspaceIds) - syncSelectionAfterMutation() - } - - // latestNotificationText is now passed as a parameter from the parent view - // to avoid subscribing to notificationStore changes in every TabItemView. - - private func branchDirectoryRow( - gitSummary: String?, - directorySummary: String? - ) -> String? { - var parts: [String] = [] - - if let gitSummary { - parts.append(gitSummary) - } - - if let directorySummary { - parts.append(directorySummary) - } - - let result = parts.joined(separator: " · ") - return result.isEmpty ? nil : result - } - - private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { - let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) - guard !lines.isEmpty else { return nil } - return lines.joined(separator: " | ") - } - - private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { - tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in - "\(branch.branch)\(branch.isDirty ? "*" : "")" - } - } - - private struct VerticalBranchDirectoryLine { - let branch: String? - let directory: String? - } - - /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, - /// branch/directory lines, PR snapshot) and writes them into @State. - /// Must be called only from the debounced publisher, onAppear, and - /// settings-change handlers — never from the immediate (title) publisher. - private func recomputeSidebarDetailCache() { - let detail = visibleAuxiliaryDetails - let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests - let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil - cachedOrderedPanelIds = ids - cachedBranchDirectoryLines = { - guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } - return verticalBranchDirectoryLines(orderedPanelIds: ids) - }() - cachedPullRequestRows = { - guard detail.showsPullRequests, let ids else { return [] } - return pullRequestDisplays(orderedPanelIds: ids) - }() - } - - private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { - let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) - let home = SidebarPathFormatter.homeDirectoryPath - return entries.compactMap { entry in - let branchText: String? = { - guard sidebarShowGitBranch, let branch = entry.branch else { return nil } - return "\(branch)\(entry.isDirty ? "*" : "")" - }() - - let directoryText: String? = { - guard let directory = entry.directory else { return nil } - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - }() - - switch (branchText, directoryText) { - case let (branch?, directory?): - return VerticalBranchDirectoryLine(branch: branch, directory: directory) - case let (branch?, nil): - return VerticalBranchDirectoryLine(branch: branch, directory: nil) - case let (nil, directory?): - return VerticalBranchDirectoryLine(branch: nil, directory: directory) - default: - return nil - } - } - } - - private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { - let home = SidebarPathFormatter.homeDirectoryPath - let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in - let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) - return shortened.isEmpty ? nil : shortened - } - return entries.isEmpty ? nil : entries.joined(separator: " | ") - } - - private struct PullRequestDisplay: Identifiable { - let id: String - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus - let checks: SidebarPullRequestChecksStatus? - } - - private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { - tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in - PullRequestDisplay( - id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", - number: pullRequest.number, - label: pullRequest.label, - url: pullRequest.url, - status: pullRequest.status, - checks: pullRequest.checks - ) - } - } - - private var pullRequestForegroundColor: Color { - isActive ? .white.opacity(0.75) : .secondary - } - - private func openPullRequestLink(_ url: URL) { - updateSelection() - if openSidebarPullRequestLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func openPortLink(_ port: Int) { - guard let url = URL(string: "http://localhost:\(port)") else { return } - updateSelection() - if openSidebarPortLinksInProgramaBrowser { - if tabManager.openBrowser( - inWorkspace: tab.id, - url: url, - preferSplitRight: true, - insertAtEnd: true - ) == nil { - NSWorkspace.shared.open(url) - } - return - } - NSWorkspace.shared.open(url) - } - - private func pullRequestStatusLabel( - _ status: SidebarPullRequestStatus, - checks _: SidebarPullRequestChecksStatus? - ) -> String { - switch status { - case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") - case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") - case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") - } - } - - private func logLevelIcon(_ level: SidebarLogLevel) -> String { - switch level { - case .info: return "circle.fill" - case .progress: return "arrowtriangle.right.fill" - case .success: return "checkmark.circle.fill" - case .warning: return "exclamationmark.triangle.fill" - case .error: return "xmark.circle.fill" - } - } - - private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { - if isActive { - switch level { - case .info: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) - case .progress: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) - case .success: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .warning: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - case .error: - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) - } - } - switch level { - case .info: return .secondary - case .progress: return .blue - case .success: return .green - case .warning: return .orange - case .error: return .red - } - } - - private func shortenPath(_ path: String, home: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return path } - if trimmed == home { - return "~" - } - if trimmed.hasPrefix(home + "/") { - return "~" + trimmed.dropFirst(home.count) - } - return trimmed - } - - private struct PullRequestStatusIcon: View { - let status: SidebarPullRequestStatus - let color: Color - private static let frameSize: CGFloat = 12 - - var body: some View { - switch status { - case .open: - PullRequestOpenIcon(color: color) - case .merged: - PullRequestMergedIcon(color: color) - case .closed: - Image(systemName: "xmark.circle") - .font(.system(size: 7, weight: .regular)) - .foregroundColor(color) - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - } - - private struct PullRequestOpenIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 3.0, y: 4.8)) - path.addLine(to: CGPoint(x: 3.0, y: 9.2)) - - path.move(to: CGPoint(x: 4.8, y: 3.0)) - path.addLine(to: CGPoint(x: 9.4, y: 3.0)) - path.addLine(to: CGPoint(x: 11.0, y: 4.6)) - path.addLine(to: CGPoint(x: 11.0, y: 9.2)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 11.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private struct PullRequestMergedIcon: View { - let color: Color - private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) - private static let nodeDiameter: CGFloat = 3.0 - private static let frameSize: CGFloat = 13 - - var body: some View { - ZStack { - Path { path in - path.move(to: CGPoint(x: 4.6, y: 4.6)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - path.addLine(to: CGPoint(x: 9.2, y: 7.0)) - - path.move(to: CGPoint(x: 4.6, y: 9.4)) - path.addLine(to: CGPoint(x: 7.1, y: 7.0)) - } - .stroke(color, style: Self.stroke) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 3.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 3.0, y: 11.0) - - Circle() - .stroke(color, lineWidth: Self.stroke.lineWidth) - .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) - .position(x: 11.0, y: 7.0) - } - .frame(width: Self.frameSize, height: Self.frameSize) - } - } - - private func applyTabColor(_ hex: String?, targetIds: [UUID]) { - for targetId in targetIds { - tabManager.setTabColor(tabId: targetId, color: hex) - } - } - - private func promptCustomColor(targetIds: [UUID]) { - let alert = NSAlert() - alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") - alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") - - let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" - let input = NSTextField(string: seed) - input.placeholderString = "#1565C0" - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) - alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) - - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { - showInvalidColorAlert(input.stringValue) - return - } - applyTabColor(normalized, targetIds: targetIds) - } - - private func showInvalidColorAlert(_ value: String) { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") - } else { - alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") - } - alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) - _ = alert.runModal() - } - - private func promptRename() { - let alert = NSAlert() - alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") - alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") - let input = NSTextField(string: tab.customTitle ?? tab.title) - input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") - input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) - alert.accessoryView = input - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) - let alertWindow = alert.window - alertWindow.initialFirstResponder = input - DispatchQueue.main.async { - alertWindow.makeFirstResponder(input) - input.selectText(nil) - } - let response = alert.runModal() - guard response == .alertFirstButtonReturn else { return } - tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) - } - - private func beginWorkspaceDescriptionEditFromContextMenu() { - selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index - tabManager.selectTab(tab) - setSelectionToTabs() - _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() - } -} - -private struct SidebarWorkspaceDescriptionText: View { - let markdown: String - let isActive: Bool - - var body: some View { - let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - } else { - Text(markdown) - } - } - .font(.system(size: 10.5)) - .foregroundColor(foregroundColor) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("SidebarWorkspaceDescriptionText") - .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) - .onAppear { -#if DEBUG - let newlineCount = markdown.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=appear " + - "len=\((markdown as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(markdown))\"" - ) -#endif - } - .onChange(of: markdown) { newValue in -#if DEBUG - let newlineCount = newValue.reduce(into: 0) { count, character in - if character == "\n" { count += 1 } - } - dlog( - "sidebar.description.render workspaceState=change " + - "len=\((newValue as NSString).length) " + - "newlines=\(newlineCount) " + - "text=\"\(debugCommandPaletteTextPreview(newValue))\"" - ) -#endif - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) - } - - private func accessibilityText(renderedMarkdown: AttributedString?) -> String { - if let renderedMarkdown { - return String(renderedMarkdown.characters) - } - return markdown - } -} - -enum SidebarMarkdownRenderer { - static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { - try? AttributedString( - markdown: markdown, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) - } -} - -private struct SidebarMetadataRows: View { - let entries: [SidebarStatusEntry] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .safeHelp(helpText) - } - - private var activeSecondaryTextColor: Color { - Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) - } - - private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) - } - - private var helpText: String { - entries.map { entry in - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? entry.key : trimmed - } - .joined(separator: "\n") - } - - private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit - } -} - -private struct SidebarMetadataEntryRow: View { - let entry: SidebarStatusEntry - let isActive: Bool - let onFocus: () -> Void - - var body: some View { - Group { - if let url = entry.url { - Button { - onFocus() - NSWorkspace.shared.open(url) - } label: { - rowContent(underlined: true) - } - .buttonStyle(.plain) - .safeHelp(url.absoluteString) - } else { - rowContent(underlined: false) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - } - } - } - - @ViewBuilder - private func rowContent(underlined: Bool) -> some View { - HStack(spacing: 4) { - if let icon = iconView { - icon - .foregroundColor(foregroundColor.opacity(0.95)) - } - metadataText(underlined: underlined) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } - .font(.system(size: 10)) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var foregroundColor: Color { - if isActive, - let raw = entry.color, - Color(hex: raw) != nil { - return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) - } - if let raw = entry.color, let explicit = Color(hex: raw) { - return explicit - } - return isActive ? .white.opacity(0.8) : .secondary - } - - private var iconView: AnyView? { - guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), - !iconRaw.isEmpty else { - return nil - } - if iconRaw.hasPrefix("emoji:") { - let value = String(iconRaw.dropFirst("emoji:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 9))) - } - if iconRaw.hasPrefix("text:") { - let value = String(iconRaw.dropFirst("text:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) - } - let symbolName: String - if iconRaw.hasPrefix("sf:") { - symbolName = String(iconRaw.dropFirst("sf:".count)) - } else { - symbolName = iconRaw - } - guard !symbolName.isEmpty else { return nil } - return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) - } - - @ViewBuilder - private func metadataText(underlined: Bool) -> some View { - let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - let display = trimmed.isEmpty ? entry.key : trimmed - if entry.format == .markdown, - let attributed = try? AttributedString( - markdown: display, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - Text(attributed) - .underline(underlined) - .foregroundColor(foregroundColor) - } else { - Text(display) - .underline(underlined) - .foregroundColor(foregroundColor) - } - } -} - -private struct SidebarMetadataMarkdownBlocks: View { - let blocks: [SidebarMetadataBlock] - let isActive: Bool - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedBlockLimit = 1 - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(visibleBlocks, id: \.key) { block in - SidebarMetadataMarkdownBlockRow( - block: block, - isActive: isActive, - onFocus: onFocus - ) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - - private var visibleBlocks: [SidebarMetadataBlock] { - guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } - return Array(blocks.prefix(collapsedBlockLimit)) - } - - private var shouldShowToggle: Bool { - blocks.count > collapsedBlockLimit - } -} - -private struct SidebarMetadataMarkdownBlockRow: View { - let block: SidebarMetadataBlock - let isActive: Bool - let onFocus: () -> Void - - @State private var renderedMarkdown: AttributedString? - - var body: some View { - Group { - if let renderedMarkdown { - Text(renderedMarkdown) - .foregroundColor(foregroundColor) - } else { - Text(block.markdown) - .foregroundColor(foregroundColor) - } - } - .font(.system(size: 10)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - .onAppear(perform: renderMarkdown) - .onChange(of: block.markdown) { _ in - renderMarkdown() - } - } - - private var foregroundColor: Color { - isActive ? .white.opacity(0.8) : .secondary - } - - private func renderMarkdown() { - renderedMarkdown = try? AttributedString( - markdown: block.markdown, - options: .init(interpretedSyntax: .full) - ) - } -} - -enum SidebarDropEdge: Equatable { - case top - case bottom -} - -struct SidebarDropIndicator: Equatable { - let tabId: UUID? - let edge: SidebarDropEdge -} - -enum SidebarDropPlanner { - static func indicator( - draggedTabId: UUID?, - targetTabId: UUID?, - tabIds: [UUID], - pinnedTabIds: Set, - pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil - ) -> SidebarDropIndicator? { - guard tabIds.count > 1, let draggedTabId else { return nil } - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge: SidebarDropEdge - if let pointerY, let targetHeight { - edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) - } else { - edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - } - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - let legalTargetIndex = resolvedTargetIndex( - from: fromIndex, - insertionPosition: legalInsertionPosition, - totalCount: tabIds.count - ) - guard legalTargetIndex != fromIndex else { return nil } - return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) - } - - static func targetIndex( - draggedTabId: UUID, - targetTabId: UUID?, - indicator: SidebarDropIndicator?, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int? { - guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } - - let insertionPosition: Int - if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { - insertionPosition = indicatorInsertion - } else if let targetTabId { - guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } - let edge = (indicator?.tabId == targetTabId) - ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) - : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) - insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - } else { - insertionPosition = tabIds.count - } - - let legalInsertionPosition = legalInsertionPosition( - draggedTabId: draggedTabId, - proposedInsertionPosition: insertionPosition, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) - return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) - } - - private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { - let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) - if clampedInsertion >= tabIds.count { - return SidebarDropIndicator(tabId: nil, edge: .bottom) - } - return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) - } - - private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { - if let tabId = indicator.tabId { - guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } - return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex - } - return tabIds.count - } - - private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { - guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } - return fromIndex < targetIndex ? .bottom : .top - } - - private static func legalInsertionPosition( - draggedTabId: UUID, - proposedInsertionPosition: Int, - tabIds: [UUID], - pinnedTabIds: Set - ) -> Int { - let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) - guard !pinnedTabIds.isEmpty else { return clampedInsertion } - - let pinnedCount = tabIds.reduce(into: 0) { count, tabId in - if pinnedTabIds.contains(tabId) { - count += 1 - } - } - guard pinnedCount > 0 else { return clampedInsertion } - - if pinnedTabIds.contains(draggedTabId) { - return min(clampedInsertion, pinnedCount) - } - return max(clampedInsertion, pinnedCount) - } - - static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { - guard targetHeight > 0 else { return .top } - let clampedY = min(max(locationY, 0), targetHeight) - return clampedY < (targetHeight / 2) ? .top : .bottom - } - - private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { - let clampedInsertion = max(0, min(insertionPosition, totalCount)) - let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion - return max(0, min(adjusted, max(0, totalCount - 1))) - } -} - -enum SidebarAutoScrollDirection: Equatable { - case up - case down -} - -struct SidebarAutoScrollPlan: Equatable { - let direction: SidebarAutoScrollDirection - let pointsPerTick: CGFloat -} - -enum SidebarDragAutoScrollPlanner { - static let edgeInset: CGFloat = 44 - static let minStep: CGFloat = 2 - static let maxStep: CGFloat = 12 - - static func plan( - distanceToTop: CGFloat, - distanceToBottom: CGFloat, - edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, - minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, - maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep - ) -> SidebarAutoScrollPlan? { - guard edgeInset > 0, maxStep >= minStep else { return nil } - if distanceToTop <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) - } - if distanceToBottom <= edgeInset { - let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) - let step = minStep + ((maxStep - minStep) * normalized) - return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) - } - return nil - } -} - -@MainActor -private final class SidebarDragAutoScrollController: ObservableObject { - private weak var scrollView: NSScrollView? - private var timer: Timer? - private var activePlan: SidebarAutoScrollPlan? - - func attach(scrollView: NSScrollView?) { - self.scrollView = scrollView - } - - func updateFromDragLocation() { - guard let scrollView else { - stop() - return - } - guard let plan = plan(for: scrollView) else { - stop() - return - } - activePlan = plan - startTimerIfNeeded() - } - - func stop() { - timer?.invalidate() - timer = nil - activePlan = nil - } - - private func startTimerIfNeeded() { - guard timer == nil else { return } - let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .eventTracking) - } - - private func tick() { - guard NSEvent.pressedMouseButtons != 0 else { - stop() - return - } - guard let scrollView else { - stop() - return - } - - // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) - // when periodic drag updates are available; use it first. - if applyNativeAutoscroll(to: scrollView) { - activePlan = plan(for: scrollView) - if activePlan == nil { - stop() - } - return - } - - activePlan = self.plan(for: scrollView) - guard let plan = activePlan else { - stop() - return - } - _ = apply(plan: plan, to: scrollView) - } - - private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { - guard let event = NSApp.currentEvent else { return false } - switch event.type { - case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: - break - default: - return false - } - - let clipView = scrollView.contentView - let didScroll = clipView.autoscroll(with: event) - if didScroll { - scrollView.reflectScrolledClipView(clipView) - } - return didScroll - } - - private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { - if isFlipped { - return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) - } - return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) - } - - private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { - let viewportHeight = clipView.bounds.height - guard viewportHeight > 0 else { return nil } - - let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) - return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) - } - - private func mousePoint(in clipView: NSClipView) -> CGPoint { - let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero - return clipView.convert(mouseInWindow, from: nil) - } - - private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - let clipView = scrollView.contentView - let mouse = mousePoint(in: clipView) - return planForMousePoint(mouse, in: clipView) - } - - private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { - currentPlan(for: scrollView) - } - - private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - let clipView = scrollView.contentView - let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) - guard maxOriginY > 0 else { return false } - - let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 - let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 - let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick - let currentY = clipView.bounds.origin.y - let targetY = min(max(currentY + delta, 0), maxOriginY) - guard abs(targetY - currentY) > 0.01 else { return false } - - clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) - scrollView.reflectScrolledClipView(clipView) - return true - } -} - -private enum SidebarTabDragPayload { - static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let prefix = "programa.sidebar-tab." - - static func provider(for tabId: UUID) -> NSItemProvider { - let provider = NSItemProvider() - let payload = "\(prefix)\(tabId.uuidString)" - provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in - completion(payload.data(using: .utf8), nil) - return nil - } - return provider - } -} - -private enum BonsplitTabDragPayload { - static let typeIdentifier = "com.splittabbar.tabtransfer" - static let dropContentType = UTType(exportedAs: typeIdentifier) - static let dropContentTypes: [UTType] = [dropContentType] - private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) - - struct Transfer: Decodable { - struct TabInfo: Decodable { - let id: UUID - } - - let tab: TabInfo - let sourcePaneId: UUID - let sourceProcessId: Int32 - - private enum CodingKeys: String, CodingKey { - case tab - case sourcePaneId - case sourceProcessId - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.tab = try container.decode(TabInfo.self, forKey: .tab) - self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) - // Legacy payloads won't include this field. Treat as foreign process. - self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 - } - } - - private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { - transfer.sourceProcessId == currentProcessId - } - - static func currentTransfer() -> Transfer? { - let pasteboard = NSPasteboard(name: .drag) - let type = NSPasteboard.PasteboardType(typeIdentifier) - - if let data = pasteboard.data(forType: type), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - if let raw = pasteboard.string(forType: type), - let data = raw.data(using: .utf8), - let transfer = try? JSONDecoder().decode(Transfer.self, from: data), - isCurrentProcessTransfer(transfer) { - return transfer - } - - return nil - } -} - -private struct SidebarBonsplitTabDropDelegate: DropDelegate { - let targetWorkspaceId: UUID - let tabManager: TabManager - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - - func validateDrop(info: DropInfo) -> Bool { - guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } - return BonsplitTabDragPayload.currentTransfer() != nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard validateDrop(info: info) else { return nil } - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - guard validateDrop(info: info), - let transfer = BonsplitTabDragPayload.currentTransfer(), - let app = AppDelegate.shared else { - return false - } - - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == targetWorkspaceId { - syncSidebarSelection() - return true - } - - guard app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: targetWorkspaceId, - focus: true, - focusWindow: true - ) else { - return false - } - - selectedTabIds = [targetWorkspaceId] - syncSidebarSelection() - return true - } - - private func syncSidebarSelection() { - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } -} - -private struct SidebarTabDropDelegate: DropDelegate { - let targetTabId: UUID? - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let targetRowHeight: CGFloat? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let hasDrag = draggedTabId != nil - #if DEBUG - dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") - #endif - return hasType && hasDrag - } - - func dropEntered(info: DropInfo) { - #if DEBUG - dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) - } - - func dropExited(info: DropInfo) { -#if DEBUG - dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") -#endif - if dropIndicator?.tabId == targetTabId { - dropIndicator = nil - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) -#if DEBUG - dlog( - "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + - "indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - #if DEBUG - dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - guard let draggedTabId else { -#if DEBUG - dlog("sidebar.drop.abort reason=missingDraggedTab") -#endif - return false - } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { -#if DEBUG - dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - let tabIds = tabManager.tabs.map(\.id) - guard let targetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - indicator: dropIndicator, - tabIds: tabIds, - pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) else { -#if DEBUG - dlog( - "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + - "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return false - } - - guard fromIndex != targetIndex else { -#if DEBUG - dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") -#endif - syncSidebarSelection() - return true - } - -#if DEBUG - dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") -#endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - syncSidebarSelection(preferredSelectedTabId: selectedId) - } else { - selectedTabIds = [] - syncSidebarSelection() - } - return true - } - - private func updateDropIndicator(for info: DropInfo) { - let tabIds = tabManager.tabs.map(\.id) - let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - let nextIndicator = SidebarDropPlanner.indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: targetTabId == nil ? nil : info.location.y, - targetHeight: targetRowHeight - ) - guard dropIndicator != nextIndicator else { return } - dropIndicator = nextIndicator - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - } - - private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { - guard let indicator else { return "nil" } - let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" - return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" - } -} - -private struct MiddleClickCapture: NSViewRepresentable { - let onMiddleClick: () -> Void - - func makeNSView(context: Context) -> MiddleClickCaptureView { - let view = MiddleClickCaptureView() - view.onMiddleClick = onMiddleClick - return view - } - - func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { - nsView.onMiddleClick = onMiddleClick - } -} - -private final class MiddleClickCaptureView: NSView { - var onMiddleClick: (() -> Void)? - - override func hitTest(_ point: NSPoint) -> NSView? { - // Only intercept middle-click so left-click selection and right-click context menus - // continue to hit-test through to SwiftUI/AppKit normally. - guard let event = NSApp.currentEvent, - event.type == .otherMouseDown, - event.buttonNumber == 2 else { - return nil - } - return self - } - - override func otherMouseDown(with event: NSEvent) { - guard event.buttonNumber == 2 else { - super.otherMouseDown(with: event) - return - } - onMiddleClick?() - } -} - -enum SidebarSelection { - case tabs - case notifications -} - -private struct ClearScrollBackground: ViewModifier { - func body(content: Content) -> some View { - content - .scrollContentBackground(.hidden) - .background(ScrollBackgroundClearer()) - } -} - -private struct ScrollBackgroundClearer: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - NSView() - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let scrollView = findScrollView(startingAt: nsView) else { return } - // Clear all backgrounds and mark as non-opaque for transparency - scrollView.drawsBackground = false - scrollView.backgroundColor = .clear - scrollView.wantsLayer = true - scrollView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.layer?.isOpaque = false - - scrollView.contentView.drawsBackground = false - scrollView.contentView.backgroundColor = .clear - scrollView.contentView.wantsLayer = true - scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor - scrollView.contentView.layer?.isOpaque = false - - if let docView = scrollView.documentView { - docView.wantsLayer = true - docView.layer?.backgroundColor = NSColor.clear.cgColor - docView.layer?.isOpaque = false - } - } - } - - private func findScrollView(startingAt view: NSView) -> NSScrollView? { - var current: NSView? = view - while let candidate = current { - if let scrollView = candidate as? NSScrollView { - return scrollView - } - current = candidate.superview - } - return nil - } -} - -private struct DraggableFolderIcon: View { - let directory: String - - var body: some View { - DraggableFolderIconRepresentable(directory: directory) - .frame(width: 16, height: 16) - .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) - .onTapGesture(count: 2) { - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) - } - } -} - -private struct DraggableFolderIconRepresentable: NSViewRepresentable { - let directory: String - - func makeNSView(context: Context) -> DraggableFolderNSView { - DraggableFolderNSView(directory: directory) - } - - func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { - nsView.directory = directory - nsView.updateIcon() - } -} - -final class DraggableFolderNSView: NSView, NSDraggingSource { - private final class FolderIconImageView: NSImageView { - override var mouseDownCanMoveWindow: Bool { false } - } - - var directory: String - private var imageView: FolderIconImageView! - private var previousWindowMovableState: Bool? - private weak var suppressedWindow: NSWindow? - private var hasActiveDragSession = false - private var didArmWindowDragSuppression = false - - private func formatPoint(_ point: NSPoint) -> String { - String(format: "(%.1f,%.1f)", point.x, point.y) - } - - init(directory: String) { - self.directory = directory - super.init(frame: .zero) - setupImageView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var intrinsicContentSize: NSSize { - NSSize(width: 16, height: 16) - } - - override var mouseDownCanMoveWindow: Bool { false } - - private func setupImageView() { - imageView = FolderIconImageView() - imageView.imageScaling = .scaleProportionallyDown - imageView.translatesAutoresizingMaskIntoConstraints = false - addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.leadingAnchor.constraint(equalTo: leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: trailingAnchor), - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.widthAnchor.constraint(equalToConstant: 16), - imageView.heightAnchor.constraint(equalToConstant: 16), - ]) - updateIcon() - } - - func updateIcon() { - let icon = NSWorkspace.shared.icon(forFile: directory) - icon.size = NSSize(width: 16, height: 16) - imageView.image = icon - } - - func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { - return context == .outsideApplication ? [.copy, .link] : .copy - } - - func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { - hasActiveDragSession = false - restoreWindowMovableStateIfNeeded() - #if DEBUG - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard bounds.contains(point) else { return nil } - let hit = super.hitTest(point) - #if DEBUG - let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" - let imageHit = (hit === imageView) - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") - #endif - return self - } - - override func mouseDown(with event: NSEvent) { - maybeDisableWindowDraggingEarly(trigger: "mouseDown") - hasActiveDragSession = false - #if DEBUG - let localPoint = convert(event.locationInWindow, from: nil) - let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = window.map { String($0.isMovable) } ?? "nil" - let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" - dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") - #endif - let fileURL = URL(fileURLWithPath: directory) - let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) - - let iconImage = NSWorkspace.shared.icon(forFile: directory) - iconImage.size = NSSize(width: 32, height: 32) - draggingItem.setDraggingFrame(bounds, contents: iconImage) - - let session = beginDraggingSession(with: [draggingItem], event: event, source: self) - hasActiveDragSession = true - #if DEBUG - let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 - dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") - #endif - } - - override func mouseUp(with event: NSEvent) { - super.mouseUp(with: event) - // Always restore suppression on mouse-up; drag-session callbacks can be - // skipped for non-started drags, which would otherwise leave suppression stuck. - restoreWindowMovableStateIfNeeded() - } - - override func rightMouseDown(with event: NSEvent) { - let menu = buildPathMenu() - // Pop up menu at bottom-left of icon (like native proxy icon) - let menuLocation = NSPoint(x: 0, y: bounds.height) - menu.popUp(positioning: nil, at: menuLocation, in: self) - } - - private func buildPathMenu() -> NSMenu { - let menu = NSMenu() - let url = URL(fileURLWithPath: directory).standardized - var pathComponents: [URL] = [] - - // Build path from current directory up to root - var current = url - while current.path != "/" { - pathComponents.append(current) - current = current.deletingLastPathComponent() - } - pathComponents.append(URL(fileURLWithPath: "/")) - - // Add path components (current dir at top, root at bottom - matches native macOS) - for pathURL in pathComponents { - let icon = NSWorkspace.shared.icon(forFile: pathURL.path) - icon.size = NSSize(width: 16, height: 16) - - let displayName: String - if pathURL.path == "/" { - // Use the volume name for root - if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { - displayName = volumeName - } else { - displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") - } - } else { - displayName = FileManager.default.displayName(atPath: pathURL.path) - } - - let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") - item.target = self - item.image = icon - item.representedObject = pathURL - menu.addItem(item) - } - - // Add computer name at the bottom (like native proxy icon) - let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() - computerIcon.size = NSSize(width: 16, height: 16) - - let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") - computerItem.target = self - computerItem.image = computerIcon - menu.addItem(computerItem) - - return menu - } - - @objc private func openPathComponent(_ sender: NSMenuItem) { - guard let url = sender.representedObject as? URL else { return } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) - } - - @objc private func openComputer(_ sender: NSMenuItem) { - // Open "Computer" view in Finder (shows all volumes) - NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) - } - - private func restoreWindowMovableStateIfNeeded() { - guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } - let targetWindow = suppressedWindow ?? window - let depthAfter = endWindowDragSuppression(window: targetWindow) - restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) - self.previousWindowMovableState = nil - self.suppressedWindow = nil - self.didArmWindowDragSuppression = false - #if DEBUG - let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" - dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") - #endif - } - - private func maybeDisableWindowDraggingEarly(trigger: String) { - guard !didArmWindowDragSuppression else { return } - guard let eventType = NSApp.currentEvent?.type, - eventType == .leftMouseDown || eventType == .leftMouseDragged else { - return - } - guard let currentWindow = window else { return } - - didArmWindowDragSuppression = true - suppressedWindow = currentWindow - let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 - if currentWindow.isMovable { - previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) - } else { - previousWindowMovableState = nil - } - #if DEBUG - let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" - let nowMovable = String(currentWindow.isMovable) - dlog( - "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" - ) - #endif - } -} - -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { - guard let window else { return nil } - let wasMovable = window.isMovable - if wasMovable { - window.isMovable = false - } - return wasMovable -} - -func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { - guard let window, let previousMovableState else { return } - window.isMovable = previousMovableState -} - -/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested -private struct SidebarVisualEffectBackground: NSViewRepresentable { - let material: NSVisualEffectView.Material - let blendingMode: NSVisualEffectView.BlendingMode - let state: NSVisualEffectView.State - let opacity: Double - let tintColor: NSColor? - let cornerRadius: CGFloat - let preferLiquidGlass: Bool - - init( - material: NSVisualEffectView.Material = .hudWindow, - blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, - state: NSVisualEffectView.State = .active, - opacity: Double = 1.0, - tintColor: NSColor? = nil, - cornerRadius: CGFloat = 0, - preferLiquidGlass: Bool = false - ) { - self.material = material - self.blendingMode = blendingMode - self.state = state - self.opacity = opacity - self.tintColor = tintColor - self.cornerRadius = cornerRadius - self.preferLiquidGlass = preferLiquidGlass - } - - static var liquidGlassAvailable: Bool { - NSClassFromString("NSGlassEffectView") != nil - } - - func makeNSView(context: Context) -> NSView { - // Try NSGlassEffectView if preferred or if we want to test availability - if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { - let glass = glassClass.init(frame: .zero) - glass.autoresizingMask = [.width, .height] - glass.wantsLayer = true - return glass - } - - // Use NSVisualEffectView - let view = NSVisualEffectView() - view.autoresizingMask = [.width, .height] - view.wantsLayer = true - view.layerContentsRedrawPolicy = .onSetNeedsDisplay - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - // Configure based on view type - if nsView.className == "NSGlassEffectView" { - // NSGlassEffectView configuration via private API - nsView.alphaValue = max(0.0, min(1.0, opacity)) - nsView.layer?.cornerRadius = cornerRadius - nsView.layer?.masksToBounds = cornerRadius > 0 - - // Try to set tint color via private selector - if let color = tintColor { - let selector = NSSelectorFromString("setTintColor:") - if nsView.responds(to: selector) { - nsView.perform(selector, with: color) - } - } - } else if let visualEffect = nsView as? NSVisualEffectView { - // NSVisualEffectView configuration - visualEffect.material = material - visualEffect.blendingMode = blendingMode - visualEffect.state = state - visualEffect.alphaValue = max(0.0, min(1.0, opacity)) - visualEffect.layer?.cornerRadius = cornerRadius - visualEffect.layer?.masksToBounds = cornerRadius > 0 - visualEffect.needsDisplay = true - } - } -} - -/// Reads the leading inset required to clear traffic lights + left titlebar accessories. -final class TitlebarLeadingInsetPassthroughView: NSView { - override var mouseDownCanMoveWindow: Bool { false } - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} - -private struct TitlebarLeadingInsetReader: NSViewRepresentable { - @Binding var inset: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = TitlebarLeadingInsetPassthroughView() - view.setFrameSize(.zero) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - DispatchQueue.main.async { - guard let window = nsView.window else { return } - // Start past the traffic lights - var leading: CGFloat = 78 - // Add width of all left-aligned titlebar accessories - for accessory in window.titlebarAccessoryViewControllers - where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { - leading += accessory.view.frame.width - } - leading += 0 - if leading != inset { - inset = leading - } - } - } -} - -/// 1px trailing border on the sidebar, derived from the terminal chrome background -/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: -/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. -private struct SidebarTrailingBorder: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @State private var separatorColor: NSColor = chromeSeparatorColor() - - var body: some View { - if matchTerminalBackground { - Rectangle() - .fill(Color(nsColor: separatorColor)) - .frame(width: 1) - .ignoresSafeArea() - .onAppear { - separatorColor = Self.chromeSeparatorColor() - } - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - separatorColor = Self.chromeSeparatorColor() - } - } - } - - /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. - private static func chromeSeparatorColor() -> NSColor { - let chrome = GhosttyBackgroundTheme.currentColor() - let srgb = chrome.usingColorSpace(.sRGB) ?? chrome - var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 - srgb.getRed(&r, green: &g, blue: &b, alpha: &a) - let luminance = 0.299 * r + 0.587 * g + 0.114 * b - let isLight = luminance > 0.5 - let amount: CGFloat = isLight ? -0.12 : 0.16 - let alpha: CGFloat = isLight ? 0.26 : 0.36 - return NSColor( - red: min(1.0, max(0.0, r + amount)), - green: min(1.0, max(0.0, g + amount)), - blue: min(1.0, max(0.0, b + amount)), - alpha: alpha - ) - } -} - -/// Sidebar background that uses the same technique as TitlebarLayerBackground: -/// fully opaque layer color + layer-level opacity. This matches how the terminal's -/// Metal surface composites its background. -private struct SidebarTerminalBackgroundView: NSViewRepresentable { - let backgroundColor: NSColor - let opacity: CGFloat - - func makeNSView(context: Context) -> NSView { - let view = NSView() - view.wantsLayer = true - view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - view.layer?.opacity = Float(opacity) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor - nsView.layer?.opacity = Float(opacity) - } -} - -private struct SidebarBackdrop: View { - @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false - @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity - @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex - @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? - @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue - @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 - @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 - @Environment(\.colorScheme) private var colorScheme - @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() - - var body: some View { - let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) - - if matchTerminalBackground { - // The terminal background is provided by a single CALayer, so - // the sidebar uses the configured opacity directly. - let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) - return AnyView( - SidebarTerminalBackgroundView( - backgroundColor: GhosttyApp.shared.defaultBackgroundColor, - opacity: alpha - ) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() - } - ) - } - - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active - let resolvedHex: String = { - if colorScheme == .dark, let dark = sidebarTintHexDark { - return dark - } else if colorScheme == .light, let light = sidebarTintHexLight { - return light - } - return sidebarTintHex - }() - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) - let useLiquidGlass = materialOption?.usesLiquidGlass ?? false - let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow - - return AnyView( - ZStack { - if let material = materialOption?.material { - // When using liquidGlass + behindWindow, window handles glass + tint - // Sidebar is fully transparent - if !useWindowLevelGlass { - SidebarVisualEffectBackground( - material: material, - blendingMode: blendingMode, - state: state, - opacity: sidebarBlurOpacity, - tintColor: tintColor, - cornerRadius: cornerRadius, - preferLiquidGlass: useLiquidGlass - ) - // Tint overlay for NSVisualEffectView fallback - if !useLiquidGlass { - Color(nsColor: tintColor) - } - } - } - // When material is none or useWindowLevelGlass, render nothing - } - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - ) - } -} - -enum SidebarMaterialOption: String, CaseIterable, Identifiable { - case none - case liquidGlass // macOS 26+ NSGlassEffectView - case sidebar - case hudWindow - case menu - case popover - case underWindowBackground - case windowBackground - case contentBackground - case fullScreenUI - case sheet - case headerView - case toolTip - - var id: String { rawValue } - - var title: String { - switch self { - case .none: return String(localized: "settings.material.none", defaultValue: "None") - case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") - case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") - case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") - case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") - case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") - case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") - case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") - case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") - case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") - case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") - case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") - case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") - } - } - - /// Returns true if this option should use NSGlassEffectView (macOS 26+) - var usesLiquidGlass: Bool { - self == .liquidGlass - } - - var material: NSVisualEffectView.Material? { - switch self { - case .none: return nil - case .liquidGlass: return .underWindowBackground // Fallback material - case .sidebar: return .sidebar - case .hudWindow: return .hudWindow - case .menu: return .menu - case .popover: return .popover - case .underWindowBackground: return .underWindowBackground - case .windowBackground: return .windowBackground - case .contentBackground: return .contentBackground - case .fullScreenUI: return .fullScreenUI - case .sheet: return .sheet - case .headerView: return .headerView - case .toolTip: return .toolTip - } - } -} - -enum SidebarBlendModeOption: String, CaseIterable, Identifiable { - case behindWindow - case withinWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") - case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") - } - } - - var mode: NSVisualEffectView.BlendingMode { - switch self { - case .behindWindow: return .behindWindow - case .withinWindow: return .withinWindow - } - } -} - -enum SidebarStateOption: String, CaseIterable, Identifiable { - case active - case inactive - case followWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .active: return String(localized: "settings.state.active", defaultValue: "Active") - case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") - case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") - } - } - - var state: NSVisualEffectView.State { - switch self { - case .active: return .active - case .inactive: return .inactive - case .followWindow: return .followsWindowActiveState - } - } -} - -enum SidebarTintDefaults { - static let hex = "#000000" - static let opacity = 0.18 -} - -enum SidebarPresetOption: String, CaseIterable, Identifiable { - case nativeSidebar - case glassBehind - case softBlur - case popoverGlass - case hudGlass - case underWindow - - var id: String { rawValue } - - var title: String { - switch self { - case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") - case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") - case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") - case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") - case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") - case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") - } - } - - var material: SidebarMaterialOption { - switch self { - case .nativeSidebar: return .sidebar - case .glassBehind: return .sidebar - case .softBlur: return .sidebar - case .popoverGlass: return .popover - case .hudGlass: return .hudWindow - case .underWindow: return .underWindowBackground - } - } - - var blendMode: SidebarBlendModeOption { - switch self { - case .nativeSidebar: return .withinWindow - case .glassBehind: return .behindWindow - case .softBlur: return .behindWindow - case .popoverGlass: return .behindWindow - case .hudGlass: return .withinWindow - case .underWindow: return .withinWindow - } - } - - var state: SidebarStateOption { - switch self { - case .nativeSidebar: return .followWindow - case .glassBehind: return .active - case .softBlur: return .active - case .popoverGlass: return .active - case .hudGlass: return .active - case .underWindow: return .followWindow - } - } - - var tintHex: String { - switch self { - case .nativeSidebar: return "#000000" - case .glassBehind: return "#000000" - case .softBlur: return "#000000" - case .popoverGlass: return "#000000" - case .hudGlass: return "#000000" - case .underWindow: return "#000000" - } - } - - var tintOpacity: Double { - switch self { - case .nativeSidebar: return 0.18 - case .glassBehind: return 0.36 - case .softBlur: return 0.28 - case .popoverGlass: return 0.10 - case .hudGlass: return 0.62 - case .underWindow: return 0.14 - } - } - - var cornerRadius: Double { - switch self { - case .nativeSidebar: return 0.0 - case .glassBehind: return 0.0 - case .softBlur: return 0.0 - case .popoverGlass: return 10.0 - case .hudGlass: return 10.0 - case .underWindow: return 6.0 - } - } - - var blurOpacity: Double { - switch self { - case .nativeSidebar: return 1.0 - case .glassBehind: return 0.6 - case .softBlur: return 0.45 - case .popoverGlass: return 0.9 - case .hudGlass: return 0.98 - case .underWindow: return 0.9 - } - } -} - -extension NSColor { - func hexString(includeAlpha: Bool = false) -> String { - let color = usingColorSpace(.sRGB) ?? self - var red: CGFloat = 0 - var green: CGFloat = 0 - var blue: CGFloat = 0 - var alpha: CGFloat = 0 - color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - let redByte = min(255, max(0, Int((red * 255).rounded()))) - let greenByte = min(255, max(0, Int((green * 255).rounded()))) - let blueByte = min(255, max(0, Int((blue * 255).rounded()))) - if includeAlpha { - let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) - return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) - } - return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) - } -} diff --git a/Sources/FileDropOverlayView.swift b/Sources/FileDropOverlayView.swift new file mode 100644 index 00000000000..dcff59c5369 --- /dev/null +++ b/Sources/FileDropOverlayView.swift @@ -0,0 +1,489 @@ +// File-drop overlay NSView, extracted from ContentView.swift (nuclear-review #94.3). +// Pure move — behavior-identical relocation. + +import AppKit +import Bonsplit +import WebKit + + +/// Transparent NSView installed on the window's theme frame (above the NSHostingView) to +/// handle file/URL drags from Finder. Nested NSHostingController layers (created by bonsplit's +/// SinglePaneWrapper) prevent AppKit's NSDraggingDestination routing from reaching deeply +/// embedded terminal views. This overlay sits above the entire content view hierarchy and +/// intercepts file drags, forwarding drops to the GhosttyNSView under the cursor. +/// +/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks, +/// scrolls, and other interactions pass through normally. +final class FileDropOverlayView: NSView { + /// Fallback handler when no terminal is found under the drop point. + var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false + private weak var forwardedMouseDragTarget: NSView? + private var forwardedMouseDragButton: ForwardedMouseDragButton? + /// The WKWebView currently receiving forwarded drag events, so we can + /// synthesize draggingExited/draggingEntered as the cursor moves. + private weak var activeDragWebView: WKWebView? + /// The WKWebView that accepted prepareForDragOperation so conclude can be + /// delivered to the same browser target after the drop completes. + private weak var preparedDragWebView: WKWebView? + private var lastHitTestLogSignature: String? + private var lastDragRouteLogSignatureByPhase: [String: String] = [:] + + override var acceptsFirstResponder: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + registerForDraggedTypes([.fileURL]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } + + private enum ForwardedMouseDragButton: Equatable { + case left + case right + case other(Int) + } + + private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? { + switch event.type { + case .leftMouseDown, .leftMouseUp, .leftMouseDragged: + return .left + case .rightMouseDown, .rightMouseUp, .rightMouseDragged: + return .right + case .otherMouseDown, .otherMouseUp, .otherMouseDragged: + return .other(Int(event.buttonNumber)) + default: + return nil + } + } + + private func shouldTrackForwardedMouseDragStart(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + return true + default: + return false + } + } + + private func shouldTrackForwardedMouseDragEnd(for eventType: NSEvent.EventType) -> Bool { + switch eventType { + case .leftMouseUp, .rightMouseUp, .otherMouseUp: + return true + default: + return false + } + } + + // MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so + // file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict. + + override func hitTest(_ point: NSPoint) -> NSView? { + let pb = NSPasteboard(name: .drag) + let eventType = NSApp.currentEvent?.type + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay( + pasteboardTypes: pb.types, + eventType: eventType + ) +#if DEBUG + logHitTestDecision( + pasteboardTypes: pb.types, + eventType: eventType, + shouldCapture: shouldCapture + ) +#endif + guard shouldCapture else { return nil } + + return super.hitTest(point) + } + + // MARK: Mouse forwarding — safety net for the rare case where stale drag pasteboard + // data causes hitTest to return self when no drag is actually active. + // We hit-test contentView directly and dispatch to the target rather than using + // window.sendEvent(), which caches the mouse target and causes infinite recursion. + + private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } + guard let window, let contentView = window.contentView else { return } + let eventButton = dragButton(for: event) + + isForwardingMouseEvent = true + isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + + let target: NSView? + if let eventButton, + forwardedMouseDragButton == eventButton, + let activeTarget = forwardedMouseDragTarget, + activeTarget.window != nil { + // Preserve normal AppKit mouse-delivery semantics: once a drag starts, + // keep routing dragged/up events to the original mouseDown target. + target = activeTarget + } else { + let point = contentView.convert(event.locationInWindow, from: nil) + target = contentView.hitTest(point) + } + + guard let target, target !== self else { + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + return + } + + if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton { + forwardedMouseDragTarget = target + forwardedMouseDragButton = eventButton + } + + switch event.type { + case .leftMouseDown: target.mouseDown(with: event) + case .leftMouseUp: target.mouseUp(with: event) + case .leftMouseDragged: target.mouseDragged(with: event) + case .rightMouseDown: target.rightMouseDown(with: event) + case .rightMouseUp: target.rightMouseUp(with: event) + case .rightMouseDragged: target.rightMouseDragged(with: event) + case .otherMouseDown: target.otherMouseDown(with: event) + case .otherMouseUp: target.otherMouseUp(with: event) + case .otherMouseDragged: target.otherMouseDragged(with: event) + case .scrollWheel: target.scrollWheel(with: event) + default: break + } + + if shouldTrackForwardedMouseDragEnd(for: event.type), + let eventButton, + forwardedMouseDragButton == eventButton { + forwardedMouseDragTarget = nil + forwardedMouseDragButton = nil + } + } + + override func mouseDown(with event: NSEvent) { forwardEvent(event) } + override func mouseUp(with event: NSEvent) { forwardEvent(event) } + override func mouseDragged(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDown(with event: NSEvent) { forwardEvent(event) } + override func rightMouseUp(with event: NSEvent) { forwardEvent(event) } + override func rightMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDown(with event: NSEvent) { forwardEvent(event) } + override func otherMouseUp(with event: NSEvent) { forwardEvent(event) } + override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) } + override func scrollWheel(with event: NSEvent) { forwardEvent(event) } + + // MARK: NSDraggingDestination – accept file drops over terminal and browser views. + // + // AppKit sends draggingEntered once when the drag enters this overlay, then + // draggingUpdated as the cursor moves within it. We track which WKWebView (if + // any) is under the cursor and synthesize enter/exit calls so the browser's + // HTML5 drag events (dragenter, dragleave, drop) fire correctly. + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "entered") + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + return updateDragTarget(sender, phase: "updated") + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + preparedDragWebView = nil + if let prev = activeDragWebView { + prev.draggingExited(sender) + activeDragWebView = nil + } + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? (activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "prepare", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.prepareForDragOperation(sender) + } + preparedDragWebView = nil + return hasTerminalTarget + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture + ? (preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation)) + : nil + let terminal = terminalUnderPoint(sender.draggingLocation) + let hasTerminalTarget = terminal != nil +#if DEBUG + logDragRouteDecision( + phase: "perform", + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture else { + preparedDragWebView = nil + activeDragWebView = nil + return false + } + if let webView { + preparedDragWebView = webView + return webView.performDragOperation(sender) + } + preparedDragWebView = nil + activeDragWebView = nil + guard let terminal else { return false } + return terminal.performDragOperation(sender) + } + + override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + defer { + preparedDragWebView = nil + activeDragWebView = nil + } + guard let sender else { return } + let webView = preparedDragWebView ?? activeDragWebView ?? webViewUnderPoint(sender.draggingLocation) + webView?.concludeDragOperation(sender) + } + + private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { + let loc = sender.draggingLocation + let hasLocalDraggingSource = sender.draggingSource != nil + let types = sender.draggingPasteboard.types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination( + pasteboardTypes: types, + hasLocalDraggingSource: hasLocalDraggingSource + ) + let webView = shouldCapture ? webViewUnderPoint(loc) : nil + + if let prev = activeDragWebView, prev !== webView { + prev.draggingExited(sender) + activeDragWebView = nil + } + + if let webView { + if activeDragWebView !== webView { + activeDragWebView = webView + return webView.draggingEntered(sender) + } + return webView.draggingUpdated(sender) + } + + let hasTerminalTarget = terminalUnderPoint(loc) != nil +#if DEBUG + logDragRouteDecision( + phase: phase, + pasteboardTypes: types, + shouldCapture: shouldCapture, + hasLocalDraggingSource: hasLocalDraggingSource, + hasTerminalTarget: hasTerminalTarget + ) +#endif + guard shouldCapture, hasTerminalTarget else { return [] } + return .copy + } + + private func debugPasteboardTypes(_ types: [NSPasteboard.PasteboardType]?) -> String { + guard let types, !types.isEmpty else { return "-" } + return types.map(\.rawValue).joined(separator: ",") + } + + /// Hit-tests the window to find a WKWebView (browser panel) under the cursor. + func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? { + if let window, + let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) { + return portalWebView + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let webView = view as? WKWebView { return webView } + current = view.superview + } + return nil + } + + private func debugTopHitViewForCurrentEvent() -> String { + guard let window, + let currentEvent = NSApp.currentEvent, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return "-" } + + let pointInTheme = themeFrame.convert(currentEvent.locationInWindow, from: nil) + // Don't toggle isHidden here — it triggers setNeedsDisplay which can + // exceed AppKit's display-pass limit during cursor-update display cycles. + guard let hit = themeFrame.hitTest(pointInTheme) else { return "nil" } + var chain: [String] = [] + var current: NSView? = hit + var depth = 0 + while let view = current, depth < 6 { + chain.append(debugHitViewDescriptor(view)) + current = view.superview + depth += 1 + } + return chain.joined(separator: "->") + } + + private func debugHitViewDescriptor(_ view: NSView) -> String { + let className = String(describing: type(of: view)) + let ptr = String(describing: Unmanaged.passUnretained(view).toOpaque()) + let dragTypes = debugRegisteredDragTypes(view) + return "\(className)@\(ptr){dragTypes=\(dragTypes)}" + } + + private func debugRegisteredDragTypes(_ view: NSView) -> String { + let types = view.registeredDraggedTypes + guard !types.isEmpty else { return "-" } + + let interestingTypes = types.filter { type in + let raw = type.rawValue + return raw == NSPasteboard.PasteboardType.fileURL.rawValue + || raw == DragOverlayRoutingPolicy.bonsplitTabTransferType.rawValue + || raw == DragOverlayRoutingPolicy.sidebarTabReorderType.rawValue + || raw.contains("public.text") + || raw.contains("public.url") + || raw.contains("public.data") + } + let selected = interestingTypes.isEmpty ? Array(types.prefix(3)) : interestingTypes + let rendered = selected.map(\.rawValue).joined(separator: ",") + if selected.count < types.count { + return "\(rendered),+\(types.count - selected.count)" + } + return rendered + } + + private func hasRelevantDragTypes(_ types: [NSPasteboard.PasteboardType]?) -> Bool { + guard let types else { return false } + return types.contains(.fileURL) + || types.contains(DragOverlayRoutingPolicy.bonsplitTabTransferType) + || types.contains(DragOverlayRoutingPolicy.sidebarTabReorderType) + } + + private func debugEventName(_ eventType: NSEvent.EventType?) -> String { + guard let eventType else { return "none" } + switch eventType { + case .cursorUpdate: return "cursorUpdate" + case .appKitDefined: return "appKitDefined" + case .systemDefined: return "systemDefined" + case .applicationDefined: return "applicationDefined" + case .periodic: return "periodic" + case .mouseMoved: return "mouseMoved" + case .mouseEntered: return "mouseEntered" + case .mouseExited: return "mouseExited" + case .flagsChanged: return "flagsChanged" + case .leftMouseDown: return "leftMouseDown" + case .leftMouseUp: return "leftMouseUp" + case .leftMouseDragged: return "leftMouseDragged" + case .rightMouseDown: return "rightMouseDown" + case .rightMouseUp: return "rightMouseUp" + case .rightMouseDragged: return "rightMouseDragged" + case .otherMouseDown: return "otherMouseDown" + case .otherMouseUp: return "otherMouseUp" + case .otherMouseDragged: return "otherMouseDragged" + case .scrollWheel: return "scrollWheel" + default: return "other(\(eventType.rawValue))" + } + } + +#if DEBUG + private func logHitTestDecision( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType?, + shouldCapture: Bool + ) { + let isDragEvent = eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + guard shouldCapture || isDragEvent || hasRelevantDragTypes(pasteboardTypes) else { return } + + let signature = "\(shouldCapture ? 1 : 0)|\(debugEventName(eventType))|\(debugPasteboardTypes(pasteboardTypes))" + guard lastHitTestLogSignature != signature else { return } + lastHitTestLogSignature = signature + dlog( + "overlay.fileDrop.hitTest capture=\(shouldCapture ? 1 : 0) " + + "event=\(debugEventName(eventType)) " + + "topHit=\(debugTopHitViewForCurrentEvent()) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } + + private func logDragRouteDecision( + phase: String, + pasteboardTypes: [NSPasteboard.PasteboardType]?, + shouldCapture: Bool, + hasLocalDraggingSource: Bool, + hasTerminalTarget: Bool + ) { + guard shouldCapture || hasRelevantDragTypes(pasteboardTypes) else { return } + let signature = [ + shouldCapture ? "1" : "0", + hasLocalDraggingSource ? "1" : "0", + hasTerminalTarget ? "1" : "0", + debugPasteboardTypes(pasteboardTypes) + ].joined(separator: "|") + guard lastDragRouteLogSignatureByPhase[phase] != signature else { return } + lastDragRouteLogSignatureByPhase[phase] = signature + dlog( + "overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " + + "localSource=\(hasLocalDraggingSource ? 1 : 0) " + + "hasTerminal=\(hasTerminalTarget ? 1 : 0) " + + "types=\(debugPasteboardTypes(pasteboardTypes))" + ) + } +#endif + /// Hit-tests the window to find the GhosttyNSView under the cursor. + func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? { + if let window, + let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) { + return portalTerminal + } + + guard let window, let contentView = window.contentView else { return nil } + isHidden = true + defer { isHidden = false } + let point = contentView.convert(windowPoint, from: nil) + let hitView = contentView.hitTest(point) + + var current: NSView? = hitView + while let view = current { + if let terminal = view as? GhosttyNSView { return terminal } + current = view.superview + } + return nil + } +} diff --git a/Sources/SidebarDragDrop.swift b/Sources/SidebarDragDrop.swift new file mode 100644 index 00000000000..5413e57ab83 --- /dev/null +++ b/Sources/SidebarDragDrop.swift @@ -0,0 +1,926 @@ +// Sidebar drag-and-drop domain, extracted from ContentView.swift (nuclear-review #94.4). +// Pure move: DragOverlayRoutingPolicy, drag payload enums, drop delegates, +// drop edge/indicator/planner, auto-scroll planner, and drag lifecycle/failsafe +// types, consolidated from several non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarDragFailsafeMonitor and SidebarExternalDropOverlay +// were file-private to the old ContentView.swift; widened to internal (dropped +// the `private` modifier) because VerticalTabsSidebar and ContentView, which stay +// in ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +enum DragOverlayRoutingPolicy { + static let bonsplitTabTransferType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer") + static let sidebarTabReorderType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + + static func hasBonsplitTabTransfer(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(bonsplitTabTransferType) + } + + static func hasSidebarTabReorder(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(sidebarTabReorderType) + } + + static func hasFileURL(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool { + guard let pasteboardTypes else { return false } + return pasteboardTypes.contains(.fileURL) + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + hasLocalDraggingSource: Bool + ) -> Bool { + // Local file drags (e.g. in-app draggable folder views) are valid drop + // inputs; rely on explicit non-file drag types below to avoid hijacking + // Bonsplit/sidebar drags. + _ = hasLocalDraggingSource + guard hasFileURL(pasteboardTypes) else { return false } + + // Prefer explicit non-file drag types so stale fileURL entries cannot hijack + // Bonsplit tab drags or sidebar tab reorder drags. + if hasBonsplitTabTransfer(pasteboardTypes) { return false } + if hasSidebarTabReorder(pasteboardTypes) { return false } + return true + } + + static func shouldCaptureFileDropDestination( + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureFileDropDestination( + pasteboardTypes: pasteboardTypes, + hasLocalDraggingSource: false + ) + } + + static func shouldCaptureFileDropOverlay( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard shouldCaptureFileDropDestination(pasteboardTypes: pasteboardTypes) else { return false } + guard isDragMouseEvent(eventType) else { return false } + return true + } + + static func shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: Bool, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + guard hasSidebarDragState else { return false } + return hasSidebarTabReorder(pasteboardTypes) + } + + static func shouldCaptureSidebarExternalOverlay( + draggedTabId: UUID?, + pasteboardTypes: [NSPasteboard.PasteboardType]? + ) -> Bool { + shouldCaptureSidebarExternalOverlay( + hasSidebarDragState: draggedTabId != nil, + pasteboardTypes: pasteboardTypes + ) + } + + static func shouldPassThroughPortalHitTesting( + pasteboardTypes: [NSPasteboard.PasteboardType]?, + eventType: NSEvent.EventType? + ) -> Bool { + guard isPortalDragEvent(eventType) else { return false } + return hasBonsplitTabTransfer(pasteboardTypes) || hasSidebarTabReorder(pasteboardTypes) + } + + private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool { + eventType == .leftMouseDragged + || eventType == .rightMouseDragged + || eventType == .otherMouseDragged + } + + private static func isPortalDragEvent(_ eventType: NSEvent.EventType?) -> Bool { + // Restrict portal pass-through to explicit drag-motion events so stale + // NSPasteboard(name: .drag) types cannot hijack normal pointer input. + guard let eventType else { return false } + switch eventType { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + return true + default: + return false + } + } +} + +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("programa.sidebarDragStateDidChange") + static let requestClear = Notification.Name("programa.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } +} + +@MainActor +final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } + } + + func stop() { + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } + onRequestClear = nil + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + let dragPasteboardTypes = NSPasteboard(name: .drag).types + let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay( + draggedTabId: draggedTabId, + pasteboardTypes: dragPasteboardTypes + ) + Group { + if shouldCapture { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(true) + .onDrop( + of: SidebarTabDragPayload.dropContentTypes, + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } else { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + } + } + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + +@MainActor + +enum SidebarDropEdge: Equatable { + case top + case bottom +} + +struct SidebarDropIndicator: Equatable { + let tabId: UUID? + let edge: SidebarDropEdge +} + +enum SidebarDropPlanner { + static func indicator( + draggedTabId: UUID?, + targetTabId: UUID?, + tabIds: [UUID], + pinnedTabIds: Set, + pointerY: CGFloat? = nil, + targetHeight: CGFloat? = nil + ) -> SidebarDropIndicator? { + guard tabIds.count > 1, let draggedTabId else { return nil } + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge: SidebarDropEdge + if let pointerY, let targetHeight { + edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight) + } else { + edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + } + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } + return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) + } + + static func targetIndex( + draggedTabId: UUID, + targetTabId: UUID?, + indicator: SidebarDropIndicator?, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int? { + guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } + + let insertionPosition: Int + if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) { + insertionPosition = indicatorInsertion + } else if let targetTabId { + guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } + let edge = (indicator?.tabId == targetTabId) + ? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)) + : preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) + insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex + } else { + insertionPosition = tabIds.count + } + + let legalInsertionPosition = legalInsertionPosition( + draggedTabId: draggedTabId, + proposedInsertionPosition: insertionPosition, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds + ) + return resolvedTargetIndex(from: fromIndex, insertionPosition: legalInsertionPosition, totalCount: tabIds.count) + } + + private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator { + let clampedInsertion = max(0, min(insertionPosition, tabIds.count)) + if clampedInsertion >= tabIds.count { + return SidebarDropIndicator(tabId: nil, edge: .bottom) + } + return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top) + } + + private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? { + if let tabId = indicator.tabId { + guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil } + return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex + } + return tabIds.count + } + + private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge { + guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top } + return fromIndex < targetIndex ? .bottom : .top + } + + private static func legalInsertionPosition( + draggedTabId: UUID, + proposedInsertionPosition: Int, + tabIds: [UUID], + pinnedTabIds: Set + ) -> Int { + let clampedInsertion = max(0, min(proposedInsertionPosition, tabIds.count)) + guard !pinnedTabIds.isEmpty else { return clampedInsertion } + + let pinnedCount = tabIds.reduce(into: 0) { count, tabId in + if pinnedTabIds.contains(tabId) { + count += 1 + } + } + guard pinnedCount > 0 else { return clampedInsertion } + + if pinnedTabIds.contains(draggedTabId) { + return min(clampedInsertion, pinnedCount) + } + return max(clampedInsertion, pinnedCount) + } + + static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge { + guard targetHeight > 0 else { return .top } + let clampedY = min(max(locationY, 0), targetHeight) + return clampedY < (targetHeight / 2) ? .top : .bottom + } + + private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int { + let clampedInsertion = max(0, min(insertionPosition, totalCount)) + let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion + return max(0, min(adjusted, max(0, totalCount - 1))) + } +} + +enum SidebarAutoScrollDirection: Equatable { + case up + case down +} + +struct SidebarAutoScrollPlan: Equatable { + let direction: SidebarAutoScrollDirection + let pointsPerTick: CGFloat +} + +enum SidebarDragAutoScrollPlanner { + static let edgeInset: CGFloat = 44 + static let minStep: CGFloat = 2 + static let maxStep: CGFloat = 12 + + static func plan( + distanceToTop: CGFloat, + distanceToBottom: CGFloat, + edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset, + minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep, + maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep + ) -> SidebarAutoScrollPlan? { + guard edgeInset > 0, maxStep >= minStep else { return nil } + if distanceToTop <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step) + } + if distanceToBottom <= edgeInset { + let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset)) + let step = minStep + ((maxStep - minStep) * normalized) + return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step) + } + return nil + } +} + +@MainActor +final class SidebarDragAutoScrollController: ObservableObject { + private weak var scrollView: NSScrollView? + private var timer: Timer? + private var activePlan: SidebarAutoScrollPlan? + + func attach(scrollView: NSScrollView?) { + self.scrollView = scrollView + } + + func updateFromDragLocation() { + guard let scrollView else { + stop() + return + } + guard let plan = plan(for: scrollView) else { + stop() + return + } + activePlan = plan + startTimerIfNeeded() + } + + func stop() { + timer?.invalidate() + timer = nil + activePlan = nil + } + + private func startTimerIfNeeded() { + guard timer == nil else { return } + let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .eventTracking) + } + + private func tick() { + guard NSEvent.pressedMouseButtons != 0 else { + stop() + return + } + guard let scrollView else { + stop() + return + } + + // AppKit drag/drop autoscroll guidance recommends autoscroll(with:) + // when periodic drag updates are available; use it first. + if applyNativeAutoscroll(to: scrollView) { + activePlan = plan(for: scrollView) + if activePlan == nil { + stop() + } + return + } + + activePlan = self.plan(for: scrollView) + guard let plan = activePlan else { + stop() + return + } + _ = apply(plan: plan, to: scrollView) + } + + private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool { + guard let event = NSApp.currentEvent else { return false } + switch event.type { + case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged: + break + default: + return false + } + + let clipView = scrollView.contentView + let didScroll = clipView.autoscroll(with: event) + if didScroll { + scrollView.reflectScrolledClipView(clipView) + } + return didScroll + } + + private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) { + if isFlipped { + return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y) + } + return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y) + } + + private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? { + let viewportHeight = clipView.bounds.height + guard viewportHeight > 0 else { return nil } + + let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped) + return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom) + } + + private func mousePoint(in clipView: NSClipView) -> CGPoint { + let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero + return clipView.convert(mouseInWindow, from: nil) + } + + private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + let clipView = scrollView.contentView + let mouse = mousePoint(in: clipView) + return planForMousePoint(mouse, in: clipView) + } + + private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? { + currentPlan(for: scrollView) + } + + private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool { + guard let documentView = scrollView.documentView else { return false } + let clipView = scrollView.contentView + let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height) + guard maxOriginY > 0 else { return false } + + let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1 + let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1 + let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick + let currentY = clipView.bounds.origin.y + let targetY = min(max(currentY + delta, 0), maxOriginY) + guard abs(targetY - currentY) > 0.01 else { return false } + + clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY)) + scrollView.reflectScrolledClipView(clipView) + return true + } +} + +enum SidebarTabDragPayload { + static let typeIdentifier = "com.darkroom.programa.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let prefix = "programa.sidebar-tab." + + static func provider(for tabId: UUID) -> NSItemProvider { + let provider = NSItemProvider() + let payload = "\(prefix)\(tabId.uuidString)" + provider.registerDataRepresentation(forTypeIdentifier: typeIdentifier, visibility: .ownProcess) { completion in + completion(payload.data(using: .utf8), nil) + return nil + } + return provider + } +} + +enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] + private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) + + struct Transfer: Decodable { + struct TabInfo: Decodable { + let id: UUID + } + + let tab: TabInfo + let sourcePaneId: UUID + let sourceProcessId: Int32 + + private enum CodingKeys: String, CodingKey { + case tab + case sourcePaneId + case sourceProcessId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.tab = try container.decode(TabInfo.self, forKey: .tab) + self.sourcePaneId = try container.decode(UUID.self, forKey: .sourcePaneId) + // Legacy payloads won't include this field. Treat as foreign process. + self.sourceProcessId = try container.decodeIfPresent(Int32.self, forKey: .sourceProcessId) ?? -1 + } + } + + private static func isCurrentProcessTransfer(_ transfer: Transfer) -> Bool { + transfer.sourceProcessId == currentProcessId + } + + static func currentTransfer() -> Transfer? { + let pasteboard = NSPasteboard(name: .drag) + let type = NSPasteboard.PasteboardType(typeIdentifier) + + if let data = pasteboard.data(forType: type), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + if let raw = pasteboard.string(forType: type), + let data = raw.data(using: .utf8), + let transfer = try? JSONDecoder().decode(Transfer.self, from: data), + isCurrentProcessTransfer(transfer) { + return transfer + } + + return nil + } +} + +struct SidebarBonsplitTabDropDelegate: DropDelegate { + let targetWorkspaceId: UUID + let tabManager: TabManager + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + + func validateDrop(info: DropInfo) -> Bool { + guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } + return BonsplitTabDragPayload.currentTransfer() != nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info), + let transfer = BonsplitTabDragPayload.currentTransfer(), + let app = AppDelegate.shared else { + return false + } + + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == targetWorkspaceId { + syncSidebarSelection() + return true + } + + guard app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: targetWorkspaceId, + focus: true, + focusWindow: true + ) else { + return false + } + + selectedTabIds = [targetWorkspaceId] + syncSidebarSelection() + return true + } + + private func syncSidebarSelection() { + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +struct SidebarTabDropDelegate: DropDelegate { + let targetTabId: UUID? + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let targetRowHeight: CGFloat? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + dlog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag + } + + func dropEntered(info: DropInfo) { + #if DEBUG + dlog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif + if dropIndicator?.tabId == targetTabId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + #if DEBUG + dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + let tabIds = tabManager.tabs.map(\.id) + guard let targetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + indicator: dropIndicator, + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return false + } + + guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif + syncSidebarSelection() + return true + } + +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif + _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + syncSidebarSelection(preferredSelectedTabId: selectedId) + } else { + selectedTabIds = [] + syncSidebarSelection() + } + return true + } + + private func updateDropIndicator(for info: DropInfo) { + let tabIds = tabManager.tabs.map(\.id) + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + let nextIndicator = SidebarDropPlanner.indicator( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + pointerY: targetTabId == nil ? nil : info.location.y, + targetHeight: targetRowHeight + ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } +} + diff --git a/Sources/SidebarVisuals.swift b/Sources/SidebarVisuals.swift new file mode 100644 index 00000000000..7ce7bd54a65 --- /dev/null +++ b/Sources/SidebarVisuals.swift @@ -0,0 +1,1533 @@ +// Sidebar visual chrome, extracted from ContentView.swift (nuclear-review #94.5). +// Pure move: sidebar footer/help-menu/scrim/blur/material/tint/preset enums, +// visual-effect/backdrop views, and the NSColor extension, consolidated from two +// non-contiguous locations in ContentView.swift. +// +// Access-level widening: SidebarFooter, SidebarTopScrim, SidebarScrollViewResolver, +// SidebarEmptyArea, ClearScrollBackground, DraggableFolderIcon, +// TitlebarLeadingInsetReader, SidebarTrailingBorder, and SidebarBackdrop were +// file-private to the old ContentView.swift; widened to internal (dropped the +// `private` modifier) because VerticalTabsSidebar and ContentView, which stay in +// ContentView.swift, still construct/reference them. No other behavior change. + +import AppKit +import Bonsplit +import SwiftUI + +struct SidebarFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { +#if DEBUG + SidebarDevFooter(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) +#else + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) +#endif + } +} + +private struct SidebarFooterButtons: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + + var body: some View { + HStack(spacing: 4) { + SidebarHelpMenuButton(onSendFeedback: onSendFeedback) + UpdatePill(model: updateViewModel) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private enum SidebarHelpMenuAction { + case importBrowserData + case keyboardShortcuts + case docs + case changelog + case github + case githubIssues + case discord + case checkForUpdates + case sendFeedback + case welcome +} + +private struct SidebarHelpMenuButton: View { + private let docsURL = URL(string: "https://github.com/darkroomengineering/programa/tree/main/docs") + private let changelogURL = URL(string: "https://github.com/darkroomengineering/programa/blob/main/CHANGELOG.md") + private let githubURL = URL(string: "https://github.com/darkroomengineering/programa") + private let githubIssuesURL = URL(string: "https://github.com/darkroomengineering/programa/issues") + private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ") + private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") + private let buttonSize: CGFloat = 22 + private let iconSize: CGFloat = 11 + @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared + + let onSendFeedback: () -> Void + + @State private var isPopoverPresented = false + + private var sendFeedbackShortcutHint: String { + let _ = keyboardShortcutSettingsObserver.revision + return KeyboardShortcutSettings.shortcut(for: .sendFeedback).displayString + } + + var body: some View { + Button { + isPopoverPresented.toggle() + } label: { + Image(systemName: "questionmark.circle") + .symbolRenderingMode(.monochrome) + .font(.system(size: iconSize, weight: .medium)) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .frame(width: buttonSize, height: buttonSize, alignment: .center) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4 + ) { + helpPopover + }) + .accessibilityElement(children: .ignore) + .safeHelp(helpTitle) + .accessibilityLabel(helpTitle) + .accessibilityIdentifier("SidebarHelpMenuButton") + } + + private var helpPopover: some View { + VStack(alignment: .leading, spacing: 2) { + helpOptionButton( + title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to Programa!"), + action: .welcome, + accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"), + action: .sendFeedback, + accessibilityIdentifier: "SidebarHelpMenuOptionSendFeedback", + isExternalLink: false, + shortcutHint: sendFeedbackShortcutHint, + trailingSystemImage: "bubble.left.and.text.bubble.right" + ) + helpOptionButton( + title: String(localized: "settings.section.keyboardShortcuts", defaultValue: "Keyboard Shortcuts"), + action: .keyboardShortcuts, + accessibilityIdentifier: "SidebarHelpMenuOptionKeyboardShortcuts", + isExternalLink: false + ) + helpOptionButton( + title: String(localized: "menu.view.importFromBrowser", defaultValue: "Import Browser Data…"), + action: .importBrowserData, + accessibilityIdentifier: "SidebarHelpMenuOptionImportBrowserData", + isExternalLink: false + ) + if docsURL != nil { + helpOptionButton( + title: String(localized: "about.docs", defaultValue: "Docs"), + action: .docs, + accessibilityIdentifier: "SidebarHelpMenuOptionDocs", + isExternalLink: true + ) + } + if changelogURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.changelog", defaultValue: "Changelog"), + action: .changelog, + accessibilityIdentifier: "SidebarHelpMenuOptionChangelog", + isExternalLink: true + ) + } + if githubURL != nil { + helpOptionButton( + title: String(localized: "about.github", defaultValue: "GitHub"), + action: .github, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHub", + isExternalLink: true + ) + } + if githubIssuesURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.githubIssues", defaultValue: "GitHub Issues"), + action: .githubIssues, + accessibilityIdentifier: "SidebarHelpMenuOptionGitHubIssues", + isExternalLink: true + ) + } + if discordURL != nil { + helpOptionButton( + title: String(localized: "sidebar.help.discord", defaultValue: "Discord"), + action: .discord, + accessibilityIdentifier: "SidebarHelpMenuOptionDiscord", + isExternalLink: true + ) + } + helpOptionButton( + title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), + action: .checkForUpdates, + accessibilityIdentifier: "SidebarHelpMenuOptionCheckForUpdates", + isExternalLink: false + ) + } + .padding(8) + .frame(minWidth: 200) + } + + private func helpOptionButton( + title: String, + action: SidebarHelpMenuAction, + accessibilityIdentifier: String, + isExternalLink: Bool, + shortcutHint: String? = nil, + trailingSystemImage: String? = nil + ) -> some View { + Button { + isPopoverPresented = false + perform(action) + } label: { + HStack(spacing: 8) { + Text(title) + .font(.system(size: 12)) + Spacer(minLength: 0) + if let shortcutHint { + helpOptionShortcutHint(text: shortcutHint) + } + if let trailingSystemImage { + helpOptionTrailingIcon(systemName: trailingSystemImage) + } + if isExternalLink { + helpOptionTrailingIcon(systemName: "arrow.up.right", size: 8) + } + } + .padding(.horizontal, 8) + .frame(height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier(accessibilityIdentifier) + } + + private func helpOptionShortcutHint(text: String) -> some View { + Text(text) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func helpOptionTrailingIcon(systemName: String, size: CGFloat = 13) -> some View { + Image(systemName: systemName) + .resizable() + .scaledToFit() + .frame(width: size, height: size) + .foregroundStyle(Color(nsColor: .secondaryLabelColor)) + } + + private func perform(_ action: SidebarHelpMenuAction) { + switch action { + case .importBrowserData: + isPopoverPresented = false + DispatchQueue.main.async { + BrowserDataImportCoordinator.shared.presentImportDialog() + } + case .keyboardShortcuts: + isPopoverPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow( + debugSource: "sidebarHelpMenu.keyboardShortcuts", + navigationTarget: .keyboardShortcuts + ) + } else { + AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts) + } + } + } + case .docs: + guard let docsURL else { return } + NSWorkspace.shared.open(docsURL) + case .changelog: + guard let changelogURL else { return } + NSWorkspace.shared.open(changelogURL) + case .github: + guard let githubURL else { return } + NSWorkspace.shared.open(githubURL) + case .githubIssues: + guard let githubIssuesURL else { return } + NSWorkspace.shared.open(githubIssuesURL) + case .discord: + guard let discordURL else { return } + NSWorkspace.shared.open(discordURL) + case .checkForUpdates: + Task { @MainActor in + AppDelegate.shared?.checkForUpdates(nil) + } + case .sendFeedback: + isPopoverPresented = false + onSendFeedback() + case .welcome: + isPopoverPresented = false + Task { @MainActor in + if let appDelegate = AppDelegate.shared { + appDelegate.openWelcomeWorkspace() + } + } + } + } + +} + +private struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + let detachedGap: CGFloat + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + let view = NSView() + context.coordinator.anchorView = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + + if isPresented { + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } + } +} + +private struct SidebarFooterIconButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + SidebarFooterIconButtonStyleBody(configuration: configuration) + } +} + +private struct SidebarFooterIconButtonStyleBody: View { + let configuration: SidebarFooterIconButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +#if DEBUG +private struct SidebarDevFooter: View { + @ObservedObject var updateViewModel: UpdateViewModel + let onSendFeedback: () -> Void + @AppStorage(DevBuildBannerDebugSettings.sidebarBannerVisibleKey) + private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + SidebarFooterButtons(updateViewModel: updateViewModel, onSendFeedback: onSendFeedback) + if showSidebarDevBuildBanner { + Text(String(localized: "debug.devBuildBanner.title", defaultValue: "THIS IS A DEV BUILD")) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + } + } + .padding(.leading, 6) + .padding(.trailing, 10) + .padding(.bottom, 6) + } +} +#endif + +struct SidebarTopScrim: View { + let height: CGFloat + + var body: some View { + SidebarTopBlurEffect() + .frame(height: height) + .mask( + LinearGradient( + colors: [ + Color.black.opacity(0.95), + Color.black.opacity(0.75), + Color.black.opacity(0.35), + Color.clear + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } +} + +private struct SidebarTopBlurEffect: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .withinWindow + view.material = .underWindowBackground + view.state = .active + view.isEmphasized = false + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} + +struct SidebarScrollViewResolver: NSViewRepresentable { + let onResolve: (NSScrollView?) -> Void + + func makeNSView(context: Context) -> SidebarScrollViewResolverView { + let view = SidebarScrollViewResolverView() + view.onResolve = onResolve + return view + } + + func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) { + nsView.onResolve = onResolve + nsView.resolveScrollView() + } +} + +final class SidebarScrollViewResolverView: NSView { + var onResolve: ((NSScrollView?) -> Void)? + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + resolveScrollView() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + resolveScrollView() + } + + func resolveScrollView() { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + onResolve?(self.enclosingScrollView) + } + } +} + +struct SidebarEmptyArea: View { + @EnvironmentObject var tabManager: TabManager + let rowSpacing: CGFloat + @Binding var selection: SidebarSelection + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture(count: 2) { + tabManager.addWorkspace(placementOverride: .end) + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + selection = .tabs + } + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: nil, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .overlay(alignment: .top) { + if shouldShowTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: -(rowSpacing / 2)) + } + } + } + + private var shouldShowTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == nil { + return true + } + guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } + return indicator.tabId == lastTabId + } +} + +enum SidebarPathFormatter { + static let homeDirectoryPath: String = FileManager.default.homeDirectoryForCurrentUser.path + + static func shortenedPath( + _ path: String, + homeDirectoryPath: String = Self.homeDirectoryPath + ) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == homeDirectoryPath { + return "~" + } + if trimmed.hasPrefix(homeDirectoryPath + "/") { + return "~" + trimmed.dropFirst(homeDirectoryPath.count) + } + return trimmed + } +} + +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + +enum SidebarTrailingAccessoryWidthPolicy { + static let closeButtonWidth: CGFloat = 16 + + static func width( + canCloseWorkspace: Bool, + showsWorkspaceShortcutHint: Bool, + workspaceShortcutLabel: String?, + debugXOffset: Double + ) -> CGFloat { + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + return SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: debugXOffset + ) + } + + return canCloseWorkspace ? closeButtonWidth : 0 + } +} + + +struct MiddleClickCapture: NSViewRepresentable { + let onMiddleClick: () -> Void + + func makeNSView(context: Context) -> MiddleClickCaptureView { + let view = MiddleClickCaptureView() + view.onMiddleClick = onMiddleClick + return view + } + + func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) { + nsView.onMiddleClick = onMiddleClick + } +} + +final class MiddleClickCaptureView: NSView { + var onMiddleClick: (() -> Void)? + + override func hitTest(_ point: NSPoint) -> NSView? { + // Only intercept middle-click so left-click selection and right-click context menus + // continue to hit-test through to SwiftUI/AppKit normally. + guard let event = NSApp.currentEvent, + event.type == .otherMouseDown, + event.buttonNumber == 2 else { + return nil + } + return self + } + + override func otherMouseDown(with event: NSEvent) { + guard event.buttonNumber == 2 else { + super.otherMouseDown(with: event) + return + } + onMiddleClick?() + } +} + +enum SidebarSelection { + case tabs + case notifications +} + +struct ClearScrollBackground: ViewModifier { + func body(content: Content) -> some View { + content + .scrollContentBackground(.hidden) + .background(ScrollBackgroundClearer()) + } +} + +private struct ScrollBackgroundClearer: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let scrollView = findScrollView(startingAt: nsView) else { return } + // Clear all backgrounds and mark as non-opaque for transparency + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.wantsLayer = true + scrollView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.layer?.isOpaque = false + + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear + scrollView.contentView.wantsLayer = true + scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor + scrollView.contentView.layer?.isOpaque = false + + if let docView = scrollView.documentView { + docView.wantsLayer = true + docView.layer?.backgroundColor = NSColor.clear.cgColor + docView.layer?.isOpaque = false + } + } + } + + private func findScrollView(startingAt view: NSView) -> NSScrollView? { + var current: NSView? = view + while let candidate = current { + if let scrollView = candidate as? NSScrollView { + return scrollView + } + current = candidate.superview + } + return nil + } +} + +struct DraggableFolderIcon: View { + let directory: String + + var body: some View { + DraggableFolderIconRepresentable(directory: directory) + .frame(width: 16, height: 16) + .safeHelp(String(localized: "sidebar.folderIcon.dragHint", defaultValue: "Drag to open in Finder or another app")) + .onTapGesture(count: 2) { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory) + } + } +} + +private struct DraggableFolderIconRepresentable: NSViewRepresentable { + let directory: String + + func makeNSView(context: Context) -> DraggableFolderNSView { + DraggableFolderNSView(directory: directory) + } + + func updateNSView(_ nsView: DraggableFolderNSView, context: Context) { + nsView.directory = directory + nsView.updateIcon() + } +} + +final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + + var directory: String + private var imageView: FolderIconImageView! + private var previousWindowMovableState: Bool? + private weak var suppressedWindow: NSWindow? + private var hasActiveDragSession = false + private var didArmWindowDragSuppression = false + + private func formatPoint(_ point: NSPoint) -> String { + String(format: "(%.1f,%.1f)", point.x, point.y) + } + + init(directory: String) { + self.directory = directory + super.init(frame: .zero) + setupImageView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 16, height: 16) + } + + override var mouseDownCanMoveWindow: Bool { false } + + private func setupImageView() { + imageView = FolderIconImageView() + imageView.imageScaling = .scaleProportionallyDown + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: trailingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.widthAnchor.constraint(equalToConstant: 16), + imageView.heightAnchor.constraint(equalToConstant: 16), + ]) + updateIcon() + } + + func updateIcon() { + let icon = NSWorkspace.shared.icon(forFile: directory) + icon.size = NSSize(width: 16, height: 16) + imageView.image = icon + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return context == .outsideApplication ? [.copy, .link] : .copy + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() + #if DEBUG + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard bounds.contains(point) else { return nil } + let hit = super.hitTest(point) + #if DEBUG + let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil" + let imageHit = (hit === imageView) + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)") + #endif + return self + } + + override func mouseDown(with event: NSEvent) { + maybeDisableWindowDraggingEarly(trigger: "mouseDown") + hasActiveDragSession = false + #if DEBUG + let localPoint = convert(event.locationInWindow, from: nil) + let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil" + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = window.map { String($0.isMovable) } ?? "nil" + let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" + dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)") + #endif + let fileURL = URL(fileURLWithPath: directory) + let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) + + let iconImage = NSWorkspace.shared.icon(forFile: directory) + iconImage.size = NSSize(width: 32, height: 32) + draggingItem.setDraggingFrame(bounds, contents: iconImage) + + let session = beginDraggingSession(with: [draggingItem], event: event, source: self) + hasActiveDragSession = true + #if DEBUG + let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0 + dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)") + #endif + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + // Always restore suppression on mouse-up; drag-session callbacks can be + // skipped for non-started drags, which would otherwise leave suppression stuck. + restoreWindowMovableStateIfNeeded() + } + + override func rightMouseDown(with event: NSEvent) { + let menu = buildPathMenu() + // Pop up menu at bottom-left of icon (like native proxy icon) + let menuLocation = NSPoint(x: 0, y: bounds.height) + menu.popUp(positioning: nil, at: menuLocation, in: self) + } + + private func buildPathMenu() -> NSMenu { + let menu = NSMenu() + let url = URL(fileURLWithPath: directory).standardized + var pathComponents: [URL] = [] + + // Build path from current directory up to root + var current = url + while current.path != "/" { + pathComponents.append(current) + current = current.deletingLastPathComponent() + } + pathComponents.append(URL(fileURLWithPath: "/")) + + // Add path components (current dir at top, root at bottom - matches native macOS) + for pathURL in pathComponents { + let icon = NSWorkspace.shared.icon(forFile: pathURL.path) + icon.size = NSSize(width: 16, height: 16) + + let displayName: String + if pathURL.path == "/" { + // Use the volume name for root + if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName { + displayName = volumeName + } else { + displayName = String(localized: "sidebar.pathMenu.macintoshHD", defaultValue: "Macintosh HD") + } + } else { + displayName = FileManager.default.displayName(atPath: pathURL.path) + } + + let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "") + item.target = self + item.image = icon + item.representedObject = pathURL + menu.addItem(item) + } + + // Add computer name at the bottom (like native proxy icon) + let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage() + computerIcon.size = NSSize(width: 16, height: 16) + + let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "") + computerItem.target = self + computerItem.image = computerIcon + menu.addItem(computerItem) + + return menu + } + + @objc private func openPathComponent(_ sender: NSMenuItem) { + guard let url = sender.representedObject as? URL else { return } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + } + + @objc private func openComputer(_ sender: NSMenuItem) { + // Open "Computer" view in Finder (shows all volumes) + NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true)) + } + + private func restoreWindowMovableStateIfNeeded() { + guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return } + let targetWindow = suppressedWindow ?? window + let depthAfter = endWindowDragSuppression(window: targetWindow) + restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState) + self.previousWindowMovableState = nil + self.suppressedWindow = nil + self.didArmWindowDragSuppression = false + #if DEBUG + let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil" + dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)") + #endif + } + + private func maybeDisableWindowDraggingEarly(trigger: String) { + guard !didArmWindowDragSuppression else { return } + guard let eventType = NSApp.currentEvent?.type, + eventType == .leftMouseDown || eventType == .leftMouseDragged else { + return + } + guard let currentWindow = window else { return } + + didArmWindowDragSuppression = true + suppressedWindow = currentWindow + let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0 + if currentWindow.isMovable { + previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow) + } else { + previousWindowMovableState = nil + } + #if DEBUG + let wasMovable = previousWindowMovableState.map(String.init) ?? "nil" + let nowMovable = String(currentWindow.isMovable) + dlog( + "folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)" + ) + #endif + } +} + +func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { + guard let window else { return nil } + let wasMovable = window.isMovable + if wasMovable { + window.isMovable = false + } + return wasMovable +} + +func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { + guard let window, let previousMovableState else { return } + window.isMovable = previousMovableState +} + +/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested +private struct SidebarVisualEffectBackground: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + let state: NSVisualEffectView.State + let opacity: Double + let tintColor: NSColor? + let cornerRadius: CGFloat + let preferLiquidGlass: Bool + + init( + material: NSVisualEffectView.Material = .hudWindow, + blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, + state: NSVisualEffectView.State = .active, + opacity: Double = 1.0, + tintColor: NSColor? = nil, + cornerRadius: CGFloat = 0, + preferLiquidGlass: Bool = false + ) { + self.material = material + self.blendingMode = blendingMode + self.state = state + self.opacity = opacity + self.tintColor = tintColor + self.cornerRadius = cornerRadius + self.preferLiquidGlass = preferLiquidGlass + } + + static var liquidGlassAvailable: Bool { + NSClassFromString("NSGlassEffectView") != nil + } + + func makeNSView(context: Context) -> NSView { + // Try NSGlassEffectView if preferred or if we want to test availability + if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type { + let glass = glassClass.init(frame: .zero) + glass.autoresizingMask = [.width, .height] + glass.wantsLayer = true + return glass + } + + // Use NSVisualEffectView + let view = NSVisualEffectView() + view.autoresizingMask = [.width, .height] + view.wantsLayer = true + view.layerContentsRedrawPolicy = .onSetNeedsDisplay + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // Configure based on view type + if nsView.className == "NSGlassEffectView" { + // NSGlassEffectView configuration via private API + nsView.alphaValue = max(0.0, min(1.0, opacity)) + nsView.layer?.cornerRadius = cornerRadius + nsView.layer?.masksToBounds = cornerRadius > 0 + + // Try to set tint color via private selector + if let color = tintColor { + let selector = NSSelectorFromString("setTintColor:") + if nsView.responds(to: selector) { + nsView.perform(selector, with: color) + } + } + } else if let visualEffect = nsView as? NSVisualEffectView { + // NSVisualEffectView configuration + visualEffect.material = material + visualEffect.blendingMode = blendingMode + visualEffect.state = state + visualEffect.alphaValue = max(0.0, min(1.0, opacity)) + visualEffect.layer?.cornerRadius = cornerRadius + visualEffect.layer?.masksToBounds = cornerRadius > 0 + visualEffect.needsDisplay = true + } + } +} + +/// Reads the leading inset required to clear traffic lights + left titlebar accessories. +final class TitlebarLeadingInsetPassthroughView: NSView { + override var mouseDownCanMoveWindow: Bool { false } + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +struct TitlebarLeadingInsetReader: NSViewRepresentable { + @Binding var inset: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = TitlebarLeadingInsetPassthroughView() + view.setFrameSize(.zero) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + guard let window = nsView.window else { return } + // Start past the traffic lights + var leading: CGFloat = 78 + // Add width of all left-aligned titlebar accessories + for accessory in window.titlebarAccessoryViewControllers + where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left { + leading += accessory.view.frame.width + } + leading += 0 + if leading != inset { + inset = leading + } + } + } +} + +/// 1px trailing border on the sidebar, derived from the terminal chrome background +/// using the same logic as bonsplit's TabBarColors.nsColorSeparator: +/// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. +struct SidebarTrailingBorder: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @State private var separatorColor: NSColor = chromeSeparatorColor() + + var body: some View { + if matchTerminalBackground { + Rectangle() + .fill(Color(nsColor: separatorColor)) + .frame(width: 1) + .ignoresSafeArea() + .onAppear { + separatorColor = Self.chromeSeparatorColor() + } + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + separatorColor = Self.chromeSeparatorColor() + } + } + } + + /// Replicates bonsplit TabBarColors.nsColorSeparator derivation from chrome background. + private static func chromeSeparatorColor() -> NSColor { + let chrome = GhosttyBackgroundTheme.currentColor() + let srgb = chrome.usingColorSpace(.sRGB) ?? chrome + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + srgb.getRed(&r, green: &g, blue: &b, alpha: &a) + let luminance = 0.299 * r + 0.587 * g + 0.114 * b + let isLight = luminance > 0.5 + let amount: CGFloat = isLight ? -0.12 : 0.16 + let alpha: CGFloat = isLight ? 0.26 : 0.36 + return NSColor( + red: min(1.0, max(0.0, r + amount)), + green: min(1.0, max(0.0, g + amount)), + blue: min(1.0, max(0.0, b + amount)), + alpha: alpha + ) + } +} + +/// Sidebar background that uses the same technique as TitlebarLayerBackground: +/// fully opaque layer color + layer-level opacity. This matches how the terminal's +/// Metal surface composites its background. +private struct SidebarTerminalBackgroundView: NSViewRepresentable { + let backgroundColor: NSColor + let opacity: CGFloat + + func makeNSView(context: Context) -> NSView { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + view.layer?.opacity = Float(opacity) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.layer?.backgroundColor = backgroundColor.withAlphaComponent(1.0).cgColor + nsView.layer?.opacity = Float(opacity) + } +} + +struct SidebarBackdrop: View { + @AppStorage("sidebarMatchTerminalBackground") private var matchTerminalBackground = false + @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity + @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex + @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? + @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 + @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + @Environment(\.colorScheme) private var colorScheme + @State private var terminalBackgroundColor: NSColor = GhosttyBackgroundTheme.currentColor() + + var body: some View { + let cornerRadius = CGFloat(max(0, sidebarCornerRadius)) + + if matchTerminalBackground { + // The terminal background is provided by a single CALayer, so + // the sidebar uses the configured opacity directly. + let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) + return AnyView( + SidebarTerminalBackgroundView( + backgroundColor: GhosttyApp.shared.defaultBackgroundColor, + opacity: alpha + ) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + terminalBackgroundColor = GhosttyBackgroundTheme.currentColor() + } + ) + } + + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active + let resolvedHex: String = { + if colorScheme == .dark, let dark = sidebarTintHexDark { + return dark + } else if colorScheme == .light, let light = sidebarTintHexLight { + return light + } + return sidebarTintHex + }() + let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + let useLiquidGlass = materialOption?.usesLiquidGlass ?? false + let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow + + return AnyView( + ZStack { + if let material = materialOption?.material { + // When using liquidGlass + behindWindow, window handles glass + tint + // Sidebar is fully transparent + if !useWindowLevelGlass { + SidebarVisualEffectBackground( + material: material, + blendingMode: blendingMode, + state: state, + opacity: sidebarBlurOpacity, + tintColor: tintColor, + cornerRadius: cornerRadius, + preferLiquidGlass: useLiquidGlass + ) + // Tint overlay for NSVisualEffectView fallback + if !useLiquidGlass { + Color(nsColor: tintColor) + } + } + } + // When material is none or useWindowLevelGlass, render nothing + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + ) + } +} + +enum SidebarMaterialOption: String, CaseIterable, Identifiable { + case none + case liquidGlass // macOS 26+ NSGlassEffectView + case sidebar + case hudWindow + case menu + case popover + case underWindowBackground + case windowBackground + case contentBackground + case fullScreenUI + case sheet + case headerView + case toolTip + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return String(localized: "settings.material.none", defaultValue: "None") + case .liquidGlass: return String(localized: "settings.material.liquidGlass", defaultValue: "Liquid Glass (macOS 26+)") + case .sidebar: return String(localized: "settings.material.sidebar", defaultValue: "Sidebar") + case .hudWindow: return String(localized: "settings.material.hudWindow", defaultValue: "HUD Window") + case .menu: return String(localized: "settings.material.menu", defaultValue: "Menu") + case .popover: return String(localized: "settings.material.popover", defaultValue: "Popover") + case .underWindowBackground: return String(localized: "settings.material.underWindow", defaultValue: "Under Window") + case .windowBackground: return String(localized: "settings.material.windowBackground", defaultValue: "Window Background") + case .contentBackground: return String(localized: "settings.material.contentBackground", defaultValue: "Content Background") + case .fullScreenUI: return String(localized: "settings.material.fullScreenUI", defaultValue: "Full Screen UI") + case .sheet: return String(localized: "settings.material.sheet", defaultValue: "Sheet") + case .headerView: return String(localized: "settings.material.headerView", defaultValue: "Header View") + case .toolTip: return String(localized: "settings.material.toolTip", defaultValue: "Tool Tip") + } + } + + /// Returns true if this option should use NSGlassEffectView (macOS 26+) + var usesLiquidGlass: Bool { + self == .liquidGlass + } + + var material: NSVisualEffectView.Material? { + switch self { + case .none: return nil + case .liquidGlass: return .underWindowBackground // Fallback material + case .sidebar: return .sidebar + case .hudWindow: return .hudWindow + case .menu: return .menu + case .popover: return .popover + case .underWindowBackground: return .underWindowBackground + case .windowBackground: return .windowBackground + case .contentBackground: return .contentBackground + case .fullScreenUI: return .fullScreenUI + case .sheet: return .sheet + case .headerView: return .headerView + case .toolTip: return .toolTip + } + } +} + +enum SidebarBlendModeOption: String, CaseIterable, Identifiable { + case behindWindow + case withinWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .behindWindow: return String(localized: "settings.blendMode.behindWindow", defaultValue: "Behind Window") + case .withinWindow: return String(localized: "settings.blendMode.withinWindow", defaultValue: "Within Window") + } + } + + var mode: NSVisualEffectView.BlendingMode { + switch self { + case .behindWindow: return .behindWindow + case .withinWindow: return .withinWindow + } + } +} + +enum SidebarStateOption: String, CaseIterable, Identifiable { + case active + case inactive + case followWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .active: return String(localized: "settings.state.active", defaultValue: "Active") + case .inactive: return String(localized: "settings.state.inactive", defaultValue: "Inactive") + case .followWindow: return String(localized: "settings.state.followWindow", defaultValue: "Follow Window") + } + } + + var state: NSVisualEffectView.State { + switch self { + case .active: return .active + case .inactive: return .inactive + case .followWindow: return .followsWindowActiveState + } + } +} + +enum SidebarTintDefaults { + static let hex = "#000000" + static let opacity = 0.18 +} + +enum SidebarPresetOption: String, CaseIterable, Identifiable { + case nativeSidebar + case glassBehind + case softBlur + case popoverGlass + case hudGlass + case underWindow + + var id: String { rawValue } + + var title: String { + switch self { + case .nativeSidebar: return String(localized: "settings.preset.nativeSidebar", defaultValue: "Native Sidebar") + case .glassBehind: return String(localized: "settings.preset.raycastGray", defaultValue: "Raycast Gray") + case .softBlur: return String(localized: "settings.preset.softBlur", defaultValue: "Soft Blur") + case .popoverGlass: return String(localized: "settings.preset.popoverGlass", defaultValue: "Popover Glass") + case .hudGlass: return String(localized: "settings.preset.hudGlass", defaultValue: "HUD Glass") + case .underWindow: return String(localized: "settings.preset.underWindow", defaultValue: "Under Window") + } + } + + var material: SidebarMaterialOption { + switch self { + case .nativeSidebar: return .sidebar + case .glassBehind: return .sidebar + case .softBlur: return .sidebar + case .popoverGlass: return .popover + case .hudGlass: return .hudWindow + case .underWindow: return .underWindowBackground + } + } + + var blendMode: SidebarBlendModeOption { + switch self { + case .nativeSidebar: return .withinWindow + case .glassBehind: return .behindWindow + case .softBlur: return .behindWindow + case .popoverGlass: return .behindWindow + case .hudGlass: return .withinWindow + case .underWindow: return .withinWindow + } + } + + var state: SidebarStateOption { + switch self { + case .nativeSidebar: return .followWindow + case .glassBehind: return .active + case .softBlur: return .active + case .popoverGlass: return .active + case .hudGlass: return .active + case .underWindow: return .followWindow + } + } + + var tintHex: String { + switch self { + case .nativeSidebar: return "#000000" + case .glassBehind: return "#000000" + case .softBlur: return "#000000" + case .popoverGlass: return "#000000" + case .hudGlass: return "#000000" + case .underWindow: return "#000000" + } + } + + var tintOpacity: Double { + switch self { + case .nativeSidebar: return 0.18 + case .glassBehind: return 0.36 + case .softBlur: return 0.28 + case .popoverGlass: return 0.10 + case .hudGlass: return 0.62 + case .underWindow: return 0.14 + } + } + + var cornerRadius: Double { + switch self { + case .nativeSidebar: return 0.0 + case .glassBehind: return 0.0 + case .softBlur: return 0.0 + case .popoverGlass: return 10.0 + case .hudGlass: return 10.0 + case .underWindow: return 6.0 + } + } + + var blurOpacity: Double { + switch self { + case .nativeSidebar: return 1.0 + case .glassBehind: return 0.6 + case .softBlur: return 0.45 + case .popoverGlass: return 0.9 + case .hudGlass: return 0.98 + case .underWindow: return 0.9 + } + } +} + +extension NSColor { + func hexString(includeAlpha: Bool = false) -> String { + let color = usingColorSpace(.sRGB) ?? self + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let redByte = min(255, max(0, Int((red * 255).rounded()))) + let greenByte = min(255, max(0, Int((green * 255).rounded()))) + let blueByte = min(255, max(0, Int((blue * 255).rounded()))) + if includeAlpha { + let alphaByte = min(255, max(0, Int((alpha * 255).rounded()))) + return String(format: "#%02X%02X%02X%02X", redByte, greenByte, blueByte, alphaByte) + } + return String(format: "#%02X%02X%02X", redByte, greenByte, blueByte) + } +} diff --git a/Sources/TabItemView.swift b/Sources/TabItemView.swift new file mode 100644 index 00000000000..3194f78a2fc --- /dev/null +++ b/Sources/TabItemView.swift @@ -0,0 +1,1965 @@ +// TabItemView + its exclusive helper-view cluster, extracted from ContentView.swift (nuclear-review #94.2). +// Pure move — behavior-identical relocation. Do NOT touch TabItemView's Equatable +// conformance, its precomputed let parameters, or the .equatable() call site in +// VerticalTabsSidebar (ContentView.swift) — see CLAUDE.md typing-latency-sensitive paths. +// +// Access-level widening: TabItemView was `private struct` (file-private to the old +// ContentView.swift); widened to internal (default) so VerticalTabsSidebar, which stays +// in ContentView.swift, can still construct it. No other behavior change. + +import AppKit +import Bonsplit +import Combine +import SwiftUI + + +// PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when +// the parent rebuilds with unchanged values. Without this, every TabManager +// or NotificationStore publish causes ALL tab items to re-evaluate (~18% of +// main thread during typing). If you add new properties, update == below. +// Reactive workspace state inside the row must not rely on parent diffs alone: +// `.equatable()` can otherwise leave sidebar badges/details stale until an +// unrelated parent change sneaks through. Keep the workspace reference plain +// and bridge only sidebar-visible workspace changes into local state. +// Do NOT add @EnvironmentObject or new @Binding without updating ==. +// Do NOT remove .equatable() from the ForEach call site in VerticalTabsSidebar. +struct TabItemView: View, Equatable { + private static let workspaceObservationCoalesceInterval: RunLoop.SchedulerTimeType.Stride = .milliseconds(40) + + // Closures, Bindings, and object references are excluded from == + // because they're recreated every parent eval but don't affect rendering. + nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { + lhs.tab === rhs.tab && + lhs.index == rhs.index && + lhs.isActive == rhs.isActive && + lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && + lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && + lhs.canCloseWorkspace == rhs.canCloseWorkspace && + lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && + lhs.unreadCount == rhs.unreadCount && + lhs.latestNotificationText == rhs.latestNotificationText && + lhs.rowSpacing == rhs.rowSpacing && + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.contextMenuWorkspaceIds == rhs.contextMenuWorkspaceIds && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected && + lhs.settings == rhs.settings + } + + // Use plain references instead of @EnvironmentObject to avoid subscribing + // to ALL changes on these objects. Body reads use precomputed parameters; + // action handlers use the plain references without triggering re-evaluation. + let tabManager: TabManager + let notificationStore: TerminalNotificationStore + @Environment(\.colorScheme) private var colorScheme + let tab: Tab + let index: Int + let isActive: Bool + let workspaceShortcutDigit: Int? + let workspaceShortcutModifierSymbol: String + let canCloseWorkspace: Bool + let accessibilityWorkspaceCount: Int + let unreadCount: Int + let latestNotificationText: String? + let rowSpacing: CGFloat + let setSelectionToTabs: () -> Void + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let showsModifierShortcutHints: Bool + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var draggedTabId: UUID? + @Binding var dropIndicator: SidebarDropIndicator? + let contextMenuWorkspaceIds: [UUID] + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool + let settings: SidebarTabItemSettingsSnapshot + @State private var workspaceObservationGeneration: UInt64 = 0 + @State private var isHovering = false + @State private var rowHeight: CGFloat = 1 + // Cached results of the expensive bonsplit tree walk + branch/dir/PR snapshot. + // Updated only by the debounced publisher, onAppear, and settings changes — + // NOT by the immediate publisher (title keystrokes). This prevents the tree + // walk from running on every keystroke in single-panel workspaces. + @State private var cachedOrderedPanelIds: [UUID]? = nil + @State private var cachedBranchDirectoryLines: [VerticalBranchDirectoryLine] = [] + @State private var cachedPullRequestRows: [PullRequestDisplay] = [] + + var isMultiSelected: Bool { + selectedTabIds.contains(tab.id) + } + + private var isBeingDragged: Bool { + draggedTabId == tab.id + } + + private var sidebarShortcutHintXOffset: Double { + settings.sidebarShortcutHintXOffset + } + + private var sidebarShortcutHintYOffset: Double { + settings.sidebarShortcutHintYOffset + } + + private var alwaysShowShortcutHints: Bool { + settings.alwaysShowShortcutHints + } + + private var sidebarShowGitBranch: Bool { + settings.showsGitBranch + } + + private var sidebarBranchVerticalLayout: Bool { + settings.usesVerticalBranchLayout + } + + private var sidebarShowGitBranchIcon: Bool { + settings.showsGitBranchIcon + } + + private var sidebarShowSSH: Bool { + settings.showsSSH + } + + private var activeTabIndicatorStyle: SidebarActiveTabIndicatorStyle { + settings.activeTabIndicatorStyle + } + + private var sidebarSelectionColorHex: String? { + settings.selectionColorHex + } + + private var sidebarNotificationBadgeColorHex: String? { + settings.notificationBadgeColorHex + } + + private var openSidebarPullRequestLinksInProgramaBrowser: Bool { + settings.openPullRequestLinksInProgramaBrowser + } + + private var openSidebarPortLinksInProgramaBrowser: Bool { + settings.openPortLinksInProgramaBrowser + } + + private var titleFontWeight: Font.Weight { + .semibold + } + + private var showsLeadingRail: Bool { + explicitRailColor != nil + } + + private var activeBorderLineWidth: CGFloat { + switch activeTabIndicatorStyle { + case .leftRail: + return 0 + case .solidFill: + return isActive ? 1.5 : 0 + } + } + + private var activeBorderColor: Color { + guard isActive else { return .clear } + switch activeTabIndicatorStyle { + case .leftRail: + return .clear + case .solidFill: + return Color.primary.opacity(0.5) + } + } + + private var usesInvertedActiveForeground: Bool { + isActive + } + + private var activePrimaryTextColor: Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 1.0)) + : .primary + } + + private func activeSecondaryColor(_ opacity: Double = 0.75) -> Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + if let hex = sidebarNotificationBadgeColorHex, let nsColor = NSColor(hex: hex) { + return Color(nsColor: nsColor) + } + return usesInvertedActiveForeground ? Color.white.opacity(0.25) : programaAccentColor() + } + + private var activeProgressTrackColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2) + } + + private var activeProgressFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.8) : programaAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + + private var showCloseButton: Bool { + isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) + } + + private var workspaceShortcutLabel: String? { + guard let workspaceShortcutDigit else { return nil } + return "\(workspaceShortcutModifierSymbol)\(workspaceShortcutDigit)" + } + + private var showsWorkspaceShortcutHint: Bool { + (showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil + } + + private var trailingAccessoryWidth: CGFloat { + SidebarTrailingAccessoryWidthPolicy.width( + canCloseWorkspace: canCloseWorkspace, + showsWorkspaceShortcutHint: showsWorkspaceShortcutHint, + workspaceShortcutLabel: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) + } + + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + + private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { + settings.visibleAuxiliaryDetails + } + + var body: some View { + let _ = workspaceObservationGeneration + let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace") + let protectedWorkspaceTooltip = String( + localized: "sidebar.pinnedWorkspaceProtected.tooltip", + defaultValue: "Pinned workspace. Closing requires confirmation." + ) + let closeButtonTooltip = tab.isPinned + ? protectedWorkspaceTooltip + : KeyboardShortcutSettings.Action.closeWorkspace.tooltip(closeWorkspaceTooltip) + let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.") + let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") + let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") + let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle + let detailVisibility = visibleAuxiliaryDetails + // Read from cache — updated only by the debounced publisher, onAppear, + // and settings changes. Title-keystroke re-renders skip this tree walk. + let orderedPanelIds: [UUID]? = cachedOrderedPanelIds + let branchDirectoryLines: [VerticalBranchDirectoryLine] = cachedBranchDirectoryLines + let pullRequestRows: [PullRequestDisplay] = cachedPullRequestRows + let compactGitBranchSummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + sidebarShowGitBranch, + let orderedPanelIds else { + return nil + } + return gitBranchSummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactDirectorySummaryText: String? = { + guard detailVisibility.showsBranchDirectory, + !sidebarBranchVerticalLayout, + let orderedPanelIds else { + return nil + } + return directorySummaryText(orderedPanelIds: orderedPanelIds) + }() + let compactBranchDirectoryRow = branchDirectoryRow( + gitSummary: compactGitBranchSummaryText, + directorySummary: compactDirectorySummaryText + ) + let branchLinesContainBranch = sidebarShowGitBranch && branchDirectoryLines.contains { $0.branch != nil } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + if unreadCount > 0 { + ZStack { + Circle() + .fill(activeUnreadBadgeFillColor) + Text("\(unreadCount)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white) + } + .frame(width: 16, height: 16) + } + + if tab.isPinned { + Image(systemName: "pin.fill") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(activeSecondaryColor(0.8)) + .safeHelp(protectedWorkspaceTooltip) + } + + Text(tab.title) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + + Spacer(minLength: 0) + + ZStack(alignment: .trailing) { + Button(action: { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=button") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + }) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.7)) + } + .buttonStyle(.plain) + .safeHelp(closeButtonTooltip) + .frame(width: SidebarTrailingAccessoryWidthPolicy.closeButtonWidth, height: 16, alignment: .center) + .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) + .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) + + if showsWorkspaceShortcutHint, let workspaceShortcutLabel { + Text(workspaceShortcutLabel) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundColor(activePrimaryTextColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) + .offset( + x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), + y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) + ) + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints) + .frame(width: trailingAccessoryWidth, height: 16, alignment: .trailing) + } + + if let description = tab.customDescription { + SidebarWorkspaceDescriptionText( + markdown: description, + isActive: usesInvertedActiveForeground + ) + .id(description) + } + + if let subtitle = effectiveSubtitle { + Text(subtitle) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(2) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + } + + remoteWorkspaceSection + + if detailVisibility.showsMetadata { + let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + if !metadataEntries.isEmpty { + SidebarMetadataRows( + entries: metadataEntries, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + if !metadataBlocks.isEmpty { + SidebarMetadataMarkdownBlocks( + blocks: metadataBlocks, + isActive: usesInvertedActiveForeground, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + + // Latest log entry + if detailVisibility.showsLog, let latestLog = tab.logEntries.last { + HStack(spacing: 4) { + Image(systemName: logLevelIcon(latestLog.level)) + .font(.system(size: 8)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) + Text(latestLog.message) + .font(.system(size: 10)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Progress bar + if detailVisibility.showsProgress, let progress = tab.progress { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(activeProgressTrackColor) + Capsule() + .fill(activeProgressFillColor) + .frame(width: max(0, geo.size.width * CGFloat(progress.value))) + } + } + .frame(height: 3) + + if let label = progress.label { + Text(label) + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Branch + directory row + if detailVisibility.showsBranchDirectory { + if sidebarBranchVerticalLayout { + if !branchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, branchLinesContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(branchDirectoryLines.enumerated()), id: \.offset) { _, line in + HStack(spacing: 3) { + if let branch = line.branch { + Text(branch) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + if line.branch != nil, line.directory != nil { + Image(systemName: "circle.fill") + .font(.system(size: 3)) + .foregroundColor(activeSecondaryColor(0.6)) + .padding(.horizontal, 1) + } + if let directory = line.directory { + Text(directory) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + } + } + } else if let dirRow = compactBranchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranchIcon, compactGitBranchSummaryText != nil { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + // Pull request rows + if detailVisibility.showsPullRequests, !pullRequestRows.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestRows) { pullRequest in + Button(action: { + openPullRequestLink(pullRequest.url) + }) { + HStack(spacing: 4) { + PullRequestStatusIcon( + status: pullRequest.status, + color: pullRequestForegroundColor + ) + Text("\(pullRequest.label) #\(pullRequest.number)") + .underline() + .lineLimit(1) + .truncationMode(.tail) + Text(pullRequestStatusLabel(pullRequest.status, checks: pullRequest.checks)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.pullRequest.openTooltip", defaultValue: "Open \(pullRequest.label) #\(pullRequest.number)")) + } + } + } + + // Ports row + if detailVisibility.showsPorts, !tab.listeningPorts.isEmpty { + HStack(spacing: 4) { + ForEach(tab.listeningPorts, id: \.self) { port in + Button(action: { + openPortLink(port) + }) { + Text(String(localized: "sidebar.port.label", defaultValue: ":\(port)")) + .underline() + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.port.openTooltip", defaultValue: "Open localhost:\(port)")) + } + Spacer(minLength: 0) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.75)) + .lineLimit(1) + } + } + .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) + .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) + .animation(.easeInOut(duration: 0.2), value: tab.metadataBlocks.count) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(backgroundColor) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(activeBorderColor, lineWidth: activeBorderLineWidth) + } + .overlay(alignment: .leading) { + if showsLeadingRail { + Capsule(style: .continuous) + .fill(railColor) + .frame(width: 3) + .padding(.leading, 4) + .padding(.vertical, 5) + .offset(x: -1) + } + } + ) + .padding(.horizontal, 6) + .background { + GeometryReader { proxy in + Color.clear + .onAppear { + rowHeight = max(proxy.size.height, 1) + } + .onChange(of: proxy.size.height) { newHeight in + rowHeight = max(newHeight, 1) + } + } + } + .contentShape(Rectangle()) + .opacity(isBeingDragged ? 0.6 : 1) + .overlay { + MiddleClickCapture { + #if DEBUG + dlog("sidebar.close workspace=\(tab.id.uuidString.prefix(5)) method=middleClick") + #endif + tabManager.closeWorkspaceWithConfirmation(tab) + } + } + .overlay(alignment: .top) { + if showsCenteredTopDropIndicator { + Rectangle() + .fill(programaAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) + } + } + .onReceive( + tab.sidebarImmediateObservationPublisher + .receive(on: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=immediate " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + workspaceObservationGeneration &+= 1 + } + .onReceive( + tab.sidebarObservationPublisher + .receive(on: RunLoop.main) + // Prompt-time sidebar telemetry can arrive as a short burst + // (pwd, branch, PR, shell state). Coalesce that burst so the + // row redraws once with the settled state instead of blinking. + .debounce(for: Self.workspaceObservationCoalesceInterval, scheduler: RunLoop.main) + ) { _ in +#if DEBUG + let description = tab.customDescription ?? "" + dlog( + "sidebar.row.invalidate workspace=\(tab.id.uuidString.prefix(8)) " + + "source=debounced " + + "title=\"\(debugCommandPaletteTextPreview(tab.title))\" " + + "descLen=\((description as NSString).length) " + + "desc=\"\(debugCommandPaletteTextPreview(description))\"" + ) +#endif + // Refresh expensive caches (tree walk, branch/dir/PR) before + // signalling a redraw. The immediate publisher intentionally does + // NOT call this so that title keystrokes skip the tree walk. + recomputeSidebarDetailCache() + workspaceObservationGeneration &+= 1 + } + .onDrag { + #if DEBUG + dlog("sidebar.onDrag tab=\(tab.id.uuidString.prefix(5))") + #endif + draggedTabId = tab.id + dropIndicator = nil + return SidebarTabDragPayload.provider(for: tab.id) + } + .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: tab.id, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) + .onTapGesture { + updateSelection() + } + .onHover { hovering in + isHovering = hovering + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(accessibilityTitle)) + .accessibilityHint(Text(accessibilityHintText)) + .accessibilityAction(named: Text(moveUpActionText)) { + moveBy(-1) + } + .accessibilityAction(named: Text(moveDownActionText)) { + moveBy(1) + } + .onAppear { + // Prime the cache so branch/dir/PR rows appear immediately, + // before the first debounced publisher fires. + recomputeSidebarDetailCache() + } + .onChange(of: visibleAuxiliaryDetails) { _ in + // Toggling branch/PR columns changes which data we need to cache. + recomputeSidebarDetailCache() + } + .contextMenu { workspaceContextMenu } + } + + private func contextMenuLabel(multi: String, single: String, isMulti: Bool) -> String { + isMulti ? multi : single + } + + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + + @ViewBuilder + private var workspaceContextMenu: some View { + let targetIds = contextMenuWorkspaceIds + let isMulti = targetIds.count > 1 + let tabColorPalette = WorkspaceTabColorSettings.palette() + let shouldPin = !tab.isPinned + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) + let pinLabel = shouldPin + ? contextMenuLabel( + multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), + single: String(localized: "contextMenu.pinWorkspace", defaultValue: "Pin Workspace"), + isMulti: isMulti) + : contextMenuLabel( + multi: String(localized: "contextMenu.unpinWorkspaces", defaultValue: "Unpin Workspaces"), + single: String(localized: "contextMenu.unpinWorkspace", defaultValue: "Unpin Workspace"), + isMulti: isMulti) + let closeLabel = contextMenuLabel( + multi: String(localized: "contextMenu.closeWorkspaces", defaultValue: "Close Workspaces"), + single: String(localized: "contextMenu.closeWorkspace", defaultValue: "Close Workspace"), + isMulti: isMulti) + let markReadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesRead", defaultValue: "Mark Workspaces as Read"), + single: String(localized: "contextMenu.markWorkspaceRead", defaultValue: "Mark Workspace as Read"), + isMulti: isMulti) + let markUnreadLabel = contextMenuLabel( + multi: String(localized: "contextMenu.markWorkspacesUnread", defaultValue: "Mark Workspaces as Unread"), + single: String(localized: "contextMenu.markWorkspaceUnread", defaultValue: "Mark Workspace as Unread"), + isMulti: isMulti) + let clearLatestNotificationLabel = contextMenuLabel( + multi: String(localized: "contextMenu.clearLatestNotifications", defaultValue: "Clear Latest Notifications"), + single: String(localized: "contextMenu.clearLatestNotification", defaultValue: "Clear Latest Notification"), + isMulti: isMulti) + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let editWorkspaceDescriptionShortcut = KeyboardShortcutSettings.shortcut(for: .editWorkspaceDescription) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) + Button(pinLabel) { + for id in targetIds { + if let tab = tabManager.tabs.first(where: { $0.id == id }) { + tabManager.setPinned(tab, pinned: shouldPin) + } + } + syncSelectionAfterMutation() + } + + if let key = renameWorkspaceShortcut.keyEquivalent { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.renameWorkspace", defaultValue: "Rename Workspace…")) { + promptRename() + } + } + + if tab.hasCustomTitle { + Button(String(localized: "contextMenu.removeCustomWorkspaceName", defaultValue: "Remove Custom Workspace Name")) { + tabManager.clearCustomTitle(tabId: tab.id) + } + } + + if !isMulti { + if let key = editWorkspaceDescriptionShortcut.keyEquivalent { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + .keyboardShortcut(key, modifiers: editWorkspaceDescriptionShortcut.eventModifiers) + } else { + Button(String(localized: "contextMenu.editWorkspaceDescription", defaultValue: "Edit Workspace Description…")) { + beginWorkspaceDescriptionEditFromContextMenu() + } + } + + if tab.hasCustomDescription { + Button(String(localized: "contextMenu.clearWorkspaceDescription", defaultValue: "Clear Workspace Description")) { + tabManager.clearCustomDescription(tabId: tab.id) + } + } + } + + if !remoteContextMenuWorkspaceIds.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.reconnectRemoteConnection() + } + } + .disabled(allRemoteContextMenuTargetsConnecting) + + Button(disconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(allRemoteContextMenuTargetsDisconnected) + } + + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color"), systemImage: "xmark.circle") + } + } + + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…"), systemImage: "paintpalette") + } + + if !tabColorPalette.isEmpty { + Divider() + } + + ForEach(tabColorPalette, id: \.id) { entry in + Button { + applyTabColor(entry.hex, targetIds: targetIds) + } label: { + Label { + Text(entry.name) + } icon: { + Image(nsImage: coloredCircleImage(color: tabColorSwatchColor(for: entry.hex))) + } + } + } + } + + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + + Divider() + + Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { + moveBy(-1) + } + .disabled(index == 0) + + Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { + moveBy(1) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { + tabManager.moveTabsToTop(Set(targetIds)) + syncSelectionAfterMutation() + } + .disabled(targetIds.isEmpty) + + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 + ? String(localized: "contextMenu.moveWorkspacesToWindow", defaultValue: "Move Workspaces to Window") + : String(localized: "contextMenu.moveWorkspaceToWindow", defaultValue: "Move Workspace to Window") + Menu(moveMenuTitle) { + Button(String(localized: "contextMenu.newWindow", defaultValue: "New Window")) { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) + + if !windowMoveTargets.isEmpty { + Divider() + } + + ForEach(windowMoveTargets) { target in + Button(target.label) { + moveWorkspaces(targetIds, toWindow: target.windowId) + } + .disabled(target.isCurrentWindow || targetIds.isEmpty) + } + } + .disabled(targetIds.isEmpty) + + Divider() + + if let key = closeWorkspaceShortcut.keyEquivalent { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .keyboardShortcut(key, modifiers: closeWorkspaceShortcut.eventModifiers) + .disabled(targetIds.isEmpty) + } else { + Button(closeLabel) { + closeTabs(targetIds, allowPinned: true) + } + .disabled(targetIds.isEmpty) + } + + Button(String(localized: "contextMenu.closeOtherWorkspaces", defaultValue: "Close Other Workspaces")) { + closeOtherTabs(targetIds) + } + .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) + + Button(String(localized: "contextMenu.closeWorkspacesBelow", defaultValue: "Close Workspaces Below")) { + closeTabsBelow(tabId: tab.id) + } + .disabled(index >= tabManager.tabs.count - 1) + + Button(String(localized: "contextMenu.closeWorkspacesAbove", defaultValue: "Close Workspaces Above")) { + closeTabsAbove(tabId: tab.id) + } + .disabled(index == 0) + + Divider() + + Button(markReadLabel) { + markTabsRead(targetIds) + } + .disabled(!hasUnreadNotifications(in: targetIds)) + + Button(markUnreadLabel) { + markTabsUnread(targetIds) + } + .disabled(!hasReadNotifications(in: targetIds)) + + Button(clearLatestNotificationLabel) { + clearLatestNotifications(targetIds) + } + .disabled(!hasLatestNotifications(in: targetIds)) + } + + private var selectionBackgroundColor: NSColor { + if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { + return parsed + } + return programaAccentNSColor(for: colorScheme) + } + + private var backgroundColor: Color { + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: selectionBackgroundColor) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return programaAccentColor().opacity(0.25) } + return Color.clear + } + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil + } + return custom.opacity(0.95) + } + + private var resolvedCustomTabColor: Color? { + guard let hex = tab.customColor else { return nil } + return WorkspaceTabColorSettings.displayColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) + } + + private func tabColorSwatchColor(for hex: String) -> NSColor { + WorkspaceTabColorSettings.displayNSColor( + hex: hex, + colorScheme: colorScheme, + forceBright: activeTabIndicatorStyle == .leftRail + ) ?? NSColor(hex: hex) ?? .gray + } + + private var showsCenteredTopDropIndicator: Bool { + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tab.id && indicator.edge == .top { + return true + } + + guard indicator.edge == .bottom, + let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), + currentIndex > 0 + else { + return false + } + return tabManager.tabs[currentIndex - 1].id == indicator.tabId + } + + private var accessibilityTitle: String { + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") + } + + private func moveBy(_ delta: Int) { + let targetIndex = index + delta + guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } + guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } + tabManager.selectTab(tab) + setSelectionToTabs() + } + + private func updateSelection() { + #if DEBUG + let mods = NSEvent.modifierFlags + var modStr = "" + if mods.contains(.command) { modStr += "cmd " } + if mods.contains(.shift) { modStr += "shift " } + if mods.contains(.option) { modStr += "opt " } + if mods.contains(.control) { modStr += "ctrl " } + dlog("sidebar.select workspace=\(tab.id.uuidString.prefix(5)) modifiers=\(modStr.isEmpty ? "none" : modStr.trimmingCharacters(in: .whitespaces))") + #endif + let modifiers = NSEvent.modifierFlags + let isCommand = modifiers.contains(.command) + let isShift = modifiers.contains(.shift) + let wasSelected = tabManager.selectedTabId == tab.id + + if isShift, let lastIndex = lastSidebarSelectionIndex { + let lower = min(lastIndex, index) + let upper = max(lastIndex, index) + let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + if isCommand { + selectedTabIds.formUnion(rangeIds) + } else { + selectedTabIds = Set(rangeIds) + } + } else if isCommand { + if selectedTabIds.contains(tab.id) { + selectedTabIds.remove(tab.id) + } else { + selectedTabIds.insert(tab.id) + } + } else { + selectedTabIds = [tab.id] + } + + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + if wasSelected, !isCommand, !isShift { + tabManager.dismissNotificationOnDirectInteraction( + tabId: tab.id, + surfaceId: tabManager.focusedSurfaceId(for: tab.id) + ) + } + setSelectionToTabs() + } + + private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { + tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned) + syncSelectionAfterMutation() + } + + private func closeOtherTabs(_ targetIds: [UUID]) { + let keepIds = Set(targetIds) + let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsBelow(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func closeTabsAbove(tabId: UUID) { + guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id } + closeTabs(idsToClose, allowPinned: true) + } + + private func markTabsRead(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markRead(forTabId: id) + } + } + + private func markTabsUnread(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.markUnread(forTabId: id) + } + } + + private func clearLatestNotifications(_ targetIds: [UUID]) { + for id in targetIds { + notificationStore.clearLatestNotification(forTabId: id) + } + } + + private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead } + } + + private func hasReadNotifications(in targetIds: [UUID]) -> Bool { + let targetSet = Set(targetIds) + return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } + } + + private func hasLatestNotifications(in targetIds: [UUID]) -> Bool { + targetIds.contains { notificationStore.latestNotification(forTabId: $0) != nil } + } + + private func syncSelectionAfterMutation() { + let existingIds = Set(tabManager.tabs.map { $0.id }) + selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } + if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + } + if let selectedId = tabManager.selectedTabId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } + } + + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } + private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard !orderedWorkspaceIds.isEmpty else { return } + + for (index, workspaceId) in orderedWorkspaceIds.enumerated() { + let shouldFocus = index == orderedWorkspaceIds.count - 1 + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: windowId, focus: shouldFocus) + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + private func moveWorkspacesToNewWindow(_ workspaceIds: [UUID]) { + guard let app = AppDelegate.shared else { return } + let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } + guard let firstWorkspaceId = orderedWorkspaceIds.first else { return } + + let shouldFocusImmediately = orderedWorkspaceIds.count == 1 + guard let newWindowId = app.moveWorkspaceToNewWindow(workspaceId: firstWorkspaceId, focus: shouldFocusImmediately) else { + return + } + + if orderedWorkspaceIds.count > 1 { + for workspaceId in orderedWorkspaceIds.dropFirst() { + _ = app.moveWorkspaceToWindow(workspaceId: workspaceId, windowId: newWindowId, focus: false) + } + if let finalWorkspaceId = orderedWorkspaceIds.last { + _ = app.moveWorkspaceToWindow(workspaceId: finalWorkspaceId, windowId: newWindowId, focus: true) + } + } + + selectedTabIds.subtract(orderedWorkspaceIds) + syncSelectionAfterMutation() + } + + // latestNotificationText is now passed as a parameter from the parent view + // to avoid subscribing to notificationStore changes in every TabItemView. + + private func branchDirectoryRow( + gitSummary: String?, + directorySummary: String? + ) -> String? { + var parts: [String] = [] + + if let gitSummary { + parts.append(gitSummary) + } + + if let directorySummary { + parts.append(directorySummary) + } + + let result = parts.joined(separator: " · ") + return result.isEmpty ? nil : result + } + + private func gitBranchSummaryText(orderedPanelIds: [UUID]) -> String? { + let lines = gitBranchSummaryLines(orderedPanelIds: orderedPanelIds) + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private func gitBranchSummaryLines(orderedPanelIds: [UUID]) -> [String] { + tab.sidebarGitBranchesInDisplayOrder(orderedPanelIds: orderedPanelIds).map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + } + + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + /// Recomputes the expensive sidebar detail caches (bonsplit tree walk, + /// branch/directory lines, PR snapshot) and writes them into @State. + /// Must be called only from the debounced publisher, onAppear, and + /// settings-change handlers — never from the immediate (title) publisher. + private func recomputeSidebarDetailCache() { + let detail = visibleAuxiliaryDetails + let needsDetail = detail.showsBranchDirectory || detail.showsPullRequests + let ids: [UUID]? = needsDetail ? tab.sidebarOrderedPanelIds() : nil + cachedOrderedPanelIds = ids + cachedBranchDirectoryLines = { + guard detail.showsBranchDirectory, sidebarBranchVerticalLayout, let ids else { return [] } + return verticalBranchDirectoryLines(orderedPanelIds: ids) + }() + cachedPullRequestRows = { + guard detail.showsPullRequests, let ids else { return [] } + return pullRequestDisplays(orderedPanelIds: ids) + }() + } + + private func verticalBranchDirectoryLines(orderedPanelIds: [UUID]) -> [VerticalBranchDirectoryLine] { + let entries = tab.sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: orderedPanelIds) + let home = SidebarPathFormatter.homeDirectoryPath + return entries.compactMap { entry in + let branchText: String? = { + guard sidebarShowGitBranch, let branch = entry.branch else { return nil } + return "\(branch)\(entry.isDirty ? "*" : "")" + }() + + let directoryText: String? = { + guard let directory = entry.directory else { return nil } + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + }() + + switch (branchText, directoryText) { + case let (branch?, directory?): + return VerticalBranchDirectoryLine(branch: branch, directory: directory) + case let (branch?, nil): + return VerticalBranchDirectoryLine(branch: branch, directory: nil) + case let (nil, directory?): + return VerticalBranchDirectoryLine(branch: nil, directory: directory) + default: + return nil + } + } + } + + private func directorySummaryText(orderedPanelIds: [UUID]) -> String? { + let home = SidebarPathFormatter.homeDirectoryPath + let entries = tab.sidebarDirectoriesInDisplayOrder(orderedPanelIds: orderedPanelIds).compactMap { directory in + let shortened = SidebarPathFormatter.shortenedPath(directory, homeDirectoryPath: home) + return shortened.isEmpty ? nil : shortened + } + return entries.isEmpty ? nil : entries.joined(separator: " | ") + } + + private struct PullRequestDisplay: Identifiable { + let id: String + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus + let checks: SidebarPullRequestChecksStatus? + } + + private func pullRequestDisplays(orderedPanelIds: [UUID]) -> [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder(orderedPanelIds: orderedPanelIds).map { pullRequest in + PullRequestDisplay( + id: "\(pullRequest.label.lowercased())#\(pullRequest.number)|\(pullRequest.url.absoluteString)", + number: pullRequest.number, + label: pullRequest.label, + url: pullRequest.url, + status: pullRequest.status, + checks: pullRequest.checks + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func openPortLink(_ port: Int) { + guard let url = URL(string: "http://localhost:\(port)") else { return } + updateSelection() + if openSidebarPortLinksInProgramaBrowser { + if tabManager.openBrowser( + inWorkspace: tab.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) == nil { + NSWorkspace.shared.open(url) + } + return + } + NSWorkspace.shared.open(url) + } + + private func pullRequestStatusLabel( + _ status: SidebarPullRequestStatus, + checks _: SidebarPullRequestChecksStatus? + ) -> String { + switch status { + case .open: return String(localized: "sidebar.pullRequest.statusOpen", defaultValue: "open") + case .merged: return String(localized: "sidebar.pullRequest.statusMerged", defaultValue: "merged") + case .closed: return String(localized: "sidebar.pullRequest.statusClosed", defaultValue: "closed") + } + } + + private func logLevelIcon(_ level: SidebarLogLevel) -> String { + switch level { + case .info: return "circle.fill" + case .progress: return "arrowtriangle.right.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { + if isActive { + switch level { + case .info: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.5)) + case .progress: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.8)) + case .success: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .warning: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + case .error: + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.9)) + } + } + switch level { + case .info: return .secondary + case .progress: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + + private struct PullRequestStatusIcon: View { + let status: SidebarPullRequestStatus + let color: Color + private static let frameSize: CGFloat = 12 + + var body: some View { + switch status { + case .open: + PullRequestOpenIcon(color: color) + case .merged: + PullRequestMergedIcon(color: color) + case .closed: + Image(systemName: "xmark.circle") + .font(.system(size: 7, weight: .regular)) + .foregroundColor(color) + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + } + + private struct PullRequestOpenIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 3.0, y: 4.8)) + path.addLine(to: CGPoint(x: 3.0, y: 9.2)) + + path.move(to: CGPoint(x: 4.8, y: 3.0)) + path.addLine(to: CGPoint(x: 9.4, y: 3.0)) + path.addLine(to: CGPoint(x: 11.0, y: 4.6)) + path.addLine(to: CGPoint(x: 11.0, y: 9.2)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 11.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private struct PullRequestMergedIcon: View { + let color: Color + private static let stroke = StrokeStyle(lineWidth: 1.2, lineCap: .round, lineJoin: .round) + private static let nodeDiameter: CGFloat = 3.0 + private static let frameSize: CGFloat = 13 + + var body: some View { + ZStack { + Path { path in + path.move(to: CGPoint(x: 4.6, y: 4.6)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + path.addLine(to: CGPoint(x: 9.2, y: 7.0)) + + path.move(to: CGPoint(x: 4.6, y: 9.4)) + path.addLine(to: CGPoint(x: 7.1, y: 7.0)) + } + .stroke(color, style: Self.stroke) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 3.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 3.0, y: 11.0) + + Circle() + .stroke(color, lineWidth: Self.stroke.lineWidth) + .frame(width: Self.nodeDiameter, height: Self.nodeDiameter) + .position(x: 11.0, y: 7.0) + } + .frame(width: Self.frameSize, height: Self.frameSize) + } + } + + private func applyTabColor(_ hex: String?, targetIds: [UUID]) { + for targetId in targetIds { + tabManager.setTabColor(tabId: targetId, color: hex) + } + } + + private func promptCustomColor(targetIds: [UUID]) { + let alert = NSAlert() + alert.messageText = String(localized: "alert.customColor.title", defaultValue: "Custom Workspace Color") + alert.informativeText = String(localized: "alert.customColor.message", defaultValue: "Enter a hex color in the format #RRGGBB.") + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customPaletteEntries().first?.hex ?? "" + let input = NSTextField(string: seed) + input.placeholderString = "#1565C0" + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.customColor.apply", defaultValue: "Apply")) + alert.addButton(withTitle: String(localized: "alert.customColor.cancel", defaultValue: "Cancel")) + + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + guard let normalized = WorkspaceTabColorSettings.addCustomColor(input.stringValue) else { + showInvalidColorAlert(input.stringValue) + return + } + applyTabColor(normalized, targetIds: targetIds) + } + + private func showInvalidColorAlert(_ value: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "alert.invalidColor.title", defaultValue: "Invalid Color") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = String(localized: "alert.invalidColor.emptyMessage", defaultValue: "Enter a hex color in the format #RRGGBB.") + } else { + alert.informativeText = String(localized: "alert.invalidColor.invalidMessage", defaultValue: "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB.") + } + alert.addButton(withTitle: String(localized: "alert.invalidColor.ok", defaultValue: "OK")) + _ = alert.runModal() + } + + private func promptRename() { + let alert = NSAlert() + alert.messageText = String(localized: "alert.renameWorkspace.title", defaultValue: "Rename Workspace") + alert.informativeText = String(localized: "alert.renameWorkspace.message", defaultValue: "Enter a custom name for this workspace.") + let input = NSTextField(string: tab.customTitle ?? tab.title) + input.placeholderString = String(localized: "alert.renameWorkspace.placeholder", defaultValue: "Workspace name") + input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) + alert.accessoryView = input + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.rename", defaultValue: "Rename")) + alert.addButton(withTitle: String(localized: "alert.renameWorkspace.cancel", defaultValue: "Cancel")) + let alertWindow = alert.window + alertWindow.initialFirstResponder = input + DispatchQueue.main.async { + alertWindow.makeFirstResponder(input) + input.selectText(nil) + } + let response = alert.runModal() + guard response == .alertFirstButtonReturn else { return } + tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue) + } + + private func beginWorkspaceDescriptionEditFromContextMenu() { + selectedTabIds = [tab.id] + lastSidebarSelectionIndex = index + tabManager.selectTab(tab) + setSelectionToTabs() + _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() + } +} + +private struct SidebarWorkspaceDescriptionText: View { + let markdown: String + let isActive: Bool + + var body: some View { + let renderedMarkdown = SidebarMarkdownRenderer.renderWorkspaceDescription(markdown) + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + } else { + Text(markdown) + } + } + .font(.system(size: 10.5)) + .foregroundColor(foregroundColor) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("SidebarWorkspaceDescriptionText") + .accessibilityLabel(accessibilityText(renderedMarkdown: renderedMarkdown)) + .onAppear { +#if DEBUG + let newlineCount = markdown.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=appear " + + "len=\((markdown as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(markdown))\"" + ) +#endif + } + .onChange(of: markdown) { newValue in +#if DEBUG + let newlineCount = newValue.reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + dlog( + "sidebar.description.render workspaceState=change " + + "len=\((newValue as NSString).length) " + + "newlines=\(newlineCount) " + + "text=\"\(debugCommandPaletteTextPreview(newValue))\"" + ) +#endif + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.84) : .secondary.opacity(0.95) + } + + private func accessibilityText(renderedMarkdown: AttributedString?) -> String { + if let renderedMarkdown { + return String(renderedMarkdown.characters) + } + return markdown + } +} + +enum SidebarMarkdownRenderer { + static func renderWorkspaceDescription(_ markdown: String) -> AttributedString? { + try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) + } +} + +private struct SidebarMetadataRows: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? activeSecondaryTextColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var activeSecondaryTextColor: Color { + Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.65)) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map { entry in + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? entry.key : trimmed + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +private struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let onFocus: () -> Void + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(spacing: 4) { + if let icon = iconView { + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .font(.system(size: 10)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + if isActive, + let raw = entry.color, + Color(hex: raw) != nil { + return Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: 0.95)) + } + if let raw = entry.color, let explicit = Color(hex: raw) { + return explicit + } + return isActive ? .white.opacity(0.8) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 9))) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).font(.system(size: 8, weight: .semibold))) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(Image(systemName: symbolName).font(.system(size: 8, weight: .medium))) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let trimmed = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + let display = trimmed.isEmpty ? entry.key : trimmed + if entry.format == .markdown, + let attributed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} + +private struct SidebarMetadataMarkdownBlocks: View { + let blocks: [SidebarMetadataBlock] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedBlockLimit = 1 + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(visibleBlocks, id: \.key) { block in + SidebarMetadataMarkdownBlockRow( + block: block, + isActive: isActive, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLessDetails", defaultValue: "Show less details") : String(localized: "sidebar.metadata.showMoreDetails", defaultValue: "Show more details")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) + } + + private var shouldShowToggle: Bool { + blocks.count > collapsedBlockLimit + } +} + +private struct SidebarMetadataMarkdownBlockRow: View { + let block: SidebarMetadataBlock + let isActive: Bool + let onFocus: () -> Void + + @State private var renderedMarkdown: AttributedString? + + var body: some View { + Group { + if let renderedMarkdown { + Text(renderedMarkdown) + .foregroundColor(foregroundColor) + } else { + Text(block.markdown) + .foregroundColor(foregroundColor) + } + } + .font(.system(size: 10)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + .onAppear(perform: renderMarkdown) + .onChange(of: block.markdown) { _ in + renderMarkdown() + } + } + + private var foregroundColor: Color { + isActive ? .white.opacity(0.8) : .secondary + } + + private func renderMarkdown() { + renderedMarkdown = try? AttributedString( + markdown: block.markdown, + options: .init(interpretedSyntax: .full) + ) + } +} +