diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd3dc3a5..a3654ce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Validate self-hosted runner guards run: ./tests/test_ci_self_hosted_guard.sh @@ -23,10 +23,10 @@ jobs: working-directory: web steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2 - name: Install dependencies run: bun install --frozen-lockfile @@ -43,7 +43,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index a8ebeea4..246da9c4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Decide whether a nightly build is needed id: decide - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }} with: @@ -84,7 +84,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout main - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive @@ -326,7 +326,7 @@ jobs: git push origin refs/tags/nightly --force - name: Publish nightly release assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: nightly name: Nightly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9063de75..92a60dc8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,13 +17,13 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: recursive - name: Guard immutable release assets id: guard_release_assets - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard'); @@ -277,7 +277,7 @@ jobs: - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: files: | cmux-macos.dmg diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml index 17c07fb5..d92de590 100644 --- a/.github/workflows/update-homebrew.yml +++ b/.github/workflows/update-homebrew.yml @@ -65,7 +65,7 @@ jobs: echo "DMG SHA256: $SHA256" - name: Checkout homebrew-cmux - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: manaflow-ai/homebrew-cmux token: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c4e6bcc2..da780371 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,9 @@ import Foundation import Darwin import Security +#if canImport(Sentry) +import Sentry +#endif struct CLIError: Error, CustomStringConvertible { let message: String @@ -8,6 +11,182 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } +private final class CLISocketSentryTelemetry { + private let command: String + private let subcommand: String + private let socketPath: String + private let envSocketPath: String? + private let workspaceId: String? + private let surfaceId: String? + private let disabledByEnv: Bool + +#if canImport(Sentry) + private static let startupLock = NSLock() + private static var started = false + private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" +#endif + + init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { + self.command = command.lowercased() + self.subcommand = commandArgs.first?.lowercased() ?? "help" + self.socketPath = socketPath + self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] + self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] + self.surfaceId = processEnv["CMUX_SURFACE_ID"] + self.disabledByEnv = + processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" || + processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" + } + + func breadcrumb(_ message: String, data: [String: Any] = [:]) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var payload = baseContext() + for (key, value) in data { + payload[key] = value + } + let crumb = Breadcrumb(level: .info, category: "cmux.cli") + crumb.message = message + crumb.data = payload + SentrySDK.addBreadcrumb(crumb) +#endif + } + + func captureError(stage: String, error: Error) { + guard shouldEmit else { return } +#if canImport(Sentry) + Self.ensureStarted() + var context = baseContext() + context["stage"] = stage + context["error"] = String(describing: error) + for (key, value) in socketDiagnostics() { + context[key] = value + } + let subcommand = self.subcommand + let command = self.command + _ = SentrySDK.capture(error: error) { scope in + scope.setLevel(.error) + scope.setTag(value: "cmux-cli", key: "component") + scope.setTag(value: command, key: "cli_command") + scope.setTag(value: subcommand, key: "cli_subcommand") + scope.setContext(value: context, key: "cli_socket") + } + SentrySDK.flush(timeout: 2.0) +#endif + } + + private var shouldEmit: Bool { + !disabledByEnv + } + + private func baseContext() -> [String: Any] { + var context: [String: Any] = [ + "command": command, + "subcommand": subcommand, + "requested_socket_path": socketPath, + "env_socket_path": envSocketPath ?? "" + ] + if let workspaceId { + context["workspace_id"] = workspaceId + } + if let surfaceId { + context["surface_id"] = surfaceId + } + return context + } + + private func socketDiagnostics() -> [String: Any] { + var context: [String: Any] = [ + "cwd": FileManager.default.currentDirectoryPath, + "uid": Int(getuid()), + "euid": Int(geteuid()) + ] + + var st = stat() + if lstat(socketPath, &st) == 0 { + context["socket_exists"] = true + context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777)) + context["socket_owner_uid"] = Int(st.st_uid) + context["socket_owner_gid"] = Int(st.st_gid) + context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode) + } else { + let code = errno + context["socket_exists"] = false + context["socket_errno"] = Int(code) + context["socket_errno_description"] = String(cString: strerror(code)) + } + + let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10) + if !tmpSockets.isEmpty { + context["tmp_cmux_sockets"] = tmpSockets + } + let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" } + if socketPath == "/tmp/cmux.sock", + (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), + !taggedSockets.isEmpty { + context["possible_root_cause"] = "CMUX_SOCKET_PATH missing while tagged sockets exist" + } + + return context + } + + private static func fileTypeDescription(mode: mode_t) -> String { + switch mode & mode_t(S_IFMT) { + case mode_t(S_IFSOCK): + return "socket" + case mode_t(S_IFREG): + return "regular" + case mode_t(S_IFDIR): + return "directory" + case mode_t(S_IFLNK): + return "symlink" + default: + return "other" + } + } + + private static func discoverTmpCmuxSockets(limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + return [] + } + var sockets: [String] = [] + for name in entries.sorted() { + guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } + let fullPath = "/tmp/\(name)" + var st = stat() + guard lstat(fullPath, &st) == 0 else { continue } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } + sockets.append(fullPath) + if sockets.count >= limit { + break + } + } + return sockets + } + +#if canImport(Sentry) + private static func ensureStarted() { + startupLock.lock() + defer { startupLock.unlock() } + guard !started else { return } + SentrySDK.start { options in + options.dsn = dsn +#if DEBUG + options.environment = "development-cli" +#else + options.environment = "production-cli" +#endif + options.debug = false + options.sendDefaultPii = true + options.attachStacktrace = true + options.tracesSampleRate = 0.0 + } + started = true + } +#endif +} + struct WindowInfo { let index: Int let id: String @@ -555,6 +734,12 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + let cliTelemetry = CLISocketSentryTelemetry( + command: command, + commandArgs: commandArgs, + socketPath: socketPath, + processEnv: ProcessInfo.processInfo.environment + ) if command == "version" { print(versionSummary()) @@ -570,7 +755,18 @@ struct CMUXCLI { } let client = SocketClient(path: socketPath) - try client.connect() + cliTelemetry.breadcrumb( + "socket.connect.attempt", + data: ["command": command] + ) + do { + try client.connect() + cliTelemetry.breadcrumb("socket.connect.success") + } catch { + cliTelemetry.breadcrumb("socket.connect.failure") + cliTelemetry.captureError(stage: "socket_connect", error: error) + throw error + } defer { client.close() } if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { @@ -1166,7 +1362,15 @@ struct CMUXCLI { print(response) case "claude-hook": - try runClaudeHook(commandArgs: commandArgs, client: client) + cliTelemetry.breadcrumb("claude-hook.dispatch") + do { + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("claude-hook.completed") + } catch { + cliTelemetry.breadcrumb("claude-hook.failure") + cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + throw error + } case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } @@ -4422,7 +4626,11 @@ fi } } - private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { + private func runClaudeHook( + commandArgs: [String], + client: SocketClient, + telemetry: CLISocketSentryTelemetry + ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") @@ -4431,11 +4639,21 @@ fi let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() + telemetry.breadcrumb( + "claude-hook.input", + data: [ + "subcommand": subcommand, + "has_session_id": parsedInput.sessionId != nil, + "has_workspace_flag": hookWsFlag != nil, + "has_surface_flag": optionValue(hookArgs, name: "--surface") != nil + ] + ) let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client) let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client) switch subcommand { case "session-start", "active": + telemetry.breadcrumb("claude-hook.session-start") let workspaceId = fallbackWorkspaceId let surfaceId = try resolveSurfaceIdForClaudeHook( surfaceArg, @@ -4460,6 +4678,7 @@ fi print("OK") case "stop", "idle": + telemetry.breadcrumb("claude-hook.stop") let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, @@ -4488,6 +4707,7 @@ fi } case "notification", "notify": + telemetry.breadcrumb("claude-hook.notification") let summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId @@ -4532,6 +4752,7 @@ fi print(response) case "help", "--help", "-h": + telemetry.breadcrumb("claude-hook.help") print( """ cmux claude-hook [--workspace ] [--surface ] @@ -4820,39 +5041,63 @@ fi private func versionSummary() -> String { let info = resolvedVersionInfo() + let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } + let baseSummary: String if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { - return "cmux \(version) (\(build))" + baseSummary = "cmux \(version) (\(build))" + } else if let version = info["CFBundleShortVersionString"] { + baseSummary = "cmux \(version)" + } else if let build = info["CFBundleVersion"] { + baseSummary = "cmux build \(build)" + } else { + baseSummary = "cmux version unknown" } - if let version = info["CFBundleShortVersionString"] { - return "cmux \(version)" - } - if let build = info["CFBundleVersion"] { - return "cmux build \(build)" - } - return "cmux version unknown" + guard let commit else { return baseSummary } + return "\(baseSummary) [\(commit)]" } private func resolvedVersionInfo() -> [String: String] { + var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { - return main + info.merge(main, uniquingKeysWith: { current, _ in current }) } - for plistURL in candidateInfoPlistURLs() { - guard let data = try? Data(contentsOf: plistURL), - let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), - let dictionary = raw as? [String: Any], - let parsed = versionInfo(from: dictionary) - else { - continue + let needsPlistFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsPlistFallback { + for plistURL in candidateInfoPlistURLs() { + guard let data = try? Data(contentsOf: plistURL), + let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), + let dictionary = raw as? [String: Any], + let parsed = versionInfo(from: dictionary) + else { + continue + } + info.merge(parsed, uniquingKeysWith: { current, _ in current }) + if info["CFBundleShortVersionString"] != nil, + info["CFBundleVersion"] != nil, + info["CMUXCommit"] != nil { + break + } } - return parsed } - if let fromProject = versionInfoFromProjectFile() { - return fromProject + let needsProjectFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsProjectFallback, let fromProject = versionInfoFromProjectFile() { + info.merge(fromProject, uniquingKeysWith: { current, _ in current }) } - return [:] + if info["CMUXCommit"] == nil, + let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) { + info["CMUXCommit"] = commit + } + + return info } private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { @@ -4871,6 +5116,10 @@ fi info["CFBundleVersion"] = trimmed } } + if let commit = dictionary["CMUXCommit"] as? String, + let normalizedCommit = normalizedCommitHash(commit) { + info["CMUXCommit"] = normalizedCommit + } return info.isEmpty ? nil : info } @@ -4896,6 +5145,9 @@ fi if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { info["CFBundleVersion"] = build } + if let commit = gitCommitHash(at: current) { + info["CMUXCommit"] = commit + } if !info.isEmpty { return info } @@ -4932,6 +5184,45 @@ fi return value } + private func gitCommitHash(at directory: URL) -> String? { + let process = Process() + let stdout = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"] + process.standardOutput = stdout + process.standardError = Pipe() + + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + return nil + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { + return nil + } + return normalizedCommitHash(output) + } + + private func normalizedCommitHash(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.contains("$(") else { + return nil + } + let normalized = trimmed.lowercased() + let allowed = CharacterSet(charactersIn: "0123456789abcdef") + guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return nil + } + return String(normalized.prefix(12)) + } + private func candidateInfoPlistURLs() -> [URL] { guard let executable = currentExecutablePath(), !executable.isEmpty else { return [] @@ -5145,6 +5436,8 @@ fi CMUX_SOCKET_PATH Override the default Unix socket path. Debug CLI defaults: /tmp/cmux-last-socket-path -> /tmp/cmux-debug.sock. Release CLI default: /tmp/cmux.sock. + CMUX_CLI_SENTRY_DISABLED + Set to 1 to disable CLI Sentry socket diagnostics. """ } } diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index dc05ce47..c3f2b4d9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; @@ -242,6 +243,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,6 +467,9 @@ ); dependencies = ( ); + packageProductDependencies = ( + A5001251 /* Sentry */, + ); name = "cmux-cli"; productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -801,6 +806,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; @@ -814,6 +825,12 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; diff --git a/README.md b/README.md index 9122c289..a9206e34 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Split a browser alongside your terminal with a scriptable API ported from

Vertical + horizontal tabs

-Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically. +Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically. Vertical tabs and split panes @@ -96,7 +96,7 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors. -The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. +The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread. The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly. diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 070b33e9..3a1c2428 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -40,6 +40,9 @@ _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" +_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}" +_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}" +_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" @@ -127,6 +130,48 @@ _cmux_prompt_command() { _CMUX_GIT_JOB_PID=$! fi + # Pull request metadata (number/state/url): + # refresh on cwd change and periodically to avoid stale status. + if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true + _CMUX_PR_JOB_PID="" + fi + fi + + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then + if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + _CMUX_PR_LAST_PWD="$pwd" + _CMUX_PR_LAST_RUN=$now + { + local branch pr_tsv number state url status_opt="" + branch=$(git branch --show-current 2>/dev/null) + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" + if [[ -z "$pr_tsv" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + IFS=$'\t' read -r number state url <<< "$pr_tsv" + if [[ -z "$number" || -z "$url" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) status_opt="" ;; + esac + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi + fi + fi + } >/dev/null 2>&1 & + _CMUX_PR_JOB_PID=$! + fi + fi + # Ports: lightweight kick to the app's batched scanner every ~10s. if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then _cmux_ports_kick diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 3121788f..323ff506 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -46,6 +46,10 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD="" typeset -g _CMUX_GIT_HEAD_PATH="" typeset -g _CMUX_GIT_HEAD_MTIME=0 typeset -g _CMUX_HAVE_ZSTAT=0 +typeset -g _CMUX_PR_LAST_PWD="" +typeset -g _CMUX_PR_LAST_RUN=0 +typeset -g _CMUX_PR_JOB_PID="" +typeset -g _CMUX_PR_FORCE=0 typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 @@ -155,7 +159,8 @@ _cmux_preexec() { local cmd="${1## }" case "$cmd" in git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) - _CMUX_GIT_FORCE=1 ;; + _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 ;; esac # Register TTY + kick batched port scan for foreground commands (servers). @@ -212,6 +217,7 @@ _cmux_precmd() { # Treat HEAD file change like a git command — force-replace any # running probe so the sidebar picks up the new branch immediately. _CMUX_GIT_FORCE=1 + _CMUX_PR_FORCE=1 should_git=1 fi fi @@ -261,6 +267,63 @@ _cmux_precmd() { fi fi + # Pull request metadata (number/state/url): + # - refresh on cwd change, explicit git/gh commands, and occasionally for status drift + # - keep this independent from the git probe cadence to avoid hitting GitHub too often + local should_pr=0 + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then + should_pr=1 + elif (( _CMUX_PR_FORCE )); then + should_pr=1 + elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then + should_pr=1 + fi + + if (( should_pr )); then + local can_launch_pr=1 + if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then + if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then + kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true + _CMUX_PR_JOB_PID="" + else + can_launch_pr=0 + fi + fi + + if (( can_launch_pr )); then + _CMUX_PR_FORCE=0 + _CMUX_PR_LAST_PWD="$pwd" + _CMUX_PR_LAST_RUN=$now + { + local branch pr_tsv number state url status_opt="" + branch=$(git branch --show-current 2>/dev/null) + if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)" + if [[ -z "$pr_tsv" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + local IFS=$'\t' + read -r number state url <<< "$pr_tsv" + if [[ -z "$number" ]] || [[ -z "$url" ]]; then + _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + else + case "$state" in + MERGED) status_opt="--state=merged" ;; + OPEN) status_opt="--state=open" ;; + CLOSED) status_opt="--state=closed" ;; + *) status_opt="" ;; + esac + _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + fi + fi + fi + } >/dev/null 2>&1 &! + _CMUX_PR_JOB_PID=$! + fi + fi + # Ports: lightweight kick to the app's batched scanner. # - Periodic scan to avoid stale values. # - Forced scan when a long-running command returns to the prompt (common when stopping a server). diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 337ad9f3..b4568427 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3229,6 +3229,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.attemptUpdate() } + @objc func restartSocketListener(_ sender: Any?) { + guard let tabManager else { + NSSound.beep() + return + } + + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { + TerminalController.shared.stop() + NSSound.beep() + return + } + + let socketPath = SocketControlSettings.socketPath() + sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ + "mode": mode.rawValue, + "path": socketPath + ]) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode) + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( @@ -3250,7 +3275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.checkForUpdates(nil) }, onOpenPreferences: { [weak self] in - self?.openPreferencesWindow() + self?.openPreferencesWindow(debugSource: "menuBarExtra") }, onQuitApp: { NSApp.terminate(nil) @@ -3258,9 +3283,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + @MainActor + static func presentPreferencesWindow( + showFallbackSettingsWindow: @MainActor () -> Void = { + SettingsWindowController.shared.show() + }, + activateApplication: @MainActor () -> Void = { + NSApp.activate(ignoringOtherApps: true) + } + ) { +#if DEBUG + dlog("settings.open.present path=customWindowDirect") +#endif + showFallbackSettingsWindow() + activateApplication() +#if DEBUG + dlog("settings.open.present activate=1") +#endif + } + + @MainActor + func openPreferencesWindow(debugSource: String) { +#if DEBUG + dlog("settings.open.request source=\(debugSource)") +#endif + Self.presentPreferencesWindow() + } + @objc func openPreferencesWindow() { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - NSApp.activate(ignoringOtherApps: true) + openPreferencesWindow(debugSource: "appDelegate") } func refreshMenuBarExtraForDebug() { @@ -4512,6 +4563,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Cmd+W must close the focused panel even if first-responder momentarily lags on a + // browser NSTextView during split focus transitions. + if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) { + if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + targetWindow.identifier?.rawValue == "cmux.settings" { + targetWindow.performClose(nil) + } else { + let responder = event.window?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + if let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { +#if DEBUG + dlog( + "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + ) +#endif + manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + } else { +#if DEBUG + dlog("shortcut.cmdW route=focusedPanelFallback") +#endif + tabManager?.closeCurrentPanelWithConfirmation() + } + } + return true + } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWorkspace)) { tabManager?.closeCurrentWorkspaceWithConfirmation() return true diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e64d56d8..2e4902b7 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5,6 +5,29 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +private 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 } + self.init( + red: Double((value >> 16) & 0xFF) / 255.0, + green: Double((value >> 8) & 0xFF) / 255.0, + blue: Double( value & 0xFF) / 255.0 + ) + } +} + +private func coloredCircleImage(color: NSColor) -> NSImage { + let size = NSSize(width: 14, height: 14) + let image = NSImage(size: size, flipped: false) { rect in + color.setFill() + NSBezierPath(ovalIn: rect.insetBy(dx: 1, dy: 1)).fill() + return true + } + image.isTemplate = false + return image +} + func sidebarActiveForegroundNSColor( opacity: CGFloat, appAppearance: NSAppearance? = NSApp?.effectiveAppearance @@ -58,7 +81,6 @@ func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) return NSColor.white.withAlphaComponent(clampedOpacity) } - struct ShortcutHintPillBackground: View { var emphasis: Double = 1.0 @@ -342,6 +364,8 @@ 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? @@ -357,6 +381,43 @@ final class FileDropOverlayView: NSView { 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. @@ -387,6 +448,7 @@ final class FileDropOverlayView: NSView { 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 @@ -395,9 +457,33 @@ final class FileDropOverlayView: NSView { isForwardingMouseEvent = false } - let point = contentView.convert(event.locationInWindow, from: nil) - let target = contentView.hitTest(point) - guard let target, target !== self else { return } + 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) @@ -412,6 +498,13 @@ final class FileDropOverlayView: NSView { 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) } @@ -685,8 +778,313 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +private var commandPaletteWindowOverlayKey: UInt8 = 0 let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") +@MainActor +private final class CommandPaletteOverlayContainerView: NSView { + var capturesMouseEvents = false + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard capturesMouseEvents else { return nil } + return super.hitTest(point) + } +} + +@MainActor +private final class WindowCommandPaletteOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = CommandPaletteOverlayContainerView(frame: .zero) + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private var installConstraints: [NSLayoutConstraint] = [] + private weak var installedThemeFrame: NSView? + private var focusLockTimer: DispatchSourceTimer? + private var scheduledFocusWorkItem: DispatchWorkItem? + private var isPaletteVisible = false + private var windowDidBecomeKeyObserver: NSObjectProtocol? + private var windowDidResignKeyObserver: NSObjectProtocol? + + init(window: NSWindow) { + self.window = window + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.capturesMouseEvents = false + containerView.identifier = commandPaletteOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + installWindowKeyObservers() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedThemeFrame = themeFrame + } else if themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + return true + } + + private func isPaletteResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let view = responder as? NSView, view.isDescendant(of: containerView) { + return true + } + + if let textView = responder as? NSTextView { + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + } + + return false + } + + private func isPaletteFieldEditor(_ textView: NSTextView) -> Bool { + guard textView.isFieldEditor else { return false } + + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + + // SwiftUI text fields can keep a field editor delegate that isn't an NSView. + // Fall back to validating editor ownership from the mounted palette text field. + if let textField = firstEditableTextField(in: hostingView), + textField.currentEditor() === textView { + return true + } + + return false + } + + private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let textView = responder as? NSTextView { + return isPaletteFieldEditor(textView) + } + + if let textField = responder as? NSTextField { + return textField.isDescendant(of: containerView) + } + + return false + } + + private func firstEditableTextField(in view: NSView) -> NSTextField? { + if let textField = view as? NSTextField, + textField.isEditable, + textField.isEnabled, + !textField.isHiddenOrHasHiddenAncestor { + return textField + } + + for subview in view.subviews { + if let match = firstEditableTextField(in: subview) { + return match + } + } + return nil + } + + private func scheduleFocusIntoPalette(retries: Int = 4) { + scheduledFocusWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.scheduledFocusWorkItem = nil + self?.focusIntoPalette(retries: retries) + } + scheduledFocusWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func focusIntoPalette(retries: Int) { + guard let window else { return } + if isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + + if window.makeFirstResponder(containerView) { + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + } + + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.focusIntoPalette(retries: retries - 1) + } + } + + private func installWindowKeyObservers() { + guard let window else { return } + windowDidBecomeKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + windowDidResignKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateFocusLockForWindowState() + } + } + } + + private func updateFocusLockForWindowState() { + guard let window else { + stopFocusLockTimer() + return + } + guard isPaletteVisible else { + stopFocusLockTimer() + return + } + + guard window.isKeyWindow else { + stopFocusLockTimer() + if isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + return + } + + startFocusLockTimer() + if !isPaletteTextInputFirstResponder(window.firstResponder) { + scheduleFocusIntoPalette(retries: 8) + } + } + + private func startFocusLockTimer() { + guard focusLockTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard let window = self.window else { + self.stopFocusLockTimer() + return + } + if self.isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + self.focusIntoPalette(retries: 1) + } + focusLockTimer = timer + timer.resume() + } + + private func stopFocusLockTimer() { + focusLockTimer?.cancel() + focusLockTimer = nil + scheduledFocusWorkItem?.cancel() + scheduledFocusWorkItem = nil + } + + private func normalizeSelectionAfterProgrammaticFocus() { + guard let window, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } + + let text = editor.string + let length = (text as NSString).length + let selection = editor.selectedRange() + guard length > 0 else { return } + guard selection.location == 0, selection.length == length else { return } + + // Keep commands-mode prefix semantics stable after focus re-assertions: + // if AppKit selected the entire query (e.g. ">foo"), restore caret-at-end + // so the next keystroke appends instead of replacing and switching modes. + guard text.hasPrefix(">") else { return } + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + + func update(rootView: AnyView, isVisible: Bool) { + guard ensureInstalled() else { return } + isPaletteVisible = isVisible + if isVisible { + hostingView.rootView = rootView + containerView.capturesMouseEvents = true + containerView.isHidden = false + containerView.alphaValue = 1 + if let themeFrame = installedThemeFrame, themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + updateFocusLockForWindowState() + } else { + stopFocusLockTimer() + if let window, isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + hostingView.rootView = AnyView(EmptyView()) + containerView.capturesMouseEvents = false + containerView.alphaValue = 0 + containerView.isHidden = true + } + } +} + +@MainActor +private func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController { + if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController { + return existing + } + let controller = WindowCommandPaletteOverlayController(window: window) + objc_setAssociatedObject(window, &commandPaletteWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 @@ -794,10 +1192,9 @@ struct ContentView: View { @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @State private var sidebarWidth: CGFloat = 200 - @State private var sidebarMinX: CGFloat = 0 - @State private var isResizerHovering = false + @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false - private let sidebarHandleWidth: CGFloat = 6 + @State private var sidebarDragStartWidth: CGFloat? @State private var selectedTabIds: Set = [] @State private var mountedWorkspaceIds: [UUID] = [] @State private var lastSidebarSelectionIndex: Int? = nil @@ -812,7 +1209,534 @@ struct ContentView: View { @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) - @State private var titlebarThemeUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) + @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? + @State private var sidebarResizerPointerMonitor: Any? + @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 commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteHoveredResultIndex: Int? + @State private var commandPaletteScrollTargetIndex: Int? + @State private var commandPaletteScrollTargetAnchor: UnitPoint? + @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @FocusState private var isCommandPaletteSearchFocused: Bool + @FocusState private var isCommandPaletteRenameFocused: Bool + + private enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + } + + private enum CommandPaletteListScope: String { + case commands + case switcher + } + + private struct CommandPaletteRenameTarget: Equatable { + enum Kind: Equatable { + case workspace(workspaceId: UUID) + case tab(workspaceId: UUID, panelId: UUID) + } + + let kind: Kind + let currentName: String + + var title: String { + switch kind { + case .workspace: + return "Rename Workspace" + case .tab: + return "Rename Tab" + } + } + + var description: String { + switch kind { + case .workspace: + return "Choose a custom workspace name." + case .tab: + return "Choose a custom tab name." + } + } + + var placeholder: String { + switch kind { + case .workspace: + return "Workspace name" + case .tab: + return "Tab name" + } + } + } + + private enum CommandPaletteRestoreFocusIntent { + case panel + case browserAddressBar + } + + private struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + let intent: CommandPaletteRestoreFocusIntent + } + + private enum CommandPaletteInputFocusTarget { + case search + case rename + } + + private enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll + } + + private enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind + } + + private struct CommandPaletteTrailingLabel { + let text: String + let style: CommandPaletteTrailingLabelStyle + } + + private struct CommandPaletteInputFocusPolicy { + let focusTarget: CommandPaletteInputFocusTarget + let selectionBehavior: CommandPaletteTextSelectionBehavior + + static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) + } + + private struct CommandPaletteCommand: Identifiable { + let id: String + let rank: Int + let title: String + let subtitle: String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } + } + + private struct CommandPaletteUsageEntry: Codable { + var useCount: Int + var lastUsedAt: TimeInterval + } + + private struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + func string(_ key: String) -> String? { + stringValues[key] + } + } + + private enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceHasPullRequests = "workspace.hasPullRequests" + + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + + static let updateHasAvailable = "update.hasAvailable" + + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } + } + + private struct CommandPaletteCommandContribution { + let commandId: String + let title: (CommandPaletteContextSnapshot) -> String + let subtitle: (CommandPaletteContextSnapshot) -> String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let when: (CommandPaletteContextSnapshot) -> Bool + let enablement: (CommandPaletteContextSnapshot) -> Bool + + init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } + } + + private struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + mutating func register(commandId: String, handler: @escaping () -> Void) { + handlers[commandId] = handler + } + + func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } + } + + private struct CommandPaletteSearchResult: Identifiable { + let command: CommandPaletteCommand + let score: Int + let titleMatchIndices: Set + + var id: String { command.id } + } + + private struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? + } + + private static let fixedSidebarResizeCursor = NSCursor( + image: NSCursor.resizeLeftRight.image, + hotSpot: NSCursor.resizeLeftRight.hotSpot + ) + private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" + private static let commandPaletteCommandsPrefix = ">" + private static let minimumSidebarWidth: CGFloat = 186 + private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 + + private enum SidebarResizerHandle: Hashable { + case divider + } + + private var sidebarResizerHitWidthPerSide: CGFloat { + SidebarResizeInteraction.hitWidthPerSide + } + + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { + let resolvedAvailableWidth = availableWidth + ?? observedWindow?.contentView?.bounds.width + ?? observedWindow?.contentLayoutRect.width + ?? NSApp.keyWindow?.contentView?.bounds.width + ?? NSApp.keyWindow?.contentLayoutRect.width + if let resolvedAvailableWidth, resolvedAvailableWidth > 0 { + return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio) + } + + let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width + ?? NSScreen.main?.frame.width + ?? 1920 + return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio) + } + + private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth) + ) + guard abs(nextWidth - sidebarWidth) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + } + + private func normalizedSidebarWidth(_ candidate: CGFloat) -> CGFloat { + let minWidth = CGFloat(SessionPersistencePolicy.minimumSidebarWidth) + let maxWidth = max(minWidth, maxSidebarWidth()) + if !candidate.isFinite { + return CGFloat(SessionPersistencePolicy.defaultSidebarWidth) + } + return max(minWidth, min(maxWidth, candidate)) + } + + private func activateSidebarResizerCursor() { + sidebarResizerCursorReleaseWorkItem?.cancel() + sidebarResizerCursorReleaseWorkItem = nil + isSidebarResizerCursorActive = true + Self.fixedSidebarResizeCursor.set() + } + + private func releaseSidebarResizerCursorIfNeeded(force: Bool = false) { + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + let shouldKeepCursor = !force + && (isResizerDragging || isResizerBandActive || !hoveredResizerHandles.isEmpty || isLeftMouseButtonDown) + guard !shouldKeepCursor else { return } + guard isSidebarResizerCursorActive else { return } + isSidebarResizerCursorActive = false + NSCursor.arrow.set() + } + + private func scheduleSidebarResizerCursorRelease(force: Bool = false, delay: TimeInterval = 0) { + sidebarResizerCursorReleaseWorkItem?.cancel() + let workItem = DispatchWorkItem { + sidebarResizerCursorReleaseWorkItem = nil + releaseSidebarResizerCursorIfNeeded(force: force) + } + sidebarResizerCursorReleaseWorkItem = workItem + if delay > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } else { + DispatchQueue.main.async(execute: workItem) + } + } + + private func dividerBandContains(pointInContent point: NSPoint, contentBounds: NSRect) -> Bool { + guard point.y >= contentBounds.minY, point.y <= contentBounds.maxY else { return false } + let minX = sidebarWidth - sidebarResizerHitWidthPerSide + let maxX = sidebarWidth + sidebarResizerHitWidthPerSide + return point.x >= minX && point.x <= maxX + } + + private func updateSidebarResizerBandState(using event: NSEvent? = nil) { + guard sidebarState.isVisible, + let window = observedWindow, + let contentView = window.contentView else { + isResizerBandActive = false + scheduleSidebarResizerCursorRelease(force: true) + return + } + + // Use live global pointer location instead of per-event coordinates. + // Overlapping tracking areas (notably WKWebView) can deliver stale/jittery + // event locations during cursor updates, which causes visible cursor flicker. + let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let pointInContent = contentView.convert(pointInWindow, from: nil) + let isInDividerBand = dividerBandContains(pointInContent: pointInContent, contentBounds: contentView.bounds) + isResizerBandActive = isInDividerBand + + if isInDividerBand || isResizerDragging { + activateSidebarResizerCursor() + startSidebarResizerCursorStabilizer() + // AppKit cursorUpdate handlers from overlapped portal/web views can run + // after our local monitor callback and temporarily reset the cursor. + // Re-assert on the next runloop turn to keep the resize cursor stable. + DispatchQueue.main.async { + Self.fixedSidebarResizeCursor.set() + } + } else { + stopSidebarResizerCursorStabilizer() + scheduleSidebarResizerCursorRelease() + } + } + + private func startSidebarResizerCursorStabilizer() { + guard sidebarResizerCursorStabilizer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(16), leeway: .milliseconds(2)) + timer.setEventHandler { + updateSidebarResizerBandState() + if isResizerBandActive || isResizerDragging { + Self.fixedSidebarResizeCursor.set() + } else { + stopSidebarResizerCursorStabilizer() + } + } + sidebarResizerCursorStabilizer = timer + timer.resume() + } + + private func stopSidebarResizerCursorStabilizer() { + sidebarResizerCursorStabilizer?.cancel() + sidebarResizerCursorStabilizer = nil + } + + private func installSidebarResizerPointerMonitorIfNeeded() { + guard sidebarResizerPointerMonitor == nil else { return } + observedWindow?.acceptsMouseMovedEvents = true + sidebarResizerPointerMonitor = NSEvent.addLocalMonitorForEvents( + matching: [ + .mouseMoved, + .mouseEntered, + .mouseExited, + .cursorUpdate, + .appKitDefined, + .systemDefined, + .leftMouseDown, + .leftMouseUp, + .leftMouseDragged, + ] + ) { event in + updateSidebarResizerBandState(using: event) + let shouldOverrideCursorEvent: Bool = { + switch event.type { + case .cursorUpdate, .mouseMoved, .mouseEntered, .mouseExited, .appKitDefined, .systemDefined: + return true + default: + return false + } + }() + if shouldOverrideCursorEvent, (isResizerBandActive || isResizerDragging) { + // Consume hover motion in divider band so overlapped views cannot + // continuously reassert their own cursor while we are resizing. + activateSidebarResizerCursor() + Self.fixedSidebarResizeCursor.set() + return nil + } + return event + } + updateSidebarResizerBandState() + } + + private func removeSidebarResizerPointerMonitor() { + if let monitor = sidebarResizerPointerMonitor { + NSEvent.removeMonitor(monitor) + sidebarResizerPointerMonitor = nil + } + isResizerBandActive = false + isSidebarResizerCursorActive = false + stopSidebarResizerCursorStabilizer() + scheduleSidebarResizerCursorRelease(force: true) + } + + private func sidebarResizerHandleOverlay( + _ handle: SidebarResizerHandle, + width: CGFloat, + availableWidth: CGFloat, + accessibilityIdentifier: String? = nil + ) -> some View { + Color.clear + .frame(width: width) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + hoveredResizerHandles.insert(handle) + activateSidebarResizerCursor() + } else { + hoveredResizerHandles.remove(handle) + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + if isLeftMouseButtonDown { + // Keep resize cursor pinned through mouse-down so AppKit + // cursorUpdate events from overlapping views do not flash arrow. + activateSidebarResizerCursor() + } else { + // Give mouse-down + drag-start callbacks time to establish state + // before any cursor pop is attempted. + scheduleSidebarResizerCursorRelease(delay: 0.05) + } + } + updateSidebarResizerBandState() + } + .onDisappear { + hoveredResizerHandles.remove(handle) + isResizerDragging = false + sidebarDragStartWidth = nil + isResizerBandActive = false + scheduleSidebarResizerCursorRelease(force: true) + } + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + if !isResizerDragging { + isResizerDragging = true + sidebarDragStartWidth = sidebarWidth + #if DEBUG + dlog("sidebar.resizeDragStart") + #endif + } + + activateSidebarResizerCursor() + let startWidth = sidebarDragStartWidth ?? sidebarWidth + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width) + ) + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } + } + .onEnded { _ in + if isResizerDragging { + isResizerDragging = false + sidebarDragStartWidth = nil + } + activateSidebarResizerCursor() + scheduleSidebarResizerCursorRelease() + } + ) + .modifier(SidebarResizerAccessibilityModifier(accessibilityIdentifier: accessibilityIdentifier)) + } + + private var sidebarResizerOverlay: some View { + GeometryReader { proxy in + let totalWidth = max(0, proxy.size.width) + let dividerX = min(max(sidebarWidth, 0), totalWidth) + let leadingWidth = max(0, dividerX - sidebarResizerHitWidthPerSide) + + HStack(spacing: 0) { + Color.clear + .frame(width: leadingWidth) + .allowsHitTesting(false) + + sidebarResizerHandleOverlay( + .divider, + width: sidebarResizerHitWidthPerSide * 2, + availableWidth: totalWidth, + accessibilityIdentifier: "SidebarResizer" + ) + + Color.clear + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + } + .frame(width: totalWidth, height: proxy.size.height, alignment: .leading) + .onAppear { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } + .onChange(of: totalWidth) { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } + } + } private var sidebarView: some View { VerticalTabsSidebar( @@ -822,64 +1746,6 @@ struct ContentView: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) - .background(GeometryReader { proxy in - Color.clear - .preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global)) - }) - .overlay(alignment: .trailing) { - Color.clear - .frame(width: sidebarHandleWidth) - .contentShape(Rectangle()) - .accessibilityIdentifier("SidebarResizer") - .onHover { hovering in - if hovering { - if !isResizerHovering { - NSCursor.resizeLeftRight.push() - isResizerHovering = true - } - } else if isResizerHovering { - if !isResizerDragging { - NSCursor.pop() - isResizerHovering = false - } - } - } - .onDisappear { - if isResizerHovering || isResizerDragging { - NSCursor.pop() - isResizerHovering = false - isResizerDragging = false - } - } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isResizerDragging { - isResizerDragging = true - #if DEBUG - dlog("sidebar.resizeDragStart") - #endif - if !isResizerHovering { - NSCursor.resizeLeftRight.push() - isResizerHovering = true - } - } - let maxSidebarWidth = (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 - let nextWidth = max(186, min(maxSidebarWidth, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) - withTransaction(Transaction(animation: nil)) { - sidebarWidth = nextWidth - } - } - .onEnded { _ in - if isResizerDragging { - isResizerDragging = false - if !isResizerHovering { - NSCursor.pop() - } - } - } - ) - } } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -897,7 +1763,11 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id - let isInputActive = isSelectedWorkspace || isRetiringWorkspace + // Keep the retiring workspace visible during handoff, but never input-active. + // Allowing both selected+retiring workspaces to be input-active lets the + // old workspace steal first responder (notably with WKWebView), which can + // delay handoff completion and make browser returns feel laggy. + let isInputActive = isSelectedWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( @@ -905,7 +1775,15 @@ struct ContentView: View { isWorkspaceVisible: isVisible, isWorkspaceInputActive: isInputActive, workspacePortalPriority: portalPriority, - onThemeRefreshRequest: nil + onThemeRefreshRequest: { reason, eventId, source, payloadHex in + scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: tab.id, + reason: reason, + backgroundEventId: eventId, + backgroundSource: source, + notificationPayloadHex: payloadHex + ) + } ) .opacity(isVisible ? 1 : 0) .allowsHitTesting(isSelectedWorkspace) @@ -958,19 +1836,11 @@ struct ContentView: View { ? Color.black.opacity(0.78) : Color.white.opacity(0.82) } - private var fakeTitlebarSeparatorColor: Color { - _ = titlebarThemeGeneration - let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor - return ghosttyBackground.isLightColor - ? Color.black.opacity(0.18) - : Color.white.opacity(0.22) - } - private var fullscreenControls: some View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, - onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, + onToggleSidebar: { sidebarState.toggle() }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, @@ -988,6 +1858,7 @@ struct ContentView: View { WindowDragHandleView() TitlebarLeadingInsetReader(inset: $titlebarLeadingInset) + .allowsHitTesting(false) HStack(spacing: 8) { if isFullScreen && !sidebarState.isVisible { @@ -1003,6 +1874,7 @@ struct ContentView: View { .font(.system(size: 13, weight: .bold)) .foregroundColor(fakeTitlebarTextColor) .lineLimit(1) + .allowsHitTesting(false) Spacer() @@ -1015,13 +1887,10 @@ struct ContentView: View { .frame(height: titlebarPadding) .frame(maxWidth: .infinity) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - NSApp.keyWindow?.zoom(nil) - } .background(fakeTitlebarBackground) .overlay(alignment: .bottom) { Rectangle() - .fill(fakeTitlebarSeparatorColor) + .fill(Color(nsColor: .separatorColor)) .frame(height: 1) } } @@ -1046,12 +1915,47 @@ struct ContentView: View { } } - private func scheduleTitlebarThemeRefresh() { - titlebarThemeUpdateCoalescer.signal { - titlebarThemeGeneration &+= 1 + private func scheduleTitlebarThemeRefresh( + reason: String, + backgroundEventId: UInt64? = nil, + backgroundSource: String? = nil, + notificationPayloadHex: String? = nil + ) { + let previousGeneration = titlebarThemeGeneration + titlebarThemeGeneration &+= 1 + if GhosttyApp.shared.backgroundLogEnabled { + let eventLabel = backgroundEventId.map(String.init) ?? "nil" + let sourceLabel = backgroundSource ?? "nil" + let payloadLabel = notificationPayloadHex ?? "nil" + GhosttyApp.shared.logBackground( + "titlebar theme refresh scheduled reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousGeneration=\(previousGeneration) generation=\(titlebarThemeGeneration) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) } } + private func scheduleTitlebarThemeRefreshFromWorkspace( + workspaceId: UUID, + reason: String, + backgroundEventId: UInt64?, + backgroundSource: String?, + notificationPayloadHex: String? + ) { + guard tabManager.selectedTabId == workspaceId else { + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh skipped workspace=\(workspaceId.uuidString) selected=\(tabManager.selectedTabId?.uuidString ?? "nil") reason=\(reason)" + ) + return + } + + scheduleTitlebarThemeRefresh( + reason: reason, + backgroundEventId: backgroundEventId, + backgroundSource: backgroundSource, + notificationPayloadHex: notificationPayloadHex + ) + } + private var focusedDirectory: String? { guard let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else { @@ -1070,10 +1974,11 @@ struct ContentView: View { } private var contentAndSidebarLayout: AnyView { + let layout: AnyView if sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue { // Overlay mode: terminal extends full width, sidebar on top // This allows withinWindow blur to see the terminal content - return AnyView( + layout = AnyView( ZStack(alignment: .leading) { terminalContentWithSidebarDropOverlay .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) @@ -1082,22 +1987,33 @@ struct ContentView: View { } } ) + } else { + // Standard HStack mode for behindWindow blur + layout = AnyView( + HStack(spacing: 0) { + if sidebarState.isVisible { + sidebarView + } + terminalContentWithSidebarDropOverlay + } + ) } - // Standard HStack mode for behindWindow blur return AnyView( - HStack(spacing: 0) { - if sidebarState.isVisible { - sidebarView + layout + .overlay(alignment: .leading) { + if sidebarState.isVisible { + sidebarResizerOverlay + .zIndex(1000) + } } - terminalContentWithSidebarDropOverlay - } ) } var body: some View { var view = AnyView( contentAndSidebarLayout + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls @@ -1113,6 +2029,14 @@ struct ContentView: View { tabManager.applyWindowBackgroundForSelectedTab() reconcileMountedWorkspaceIds() previousSelectedWorkspaceId = tabManager.selectedTabId + installSidebarResizerPointerMonitorIfNeeded() + let restoredWidth = normalizedSidebarWidth(sidebarState.persistedWidth) + if abs(sidebarWidth - restoredWidth) > 0.5 { + sidebarWidth = restoredWidth + } + if abs(sidebarState.persistedWidth - restoredWidth) > 0.5 { + sidebarState.persistedWidth = restoredWidth + } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -1178,12 +2102,11 @@ struct ContentView: View { scheduleTitlebarTextRefresh() }) - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyConfigDidReload"))) { _ in - scheduleTitlebarThemeRefresh() - }) - - view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: Notification.Name("ghosttyDefaultBackgroundDidChange"))) { _ in - scheduleTitlebarThemeRefresh() + view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in + guard GhosttyApp.shared.backgroundLogEnabled else { return } + GhosttyApp.shared.logBackground( + "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" + ) }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in @@ -1192,6 +2115,25 @@ struct ContentView: View { completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in + guard let webView = notification.object as? WKWebView, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + let focusedPanelId = selectedWorkspace.focusedPanelId, + let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), + focusedBrowser.webView === webView else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in + guard let panelId = notification.object as? UUID, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + selectedWorkspace.focusedPanelId == panelId, + selectedWorkspace.browserPanel(for: panelId) != nil else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + }) + view = AnyView(view.onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { @@ -1227,10 +2169,97 @@ struct ContentView: View { #endif }) - view = AnyView(view.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in - sidebarMinX = frame.minX + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteToggleRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + toggleCommandPalette() }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteCommands() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSwitcherRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteSwitcher() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteRenameTabInput() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in + guard isCommandPalettePresented else { return } + guard case .commands = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } + moveCommandPaletteSelection(by: delta) + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputInteractionRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + handleCommandPaletteRenameInputInteraction() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputDeleteBackwardRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) + }) + + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in + MainActor.assumeIsolated { + let overlayController = commandPaletteWindowOverlayController(for: window) + overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) + } + })) + view = AnyView(view.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }) @@ -1255,14 +2284,56 @@ struct ContentView: View { AppDelegate.shared?.fullscreenControlsViewModel = nil }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + updateSidebarResizerBandState() + }) + + view = AnyView(view.onChange(of: sidebarWidth) { _ in + let sanitized = normalizedSidebarWidth(sidebarWidth) + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + return + } + if abs(sidebarState.persistedWidth - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + } + updateSidebarResizerBandState() + }) + + view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in + updateSidebarResizerBandState() + }) + + view = AnyView(view.onChange(of: sidebarState.persistedWidth) { newValue in + let sanitized = normalizedSidebarWidth(newValue) + if abs(newValue - sanitized) > 0.5 { + sidebarState.persistedWidth = sanitized + return + } + guard !isResizerDragging else { return } + if abs(sidebarWidth - sanitized) > 0.5 { + sidebarWidth = sanitized + } + }) + view = AnyView(view.ignoresSafeArea()) + view = AnyView(view.onDisappear { + removeSidebarResizerPointerMonitor() + }) + view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) window.titlebarAppearsTransparent = true // Do not make the entire background draggable; it interferes with drag gestures // like sidebar tab reordering in multi-window mode. window.isMovableByWindowBackground = false + // Keep the window immovable by default so titlebar controls (like the folder icon) + // cannot accidentally initiate native window drags. + window.isMovable = false window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -1270,6 +2341,10 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + syncCommandPaletteDebugStateForObservedWindow() + installSidebarResizerPointerMonitorIfNeeded() + updateSidebarResizerBandState() } } @@ -1469,6 +2544,2210 @@ struct ContentView: View { #endif } + private var commandPaletteOverlay: some View { + GeometryReader { proxy in + let maxAllowedWidth = max(340, proxy.size.width - 260) + let targetWidth = min(560, maxAllowedWidth) + + ZStack(alignment: .top) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + dismissCommandPalette() + } + + VStack(spacing: 0) { + switch commandPaletteMode { + case .commands: + commandPaletteCommandListView + case .renameInput(let target): + commandPaletteRenameInputView(target: target) + case let .renameConfirm(target, proposedName): + commandPaletteRenameConfirmView(target: target, proposedName: proposedName) + } + } + .frame(width: targetWidth) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.98)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5) + .padding(.top, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onExitCommand { + dismissCommandPalette() + } + .zIndex(2000) + } + + private var commandPaletteCommandListView: some View { + let visibleResults = Array(commandPaletteResults) + let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListMaxHeight: CGFloat = 450 + let commandPaletteRowHeight: CGFloat = 24 + let commandPaletteEmptyStateHeight: CGFloat = 44 + let commandPaletteListContentHeight = visibleResults.isEmpty + ? commandPaletteEmptyStateHeight + : CGFloat(visibleResults.count) * commandPaletteRowHeight + let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) + return VStack(spacing: 0) { + HStack(spacing: 8) { + TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) + .focused($isCommandPaletteSearchFocused) + .onSubmit { + runSelectedCommandPaletteResult(visibleResults: visibleResults) + } + .backport.onKeyPress(.downArrow) { _ in + moveCommandPaletteSelection(by: 1) + return .handled + } + .backport.onKeyPress(.upArrow) { _ in + moveCommandPaletteSelection(by: -1) + return .handled + } + .backport.onKeyPress("n") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("p") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + .backport.onKeyPress("j") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("k") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? cmuxAccentColor().opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) + + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } + } + } + } + } + .scrollTargetLayout() + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor + ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) + } + + // Keep Esc-to-close behavior without showing footer controls. + Button(action: { dismissCommandPalette() }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + commandPaletteHoveredResultIndex = nil + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) + resetCommandPaletteSearchFocus() + } + .onChange(of: commandPaletteQuery) { _ in + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: visibleResults.count) { _ in + commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + commandPaletteHoveredResultIndex = nil + } + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteSelectedResultIndex) { _ in + syncCommandPaletteDebugStateForObservedWindow() + } + } + + private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { + VStack(spacing: 0) { + TextField(target.placeholder, text: $commandPaletteRenameDraft) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(Color(nsColor: sidebarActiveForegroundNSColor(opacity: 1.0))) + .focused($isCommandPaletteRenameFocused) + .backport.onKeyPress(.delete) { modifiers in + handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) + } + .onSubmit { + continueRenameFlow(target: target) + } + .onTapGesture { + handleCommandPaletteRenameInputInteraction() + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Enter a \(renameTargetNoun(target)) name. Press Enter to rename, Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + continueRenameFlow(target: target) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + resetCommandPaletteRenameFocus() + } + } + + private func commandPaletteRenameConfirmView( + target: CommandPaletteRenameTarget, + proposedName: String + ) -> some View { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextName = trimmedName.isEmpty ? "(clear custom name)" : trimmedName + + return VStack(spacing: 0) { + Text(nextName) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Press Enter to apply this \(renameTargetNoun(target)) name, or Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + applyRenameFlow(target: target, proposedName: proposedName) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + + private func renameTargetNoun(_ target: CommandPaletteRenameTarget) -> String { + switch target.kind { + case .workspace: + return "workspace" + case .tab: + return "tab" + } + } + + private var commandPaletteListScope: CommandPaletteListScope { + if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) { + return .commands + } + return .switcher + } + + private var commandPaletteSearchPlaceholder: String { + switch commandPaletteListScope { + case .commands: + return "Type a command" + case .switcher: + return "Search workspaces and tabs" + } + } + + private var commandPaletteEmptyStateText: String { + switch commandPaletteListScope { + case .commands: + return "No commands match your search." + case .switcher: + return "No workspaces or tabs match your search." + } + } + + private var commandPaletteQueryForMatching: String { + switch commandPaletteListScope { + case .commands: + let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count)) + return suffix.trimmingCharacters(in: .whitespacesAndNewlines) + case .switcher: + return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private var commandPaletteEntries: [CommandPaletteCommand] { + switch commandPaletteListScope { + case .commands: + return commandPaletteCommands() + case .switcher: + return commandPaletteSwitcherEntries() + } + } + + private var commandPaletteResults: [CommandPaletteSearchResult] { + let entries = commandPaletteEntries + let query = commandPaletteQueryForMatching + let queryIsEmpty = query.isEmpty + + let results: [CommandPaletteSearchResult] = queryIsEmpty + ? entries.map { entry in + CommandPaletteSearchResult( + command: entry, + score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), + titleMatchIndices: [] + ) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { + return nil + } + return CommandPaletteSearchResult( + command: entry, + score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } + return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + } + } + + private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { + guard !matchedIndices.isEmpty else { + return Text(title).foregroundColor(.primary) + } + + let chars = Array(title) + var index = 0 + var result = Text("") + + while index < chars.count { + let isMatched = matchedIndices.contains(index) + var end = index + 1 + while end < chars.count, matchedIndices.contains(end) == isMatched { + end += 1 + } + + let segment = String(chars[index.. CommandPaletteTrailingLabel? { + if let shortcutHint = command.shortcutHint { + return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) + } + + guard commandPaletteListScope == .switcher else { return nil } + if command.id.hasPrefix("switcher.workspace.") { + return CommandPaletteTrailingLabel(text: "Workspace", style: .kind) + } + if command.id.hasPrefix("switcher.surface.") { + return CommandPaletteTrailingLabel(text: "Surface", style: .kind) + } + return nil + } + + private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { + let windowContexts = commandPaletteSwitcherWindowContexts() + guard !windowContexts.isEmpty else { return [] } + + var entries: [CommandPaletteCommand] = [] + let estimatedCount = windowContexts.reduce(0) { partial, context in + partial + max(1, context.tabManager.tabs.count) * 4 + } + entries.reserveCapacity(estimatedCount) + var nextRank = 0 + + for context in windowContexts { + var workspaces = context.tabManager.tabs + guard !workspaces.isEmpty else { continue } + + let selectedWorkspaceId = context.selectedWorkspaceId ?? context.tabManager.selectedTabId + if let selectedWorkspaceId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + let windowId = context.windowId + let windowTabManager = context.tabManager + let windowKeywords = commandPaletteWindowKeywords(windowLabel: context.windowLabel) + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "workspace", + "switch", + "go", + "open", + workspaceName + ] + windowKeywords, + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace + ) + let workspaceId = workspace.id + entries.append( + CommandPaletteCommand( + id: workspaceCommandId, + rank: nextRank, + title: workspaceName, + subtitle: commandPaletteSwitcherSubtitle(base: "Workspace", windowLabel: context.windowLabel), + shortcutHint: nil, + keywords: workspaceKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: nil + ) + } + ) + ) + nextRank += 1 + + var orderedPanelIds = workspace.sidebarOrderedPanelIds() + if let focusedPanelId = workspace.focusedPanelId, + let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { + orderedPanelIds.remove(at: focusedIndex) + orderedPanelIds.insert(focusedPanelId, at: 0) + } + + for panelId in orderedPanelIds { + guard let panel = workspace.panels[panelId] else { continue } + let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) + let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" + let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "tab", + "surface", + "panel", + "switch", + "go", + workspaceName, + panelTitle, + typeLabel.lowercased() + ] + windowKeywords, + metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + ) + entries.append( + CommandPaletteCommand( + id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + rank: nextRank, + title: panelTitle, + subtitle: commandPaletteSwitcherSubtitle( + base: "\(typeLabel) • \(workspaceName)", + windowLabel: context.windowLabel + ), + shortcutHint: nil, + keywords: panelKeywords, + dismissOnRun: true, + action: { + focusCommandPaletteSwitcherTarget( + windowId: windowId, + tabManager: windowTabManager, + workspaceId: workspaceId, + panelId: panelId + ) + } + ) + ) + nextRank += 1 + } + } + } + + return entries + } + + private func commandPaletteSwitcherWindowContexts() -> [CommandPaletteSwitcherWindowContext] { + let fallback = CommandPaletteSwitcherWindowContext( + windowId: windowId, + tabManager: tabManager, + selectedWorkspaceId: tabManager.selectedTabId, + windowLabel: nil + ) + + guard let appDelegate = AppDelegate.shared else { return [fallback] } + let summaries = appDelegate.listMainWindowSummaries() + guard !summaries.isEmpty else { return [fallback] } + + let orderedSummaries = summaries.sorted { lhs, rhs in + let lhsIsCurrent = lhs.windowId == windowId + let rhsIsCurrent = rhs.windowId == windowId + if lhsIsCurrent != rhsIsCurrent { return lhsIsCurrent } + if lhs.isKeyWindow != rhs.isKeyWindow { return lhs.isKeyWindow } + if lhs.isVisible != rhs.isVisible { return lhs.isVisible } + return lhs.windowId.uuidString < rhs.windowId.uuidString + } + + var windowLabelById: [UUID: String] = [:] + if orderedSummaries.count > 1 { + for (index, summary) in orderedSummaries.enumerated() where summary.windowId != windowId { + windowLabelById[summary.windowId] = "Window \(index + 1)" + } + } + + var contexts: [CommandPaletteSwitcherWindowContext] = [] + var seenWindowIds: Set = [] + for summary in orderedSummaries { + guard let manager = appDelegate.tabManagerFor(windowId: summary.windowId) else { continue } + guard seenWindowIds.insert(summary.windowId).inserted else { continue } + contexts.append( + CommandPaletteSwitcherWindowContext( + windowId: summary.windowId, + tabManager: manager, + selectedWorkspaceId: summary.selectedWorkspaceId, + windowLabel: windowLabelById[summary.windowId] + ) + ) + } + + if contexts.isEmpty { + return [fallback] + } + return contexts + } + + private func commandPaletteSwitcherSubtitle(base: String, windowLabel: String?) -> String { + guard let windowLabel else { return base } + return "\(base) • \(windowLabel)" + } + + private func commandPaletteWindowKeywords(windowLabel: String?) -> [String] { + guard let windowLabel else { return [] } + return ["window", windowLabel.lowercased()] + } + + private func focusCommandPaletteSwitcherTarget( + windowId: UUID, + tabManager: TabManager, + workspaceId: UUID, + panelId: UUID? + ) { + // Switcher commands dismiss the palette after action dispatch. + // Defer focus mutation one turn so browser omnibar autofocus can run + // without being blocked by the palette-visibility guard. + DispatchQueue.main.async { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + if let panelId { + tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true) + } else { + tabManager.focusTab(workspaceId, suppressFlash: true) + } + } + } + + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { + // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. + let directories = [workspace.currentDirectory] + let branches = [workspace.gitBranch?.branch].compactMap { $0 } + let ports = workspace.listeningPorts + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata { + var directories: [String] = [] + if let directory = workspace.panelDirectories[panelId] { + directories.append(directory) + } else if workspace.focusedPanelId == panelId { + directories.append(workspace.currentDirectory) + } + + var branches: [String] = [] + if let branch = workspace.panelGitBranches[panelId]?.branch { + branches.append(branch) + } else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch { + branches.append(branch) + } + + var ports = workspace.surfaceListeningPorts[panelId] ?? [] + if ports.isEmpty, workspace.panels.count == 1 { + ports = workspace.listeningPorts + } + + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPaletteCommands() -> [CommandPaletteCommand] { + let context = commandPaletteContextSnapshot() + let contributions = commandPaletteCommandContributions() + var handlerRegistry = CommandPaletteHandlerRegistry() + registerCommandPaletteHandlers(&handlerRegistry) + + var commands: [CommandPaletteCommand] = [] + commands.reserveCapacity(contributions.count) + var nextRank = 0 + + for contribution in contributions { + guard contribution.when(context), contribution.enablement(context) else { continue } + guard let action = handlerRegistry.handler(for: contribution.commandId) else { + assertionFailure("No command palette handler registered for \(contribution.commandId)") + continue + } + commands.append( + CommandPaletteCommand( + id: contribution.commandId, + rank: nextRank, + title: contribution.title(context), + subtitle: contribution.subtitle(context), + shortcutHint: commandPaletteShortcutHint(for: contribution, context: context), + keywords: contribution.keywords, + dismissOnRun: contribution.dismissOnRun, + action: action + ) + ) + nextRank += 1 + } + + return commands + } + + private func commandPaletteShortcutHint( + for contribution: CommandPaletteCommandContribution, + context: CommandPaletteContextSnapshot + ) -> String? { + // Preserve browser reload semantics for Cmd+R when a browser tab is focused. + if contribution.commandId == "palette.renameTab", + context.bool(CommandPaletteContextKeys.panelIsBrowser) { + return nil + } + if let action = commandPaletteShortcutAction(for: contribution.commandId) { + return KeyboardShortcutSettings.shortcut(for: action).displayString + } + if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) { + return staticShortcut + } + return contribution.shortcutHint + } + + private func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? { + switch commandId { + case "palette.newWorkspace": + return .newTab + case "palette.newWindow": + return .newWindow + case "palette.newTerminalTab": + return .newSurface + case "palette.newBrowserTab": + return .openBrowser + case "palette.closeWindow": + return .closeWindow + case "palette.toggleSidebar": + return .toggleSidebar + case "palette.showNotifications": + return .showNotifications + case "palette.jumpUnread": + return .jumpToUnread + case "palette.renameTab": + return .renameTab + case "palette.renameWorkspace": + return .renameWorkspace + case "palette.nextWorkspace": + return .nextSidebarTab + case "palette.previousWorkspace": + return .prevSidebarTab + case "palette.nextTabInPane": + return .nextSurface + case "palette.previousTabInPane": + return .prevSurface + case "palette.browserToggleDevTools": + return .toggleBrowserDeveloperTools + case "palette.browserConsole": + return .showBrowserJavaScriptConsole + case "palette.browserSplitRight", "palette.terminalSplitBrowserRight": + return .splitBrowserRight + case "palette.browserSplitDown", "palette.terminalSplitBrowserDown": + return .splitBrowserDown + case "palette.terminalSplitRight": + return .splitRight + case "palette.terminalSplitDown": + return .splitDown + default: + return nil + } + } + + private func commandPaletteStaticShortcutHint(for commandId: String) -> String? { + switch commandId { + case "palette.closeTab": + return "⌘W" + case "palette.closeWorkspace": + return "⌘⇧W" + case "palette.reopenClosedBrowserTab": + return "⌘⇧T" + case "palette.openSettings": + return "⌘," + case "palette.browserBack": + return "⌘[" + case "palette.browserForward": + return "⌘]" + case "palette.browserReload": + return "⌘R" + case "palette.browserFocusAddressBar": + return "⌘L" + case "palette.browserZoomIn": + return "⌘=" + case "palette.browserZoomOut": + return "⌘-" + case "palette.browserZoomReset": + return "⌘0" + case "palette.terminalFind": + return "⌘F" + case "palette.terminalFindNext": + return "⌘G" + case "palette.terminalFindPrevious": + return "⌘⇧G" + case "palette.terminalHideFind": + return "⌘⇧F" + case "palette.terminalUseSelectionForFind": + return "⌘E" + default: + return nil + } + } + + private func commandPaletteContextSnapshot() -> CommandPaletteContextSnapshot { + var snapshot = CommandPaletteContextSnapshot() + + if let workspace = tabManager.selectedWorkspace { + snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) + snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) + snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + snapshot.setBool( + CommandPaletteContextKeys.workspaceHasPullRequests, + !workspace.sidebarPullRequestsInDisplayOrder().isEmpty + ) + } + + if let panelContext = focusedPanelContext { + let workspace = panelContext.workspace + let panelId = panelContext.panelId + let panelIsTerminal = panelContext.panel.panelType == .terminal + snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) + snapshot.setString( + CommandPaletteContextKeys.panelName, + panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) + ) + snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) + snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) + snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) + let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) + || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + + if panelIsTerminal { + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + snapshot.setBool( + CommandPaletteContextKeys.terminalOpenTargetAvailable(target), + availableTargets.contains(target) + ) + } + } + } + + if case .updateAvailable = updateViewModel.effectiveState { + snapshot.setBool(CommandPaletteContextKeys.updateHasAvailable, true) + } + + return snapshot + } + + private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.workspaceName) ?? "Workspace" + return "Workspace • \(name)" + } + + func panelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Tab • \(name)" + } + + func browserPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Browser • \(name)" + } + + func terminalPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Terminal • \(name)" + } + + var contributions: [CommandPaletteCommandContribution] = [] + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspace", + title: constant("New Workspace"), + subtitle: constant("Workspace"), + keywords: ["create", "new", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWindow", + title: constant("New Window"), + subtitle: constant("Window"), + keywords: ["create", "new", "window"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newTerminalTab", + title: constant("New Tab (Terminal)"), + subtitle: constant("Tab"), + shortcutHint: "⌘T", + keywords: ["new", "terminal", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newBrowserTab", + title: constant("New Tab (Browser)"), + subtitle: constant("Tab"), + shortcutHint: "⌘⇧L", + keywords: ["new", "browser", "tab", "web"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeTab", + title: constant("Close Tab"), + subtitle: constant("Tab"), + shortcutHint: "⌘W", + keywords: ["close", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspace", + title: constant("Close Workspace"), + subtitle: constant("Workspace"), + shortcutHint: "⌘⇧W", + keywords: ["close", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWindow", + title: constant("Close Window"), + subtitle: constant("Window"), + keywords: ["close", "window"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.reopenClosedBrowserTab", + title: constant("Reopen Closed Browser Tab"), + subtitle: constant("Browser"), + shortcutHint: "⌘⇧T", + keywords: ["reopen", "closed", "browser"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleSidebar", + title: constant("Toggle Sidebar"), + subtitle: constant("Layout"), + keywords: ["toggle", "sidebar", "layout"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.showNotifications", + title: constant("Show Notifications"), + subtitle: constant("Notifications"), + keywords: ["notifications", "inbox"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.jumpUnread", + title: constant("Jump to Latest Unread"), + subtitle: constant("Notifications"), + keywords: ["jump", "unread", "notification"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openSettings", + title: constant("Open Settings"), + subtitle: constant("Global"), + shortcutHint: "⌘,", + keywords: ["settings", "preferences"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.checkForUpdates", + title: constant("Check for Updates"), + subtitle: constant("Global"), + keywords: ["update", "upgrade", "release"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.applyUpdateIfAvailable", + title: constant("Apply Update (If Available)"), + subtitle: constant("Global"), + keywords: ["apply", "install", "update", "available"], + when: { $0.bool(CommandPaletteContextKeys.updateHasAvailable) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.attemptUpdate", + title: constant("Attempt Update"), + subtitle: constant("Global"), + keywords: ["attempt", "check", "update", "upgrade", "release"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.restartSocketListener", + title: constant("Restart CLI Listener"), + subtitle: constant("Global"), + keywords: ["restart", "socket", "listener", "cli", "cmux", "control"] + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameWorkspace", + title: constant("Rename Workspace…"), + subtitle: workspaceSubtitle, + keywords: ["rename", "workspace", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearWorkspaceName", + title: constant("Clear Workspace Name"), + subtitle: workspaceSubtitle, + keywords: ["clear", "workspace", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) + && $0.bool(CommandPaletteContextKeys.workspaceHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleWorkspacePin", + title: { context in + context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? "Pin Workspace" : "Unpin Workspace" + }, + subtitle: workspaceSubtitle, + keywords: ["workspace", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextWorkspace", + title: constant("Next Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["next", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousWorkspace", + title: constant("Previous Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["previous", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameTab", + title: constant("Rename Tab…"), + subtitle: panelSubtitle, + keywords: ["rename", "tab", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearTabName", + title: constant("Clear Tab Name"), + subtitle: panelSubtitle, + keywords: ["clear", "tab", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasFocusedPanel) + && $0.bool(CommandPaletteContextKeys.panelHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabPin", + title: { context in + context.bool(CommandPaletteContextKeys.panelShouldPin) ? "Pin Tab" : "Unpin Tab" + }, + subtitle: panelSubtitle, + keywords: ["tab", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabUnread", + title: { context in + context.bool(CommandPaletteContextKeys.panelHasUnread) ? "Mark Tab as Read" : "Mark Tab as Unread" + }, + subtitle: panelSubtitle, + keywords: ["tab", "read", "unread", "notification"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextTabInPane", + title: constant("Next Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["next", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousTabInPane", + title: constant("Previous Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["previous", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openWorkspacePullRequests", + title: constant("Open All Workspace PR Links"), + subtitle: workspaceSubtitle, + keywords: ["pull", "request", "review", "merge", "pr", "mr", "open", "links", "workspace"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) && + $0.bool(CommandPaletteContextKeys.workspaceHasPullRequests) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserBack", + title: constant("Back"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘[", + keywords: ["browser", "back", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserForward", + title: constant("Forward"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘]", + keywords: ["browser", "forward", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserReload", + title: constant("Reload Page"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘R", + keywords: ["browser", "reload", "refresh"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserOpenDefault", + title: constant("Open Current Page in Default Browser"), + subtitle: browserPanelSubtitle, + keywords: ["open", "default", "external", "browser"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserFocusAddressBar", + title: constant("Focus Address Bar"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘L", + keywords: ["browser", "address", "omnibar", "url"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserToggleDevTools", + title: constant("Toggle Developer Tools"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "devtools", "inspector"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserConsole", + title: constant("Show JavaScript Console"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "console", "javascript"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomIn", + title: constant("Zoom In"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "in"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomOut", + title: constant("Zoom Out"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "out"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomReset", + title: constant("Actual Size"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "reset", "actual size"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserClearHistory", + title: constant("Clear Browser History"), + subtitle: constant("Browser"), + keywords: ["browser", "history", "clear"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitRight", + title: constant("Split Browser Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitDown", + title: constant("Split Browser Down"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserDuplicateRight", + title: constant("Duplicate Browser to the Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "duplicate", "clone", "split"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + contributions.append( + CommandPaletteCommandContribution( + commandId: target.commandPaletteCommandId, + title: constant(target.commandPaletteTitle), + subtitle: terminalPanelSubtitle, + keywords: target.commandPaletteKeywords, + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) + } + ) + ) + } + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFind", + title: constant("Find…"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘F", + keywords: ["terminal", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindNext", + title: constant("Find Next"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘G", + keywords: ["terminal", "find", "next", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindPrevious", + title: constant("Find Previous"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧G", + keywords: ["terminal", "find", "previous", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalHideFind", + title: constant("Hide Find Bar"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧F", + keywords: ["terminal", "hide", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalUseSelectionForFind", + title: constant("Use Selection for Find"), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "selection", "find"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitRight", + title: constant("Split Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitDown", + title: constant("Split Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserRight", + title: constant("Split Browser Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserDown", + title: constant("Split Browser Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + + return contributions + } + + private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.newWorkspace") { + tabManager.addWorkspace() + } + registry.register(commandId: "palette.newWindow") { + AppDelegate.shared?.openNewMainWindow(nil) + } + registry.register(commandId: "palette.newTerminalTab") { + tabManager.newSurface() + } + registry.register(commandId: "palette.newBrowserTab") { + // Let command-palette dismissal complete first so omnibar focus + // is not blocked by the palette visibility guard. + DispatchQueue.main.async { + _ = AppDelegate.shared?.openBrowserAndFocusAddressBar() + } + } + registry.register(commandId: "palette.closeTab") { + tabManager.closeCurrentPanelWithConfirmation() + } + registry.register(commandId: "palette.closeWorkspace") { + tabManager.closeCurrentWorkspaceWithConfirmation() + } + registry.register(commandId: "palette.closeWindow") { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { + NSSound.beep() + return + } + window.performClose(nil) + } + registry.register(commandId: "palette.reopenClosedBrowserTab") { + _ = tabManager.reopenMostRecentlyClosedBrowserPanel() + } + registry.register(commandId: "palette.toggleSidebar") { + sidebarState.toggle() + } + registry.register(commandId: "palette.showNotifications") { + AppDelegate.shared?.toggleNotificationsPopover(animated: false) + } + registry.register(commandId: "palette.jumpUnread") { + AppDelegate.shared?.jumpToLatestUnread() + } + registry.register(commandId: "palette.openSettings") { +#if DEBUG + dlog("palette.openSettings.invoke") +#endif + if let appDelegate = AppDelegate.shared { + appDelegate.openPreferencesWindow(debugSource: "palette.openSettings") + } else { +#if DEBUG + dlog("palette.openSettings.missingAppDelegate fallback=1") +#endif + AppDelegate.presentPreferencesWindow() + } + } + registry.register(commandId: "palette.checkForUpdates") { + AppDelegate.shared?.checkForUpdates(nil) + } + registry.register(commandId: "palette.applyUpdateIfAvailable") { + AppDelegate.shared?.applyUpdateIfAvailable(nil) + } + registry.register(commandId: "palette.attemptUpdate") { + AppDelegate.shared?.attemptUpdate(nil) + } + registry.register(commandId: "palette.restartSocketListener") { + AppDelegate.shared?.restartSocketListener(nil) + } + + registry.register(commandId: "palette.renameWorkspace") { + beginRenameWorkspaceFlow() + } + registry.register(commandId: "palette.clearWorkspaceName") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.clearCustomTitle(tabId: workspace.id) + } + registry.register(commandId: "palette.toggleWorkspacePin") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.setPinned(workspace, pinned: !workspace.isPinned) + } + registry.register(commandId: "palette.nextWorkspace") { + tabManager.selectNextTab() + } + registry.register(commandId: "palette.previousWorkspace") { + tabManager.selectPreviousTab() + } + + registry.register(commandId: "palette.renameTab") { + beginRenameTabFlow() + } + registry.register(commandId: "palette.clearTabName") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil) + } + registry.register(commandId: "palette.toggleTabPin") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelPinned( + panelId: panelContext.panelId, + pinned: !panelContext.workspace.isPanelPinned(panelContext.panelId) + ) + } + registry.register(commandId: "palette.toggleTabUnread") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId) + || notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId) + if hasUnread { + panelContext.workspace.markPanelRead(panelContext.panelId) + } else { + panelContext.workspace.markPanelUnread(panelContext.panelId) + } + } + registry.register(commandId: "palette.nextTabInPane") { + tabManager.selectNextSurface() + } + registry.register(commandId: "palette.previousTabInPane") { + tabManager.selectPreviousSurface() + } + registry.register(commandId: "palette.openWorkspacePullRequests") { + DispatchQueue.main.async { + if !openWorkspacePullRequestsInConfiguredBrowser() { + NSSound.beep() + } + } + } + + registry.register(commandId: "palette.browserBack") { + tabManager.focusedBrowserPanel?.goBack() + } + registry.register(commandId: "palette.browserForward") { + tabManager.focusedBrowserPanel?.goForward() + } + registry.register(commandId: "palette.browserReload") { + tabManager.focusedBrowserPanel?.reload() + } + registry.register(commandId: "palette.browserOpenDefault") { + if !openFocusedBrowserInDefaultBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserFocusAddressBar") { + if !focusFocusedBrowserAddressBar() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserToggleDevTools") { + if !tabManager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserConsole") { + if !tabManager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomIn") { + if !tabManager.zoomInFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomOut") { + if !tabManager.zoomOutFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomReset") { + if !tabManager.resetZoomFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserClearHistory") { + BrowserHistoryStore.shared.clearHistory() + } + registry.register(commandId: "palette.browserSplitRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.browserSplitDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + registry.register(commandId: "palette.browserDuplicateRight") { + let url = tabManager.focusedBrowserPanel?.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + _ = tabManager.createBrowserSplit(direction: .right, url: url) + } + + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + registry.register(commandId: target.commandPaletteCommandId) { + if !openFocusedDirectory(in: target) { + NSSound.beep() + } + } + } + registry.register(commandId: "palette.terminalFind") { + tabManager.startSearch() + } + registry.register(commandId: "palette.terminalFindNext") { + tabManager.findNext() + } + registry.register(commandId: "palette.terminalFindPrevious") { + tabManager.findPrevious() + } + registry.register(commandId: "palette.terminalHideFind") { + tabManager.hideFind() + } + registry.register(commandId: "palette.terminalUseSelectionForFind") { + tabManager.searchSelection() + } + registry.register(commandId: "palette.terminalSplitRight") { + tabManager.createSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitDown") { + tabManager.createSplit(direction: .down) + } + registry.register(commandId: "palette.terminalSplitBrowserRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitBrowserDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + } + + private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { + guard let workspace = tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] else { + return nil + } + return (workspace, panelId, panel) + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let custom = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty { + return custom + } + let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Workspace" : title + } + + private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String { + let title = workspace.panelTitle(panelId: panelId)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedFallback.isEmpty ? "Tab" : trimmedFallback + } + + private func commandPaletteSelectedIndex(resultCount: Int) -> Int { + guard resultCount > 0 else { return 0 } + return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) + } + + static func commandPaletteScrollPositionAnchor( + selectedIndex: Int, + resultCount: Int + ) -> UnitPoint? { + guard resultCount > 0 else { return nil } + if selectedIndex <= 0 { + return UnitPoint.top + } + if selectedIndex >= resultCount - 1 { + return UnitPoint.bottom + } + return nil + } + + private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) { + guard resultCount > 0 else { + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + return + } + + let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount) + commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor( + selectedIndex: selectedIndex, + resultCount: resultCount + ) + + let assignTarget = { + commandPaletteScrollTargetIndex = selectedIndex + } + if animated { + withAnimation(.easeOut(duration: 0.1)) { + assignTarget() + } + } else { + assignTarget() + } + } + + private func moveCommandPaletteSelection(by delta: Int) { + let count = commandPaletteResults.count + guard count > 0 else { + NSSound.beep() + return + } + let current = commandPaletteSelectedIndex(resultCount: count) + commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + syncCommandPaletteDebugStateForObservedWindow() + } + + private func handleCommandPaletteControlNavigationKey( + modifiers: EventModifiers, + delta: Int + ) -> BackportKeyPressResult { + guard modifiers.contains(.control), + !modifiers.contains(.command), + !modifiers.contains(.shift), + !modifiers.contains(.option) else { + return .ignored + } + moveCommandPaletteSelection(by: delta) + return .handled + } + + static func commandPaletteShouldPopRenameInputOnDelete( + renameDraft: String, + modifiers: EventModifiers + ) -> Bool { + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return false } + return renameDraft.isEmpty + } + + private func handleCommandPaletteRenameDeleteBackward( + modifiers: EventModifiers + ) -> BackportKeyPressResult { + guard case .renameInput = commandPaletteMode else { return .ignored } + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return .ignored } + + if Self.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: commandPaletteRenameDraft, + modifiers: modifiers + ) { + commandPaletteMode = .commands + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + if let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor { + editor.deleteBackward(nil) + commandPaletteRenameDraft = editor.string + } else if !commandPaletteRenameDraft.isEmpty { + commandPaletteRenameDraft.removeLast() + } + + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { + let visibleResults = visibleResults ?? Array(commandPaletteResults) + guard !visibleResults.isEmpty else { + NSSound.beep() + return + } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + runCommandPaletteCommand(visibleResults[index].command) + } + + private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { +#if DEBUG + dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") +#endif + recordCommandPaletteUsage(command.id) + command.action() + if command.dismissOnRun { + dismissCommandPalette(restoreFocus: false) + } + } + + private func toggleCommandPalette() { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + } + + private func openCommandPaletteCommands() { + toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + + private func openCommandPaletteSwitcher() { + toggleCommandPalette(initialQuery: "") + } + + private func toggleCommandPalette(initialQuery: String) { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: initialQuery) + } + } + + private func openCommandPaletteRenameTabInput() { + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginRenameTabFlow() + } + + static func shouldHandleCommandPaletteRequest( + observedWindow: NSWindow?, + requestedWindow: NSWindow?, + keyWindow: NSWindow?, + mainWindow: NSWindow? + ) -> Bool { + guard let observedWindow else { return false } + if let requestedWindow { + return requestedWindow === observedWindow + } + if let keyWindow { + return keyWindow === observedWindow + } + if let mainWindow { + return mainWindow === observedWindow + } + return false + } + + static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: Bool, + focusedBrowserAddressBarPanelId: UUID?, + focusedPanelId: UUID + ) -> Bool { + focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId + } + + private func syncCommandPaletteDebugStateForObservedWindow() { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) + let visibleResultCount = commandPaletteResults.count + let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 + AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) + AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) + } + + private func commandPaletteDebugSnapshot() -> CommandPaletteDebugSnapshot { + guard isCommandPalettePresented else { return .empty } + + let mode: String + switch commandPaletteMode { + case .commands: + mode = commandPaletteListScope.rawValue + case .renameInput: + mode = "rename_input" + case .renameConfirm: + mode = "rename_confirm" + } + + let rows = Array(commandPaletteResults.prefix(20)).map { result in + CommandPaletteDebugResultRow( + commandId: result.command.id, + title: result.command.title, + shortcutHint: result.command.shortcutHint, + trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text, + score: result.score + ) + } + + return CommandPaletteDebugSnapshot( + query: commandPaletteQueryForMatching, + mode: mode, + results: rows + ) + } + + private func presentCommandPalette(initialQuery: String) { + if let panelContext = focusedPanelContext { + let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( + focusedPanelIsBrowser: panelContext.panel.panelType == .browser, + focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(), + focusedPanelId: panelContext.panelId + ) + commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( + workspaceId: panelContext.workspace.id, + panelId: panelContext.panelId, + intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel + ) + } else { + commandPaletteRestoreFocusTarget = nil + } + isCommandPalettePresented = true + refreshCommandPaletteUsageHistory() + resetCommandPaletteListState(initialQuery: initialQuery) + } + + private func resetCommandPaletteListState(initialQuery: String) { + commandPaletteMode = .commands + commandPaletteQuery = initialQuery + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func dismissCommandPalette(restoreFocus: Bool = true) { + let focusTarget = commandPaletteRestoreFocusTarget + isCommandPalettePresented = false + commandPaletteMode = .commands + commandPaletteQuery = "" + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = false + commandPaletteRestoreFocusTarget = nil + if let window = observedWindow { + _ = window.makeFirstResponder(nil) + } + syncCommandPaletteDebugStateForObservedWindow() + + guard restoreFocus, let focusTarget else { return } + restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + } + + private func restoreCommandPaletteFocus( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + + if let window = observedWindow, !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) + + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) + return + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard !isCommandPalettePresented else { return } + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6) + return + } + restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func restoreCommandPaletteInputFocusIfNeeded( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard target.intent == .browserAddressBar else { return } + guard attemptsRemaining > 0 else { return } + guard let appDelegate = AppDelegate.shared else { return } + + if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + restoreCommandPaletteInputFocusIfNeeded( + target: target, + attemptsRemaining: attemptsRemaining - 1 + ) + } + } + + private func resetCommandPaletteSearchFocus() { + applyCommandPaletteInputFocusPolicy(.search) + } + + private func resetCommandPaletteRenameFocus() { + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func handleCommandPaletteRenameInputInteraction() { + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy { + let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus + ? .selectAll + : .caretAtEnd + return CommandPaletteInputFocusPolicy( + focusTarget: .rename, + selectionBehavior: selectionBehavior + ) + } + + private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) { + DispatchQueue.main.async { + switch policy.focusTarget { + case .search: + isCommandPaletteRenameFocused = false + isCommandPaletteSearchFocused = true + case .rename: + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = true + } + applyCommandPaletteTextSelection(policy.selectionBehavior) + } + } + + private func applyCommandPaletteTextSelection( + _ behavior: CommandPaletteTextSelectionBehavior, + attemptsRemaining: Int = 20 + ) { + guard isCommandPalettePresented else { return } + switch behavior { + case .selectAll: + guard case .renameInput = commandPaletteMode else { return } + case .caretAtEnd: + switch commandPaletteMode { + case .commands, .renameInput: + break + case .renameConfirm: + return + } + } + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + return + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func refreshCommandPaletteUsageHistory() { + commandPaletteUsageHistoryByCommandId = loadCommandPaletteUsageHistory() + } + + private func loadCommandPaletteUsageHistory() -> [String: CommandPaletteUsageEntry] { + guard let data = UserDefaults.standard.data(forKey: Self.commandPaletteUsageDefaultsKey) else { + return [:] + } + return (try? JSONDecoder().decode([String: CommandPaletteUsageEntry].self, from: data)) ?? [:] + } + + private func persistCommandPaletteUsageHistory(_ history: [String: CommandPaletteUsageEntry]) { + guard let data = try? JSONEncoder().encode(history) else { return } + UserDefaults.standard.set(data, forKey: Self.commandPaletteUsageDefaultsKey) + } + + private func recordCommandPaletteUsage(_ commandId: String) { + var history = commandPaletteUsageHistoryByCommandId + var entry = history[commandId] ?? CommandPaletteUsageEntry(useCount: 0, lastUsedAt: 0) + entry.useCount += 1 + entry.lastUsedAt = Date().timeIntervalSince1970 + history[commandId] = entry + commandPaletteUsageHistoryByCommandId = history + persistCommandPaletteUsageHistory(history) + } + + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + + let now = Date().timeIntervalSince1970 + let ageDays = max(0, now - entry.lastUsedAt) / 86_400 + let recencyBoost = max(0, 320 - Int(ageDays * 20)) + let countBoost = min(180, entry.useCount * 12) + let totalBoost = recencyBoost + countBoost + + return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) + } + + private func beginRenameWorkspaceFlow() { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + let target = CommandPaletteRenameTarget( + kind: .workspace(workspaceId: workspace.id), + currentName: workspaceDisplayName(workspace) + ) + startRenameFlow(target) + } + + private func beginRenameTabFlow() { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let panelName = panelDisplayName( + workspace: panelContext.workspace, + panelId: panelContext.panelId, + fallback: panelContext.panel.displayTitle + ) + let target = CommandPaletteRenameTarget( + kind: .tab(workspaceId: panelContext.workspace.id, panelId: panelContext.panelId), + currentName: panelName + ) + startRenameFlow(target) + } + + private func startRenameFlow(_ target: CommandPaletteRenameTarget) { + commandPaletteRenameDraft = target.currentName + commandPaletteMode = .renameInput(target) + resetCommandPaletteRenameFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func continueRenameFlow(target: CommandPaletteRenameTarget) { + guard case .renameInput(let activeTarget) = commandPaletteMode, + activeTarget == target else { return } + applyRenameFlow(target: target, proposedName: commandPaletteRenameDraft) + } + + private func applyRenameFlow(target: CommandPaletteRenameTarget, proposedName: String) { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedName: String? = trimmedName.isEmpty ? nil : trimmedName + + switch target.kind { + case .workspace(let workspaceId): + tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName) + case .tab(let workspaceId, let panelId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + NSSound.beep() + return + } + workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName) + } + + dismissCommandPalette() + } + + private func focusFocusedBrowserAddressBar() -> Bool { + guard let panel = tabManager.focusedBrowserPanel else { return false } + _ = panel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } + + private func openFocusedBrowserInDefaultBrowser() -> Bool { + guard let panel = tabManager.focusedBrowserPanel, + let rawURL = panel.preferredURLStringForOmnibar(), + let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return false + } + return NSWorkspace.shared.open(url) + } + + private func openWorkspacePullRequestsInConfiguredBrowser() -> Bool { + guard let workspace = tabManager.selectedWorkspace else { return false } + let pullRequests = workspace.sidebarPullRequestsInDisplayOrder() + guard !pullRequests.isEmpty else { return false } + + var openedCount = 0 + if openSidebarPullRequestLinksInCmuxBrowser { + for pullRequest in pullRequests { + if tabManager.openBrowser(url: pullRequest.url, insertAtEnd: true) != nil { + openedCount += 1 + } else if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + + for pullRequest in pullRequests { + if NSWorkspace.shared.open(pullRequest.url) { + openedCount += 1 + } + } + return openedCount > 0 + } + + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { + guard let directoryURL = focusedTerminalDirectoryURL() else { return false } + return openFocusedDirectory(directoryURL, in: target) + } + + private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { + switch target { + case .finder: + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + return true + default: + guard let applicationURL = target.applicationURL() else { return false } + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) + return true + } + } + + private func focusedTerminalDirectoryURL() -> URL? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + let rawDirectory: String = { + if let focusedPanelId = workspace.focusedPanelId, + let directory = workspace.panelDirectories[focusedPanelId] { + return directory + } + return workspace.currentDirectory + }() + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard FileManager.default.fileExists(atPath: trimmed) else { return nil } + return URL(fileURLWithPath: trimmed, isDirectory: true) + } + #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } @@ -1486,6 +4765,585 @@ struct ContentView: View { #endif } +struct CommandPaletteSwitcherSearchMetadata { + let directories: [String] + let branches: [String] + let ports: [Int] + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [] + ) { + self.directories = directories + self.branches = branches + self.ports = ports + } +} + +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) + + 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"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + } + + 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 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 = [" ", "-", "_", "/", ".", ":"] + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return 0 } + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return 0 } + + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in 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 { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return [] } + + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return [] } + + let loweredCandidate = normalize(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in tokens { + if token == loweredCandidate { + matched.formUnion(0.. String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private static func scoreToken(_ token: String, in candidate: String) -> 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 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 !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 } + + 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 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 + } +} + +private struct SidebarResizerAccessibilityModifier: ViewModifier { + let accessibilityIdentifier: String? + + @ViewBuilder + func body(content: Content) -> some View { + if let accessibilityIdentifier { + content.accessibilityIdentifier(accessibilityIdentifier) + } else { + content + } + } +} + struct VerticalTabsSidebar: View { @ObservedObject var updateViewModel: UpdateViewModel @EnvironmentObject var tabManager: TabManager @@ -1553,9 +5411,9 @@ struct VerticalTabsSidebar: View { .allowsHitTesting(false) } .overlay(alignment: .top) { - // Double-click the sidebar title-bar area to zoom the - // window, matching the panel top-bar behaviour. - DoubleClickZoomView() + // Match native titlebar behavior in the sidebar top strip: + // drag-to-move and double-click action (zoom/minimize). + WindowDragHandleView() .frame(height: trafficLightPadding) } .background(Color.clear) @@ -2120,14 +5978,6 @@ private struct SidebarTopBlurEffect: NSViewRepresentable { func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} } -private struct SidebarFramePreferenceKey: PreferenceKey { - static var defaultValue: CGRect = .zero - - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() - } -} - private struct SidebarScrollViewResolver: NSViewRepresentable { let onResolve: (NSScrollView?) -> Void @@ -2179,7 +6029,7 @@ private struct SidebarEmptyArea: View { .contentShape(Rectangle()) .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { - tabManager.addTab() + tabManager.addWorkspace(placementOverride: .end) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } @@ -2199,7 +6049,7 @@ private struct SidebarEmptyArea: View { .overlay(alignment: .top) { if shouldShowTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: -(rowSpacing / 2)) @@ -2217,65 +6067,10 @@ private struct SidebarEmptyArea: View { } } -struct SidebarRemoteErrorCopyEntry: Equatable { - let workspaceTitle: String - let target: String - let detail: String -} - -enum SidebarRemoteErrorCopySupport { - static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { - guard !entries.isEmpty else { return nil } - return entries.count == 1 ? "Copy Error" : "Copy Errors" - } - - static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { - guard !entries.isEmpty else { return nil } - if entries.count == 1 { - let entry = entries[0] - return "SSH error (\(entry.target)): \(entry.detail)" - } - - return entries.enumerated().map { index, entry in - "\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)" - }.joined(separator: "\n") - } - - static func parsedTargetAndDetail(from statusValue: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? { - let trimmed = statusValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed.hasPrefix("SSH error") else { return nil } - - let normalizedFallbackTarget: String? = { - guard let fallbackTarget else { return nil } - let trimmedFallback = fallbackTarget.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedFallback.isEmpty ? nil : trimmedFallback - }() - - if let separator = trimmed.range(of: ": ") { - let prefix = String(trimmed[.. Color { + usesInvertedActiveForeground + ? Color(nsColor: sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat(opacity))) + : .secondary + } + + private var activeUnreadBadgeFillColor: Color { + usesInvertedActiveForeground ? Color.white.opacity(0.25) : cmuxAccentColor() + } + + 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) : cmuxAccentColor() + } + + private var shortcutHintEmphasis: Double { + usesInvertedActiveForeground ? 1.0 : 0.9 + } + private var workspaceShortcutDigit: Int? { WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count) } @@ -2339,6 +6204,26 @@ private struct TabItemView: View { return ceil(textWidth) + 12 } + private var copyableSidebarSSHError: String? { + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let target = tab.remoteDisplayTarget ?? "unknown" + return "SSH error (\(target)): \(trimmedDetail)" + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + return statusValue + } + return nil + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { @@ -2346,7 +6231,7 @@ private struct TabItemView: View { if unreadCount > 0 { ZStack { Circle() - .fill(isActive ? Color.white.opacity(0.25) : Color.accentColor) + .fill(activeUnreadBadgeFillColor) Text("\(unreadCount)") .font(.system(size: 9, weight: .semibold)) .foregroundColor(.white) @@ -2357,19 +6242,12 @@ private struct TabItemView: View { if tab.isPinned { Image(systemName: "pin.fill") .font(.system(size: 9, weight: .semibold)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) - } - - if tab.isRemoteWorkspace { - Image(systemName: remoteStateIcon(tab.remoteConnectionState)) - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(remoteStateColor(tab.remoteConnectionState, isActive: isActive)) - .help(remoteStateHelpText) + .foregroundColor(activeSecondaryColor(0.8)) } Text(tab.title) - .font(.system(size: 12.5, weight: .semibold)) - .foregroundColor(isActive ? .white : .primary) + .font(.system(size: 12.5, weight: titleFontWeight)) + .foregroundColor(activePrimaryTextColor) .lineLimit(1) .truncationMode(.tail) @@ -2384,10 +6262,10 @@ private struct TabItemView: View { }) { Image(systemName: "xmark") .font(.system(size: 9, weight: .medium)) - .foregroundColor(isActive ? .white.opacity(0.7) : .secondary) + .foregroundColor(activeSecondaryColor(0.7)) } .buttonStyle(.plain) - .help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))") + .help(KeyboardShortcutSettings.Action.closeWorkspace.tooltip("Close Workspace")) .frame(width: 16, height: 16, alignment: .center) .opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0) .allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint) @@ -2398,10 +6276,10 @@ private struct TabItemView: View { .fixedSize(horizontal: true, vertical: false) .font(.system(size: 10, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundColor(isActive ? .white : .primary) + .foregroundColor(activePrimaryTextColor) .padding(.horizontal, 6) .padding(.vertical, 2) - .background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9)) + .background(ShortcutHintPillBackground(emphasis: shortcutHintEmphasis)) .offset( x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset), y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset) @@ -2416,22 +6294,31 @@ private struct TabItemView: View { if let subtitle = latestNotificationText { Text(subtitle) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(2) .truncationMode(.tail) .multilineTextAlignment(.leading) } - if sidebarShowStatusPills, !tab.statusEntries.isEmpty { - SidebarStatusPillsRow( - entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in - if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } - return lhs.key < rhs.key - }), - isActive: isActive, - onFocus: { updateSelection() } - ) - .transition(.opacity.combined(with: .move(edge: .top))) + if sidebarShowMetadata { + 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 @@ -2439,10 +6326,10 @@ private struct TabItemView: View { HStack(spacing: 4) { Image(systemName: logLevelIcon(latestLog.level)) .font(.system(size: 8)) - .foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) + .foregroundColor(logLevelColor(latestLog.level, isActive: usesInvertedActiveForeground)) Text(latestLog.message) .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .foregroundColor(activeSecondaryColor(0.8)) .lineLimit(1) .truncationMode(.tail) } @@ -2455,9 +6342,9 @@ private struct TabItemView: View { GeometryReader { geo in ZStack(alignment: .leading) { Capsule() - .fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) + .fill(activeProgressTrackColor) Capsule() - .fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) + .fill(activeProgressFillColor) .frame(width: max(0, geo.size.width * CGFloat(progress.value))) } } @@ -2466,7 +6353,7 @@ private struct TabItemView: View { if let label = progress.label { Text(label) .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .foregroundColor(activeSecondaryColor(0.6)) .lineLimit(1) } } @@ -2474,18 +6361,85 @@ private struct TabItemView: View { } // Branch + directory row - if let dirRow = branchDirectoryRow { - HStack(spacing: 3) { - if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + if sidebarShowBranchDirectory { + if sidebarBranchVerticalLayout { + if !verticalBranchDirectoryLines.isEmpty { + HStack(alignment: .top, spacing: 3) { + if sidebarShowGitBranchIcon, sidebarShowGitBranch, verticalRowsContainBranch { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(activeSecondaryColor(0.6)) + } + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(verticalBranchDirectoryLines.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 = branchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranch && gitBranchSummaryText != nil && sidebarShowGitBranchIcon { + 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 sidebarShowPullRequest, !pullRequestDisplays.isEmpty { + VStack(alignment: .leading, spacing: 1) { + ForEach(pullRequestDisplays) { 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)) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(pullRequestForegroundColor) + } + .buttonStyle(.plain) + .help("Open \(pullRequest.label) #\(pullRequest.number)") } - Text(dirRow) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) } } @@ -2493,18 +6447,33 @@ private struct TabItemView: View { if sidebarShowPorts, !tab.listeningPorts.isEmpty { Text(tab.listeningPorts.map { ":\($0)" }.joined(separator: ", ")) .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .foregroundColor(activeSecondaryColor(0.75)) .lineLimit(1) .truncationMode(.tail) } } .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 { @@ -2531,7 +6500,7 @@ private struct TabItemView: View { .overlay(alignment: .top) { if showsCenteredTopDropIndicator { Rectangle() - .fill(Color.accentColor) + .fill(cmuxAccentColor()) .frame(height: 2) .padding(.horizontal, 8) .offset(y: index == 0 ? 0 : -(rowSpacing / 2)) @@ -2555,6 +6524,12 @@ private struct TabItemView: View { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) + .onDrop(of: [BonsplitTabDragPayload.typeIdentifier], delegate: SidebarBonsplitTabDropDelegate( + targetWorkspaceId: tab.id, + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex + )) .onTapGesture { updateSelection() } @@ -2572,11 +6547,7 @@ private struct TabItemView: View { } .contextMenu { let targetIds = contextTargetIds() - let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) } - let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace } - let remoteWorkspaceErrors = remoteErrorCopyEntries(in: targetWorkspaces) - let reconnectLabel = remoteTargetWorkspaces.count > 1 ? "Reconnect Workspaces" : "Reconnect Workspace" - let disconnectLabel = remoteTargetWorkspaces.count > 1 ? "Disconnect Workspaces" : "Disconnect Workspace" + let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned let pinLabel = targetIds.count > 1 ? (shouldPin ? "Pin Workspaces" : "Unpin Workspaces") @@ -2584,6 +6555,8 @@ private struct TabItemView: View { let closeLabel = targetIds.count > 1 ? "Close Workspaces" : "Close Workspace" let markReadLabel = targetIds.count > 1 ? "Mark Workspaces as Read" : "Mark Workspace as Read" let markUnreadLabel = targetIds.count > 1 ? "Mark Workspaces as Unread" : "Mark Workspace as Unread" + let renameWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .renameWorkspace) + let closeWorkspaceShortcut = KeyboardShortcutSettings.shortcut(for: .closeWorkspace) Button(pinLabel) { for id in targetIds { if let tab = tabManager.tabs.first(where: { $0.id == id }) { @@ -2593,8 +6566,15 @@ private struct TabItemView: View { syncSelectionAfterMutation() } - Button("Rename Workspace…") { - promptRename() + if let key = renameWorkspaceShortcut.keyEquivalent { + Button("Rename Workspace…") { + promptRename() + } + .keyboardShortcut(key, modifiers: renameWorkspaceShortcut.eventModifiers) + } else { + Button("Rename Workspace…") { + promptRename() + } } if tab.hasCustomTitle { @@ -2603,33 +6583,44 @@ private struct TabItemView: View { } } - if !remoteTargetWorkspaces.isEmpty || !remoteWorkspaceErrors.isEmpty { - Divider() - - if !remoteTargetWorkspaces.isEmpty { - Button(reconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.reconnectRemoteConnection() - } + Menu("Tab Color") { + if tab.customColor != nil { + Button { + applyTabColor(nil, targetIds: targetIds) + } label: { + Label("Clear Color", systemImage: "xmark.circle") } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting }) - - Button(disconnectLabel) { - for workspace in remoteTargetWorkspaces { - workspace.disconnectRemoteConnection(clearConfiguration: false) - } - } - .disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected }) } - if let copyErrorLabel = SidebarRemoteErrorCopySupport.menuLabel(for: remoteWorkspaceErrors), - let copyErrorText = SidebarRemoteErrorCopySupport.clipboardText(for: remoteWorkspaceErrors) { - Button(copyErrorLabel) { - copyTextToPasteboard(copyErrorText) + Button { + promptCustomColor(targetIds: targetIds) + } label: { + Label("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("Copy SSH Error") { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + Divider() Button("Move Up") { @@ -2648,13 +6639,43 @@ private struct TabItemView: View { } .disabled(targetIds.isEmpty) - Divider() + let referenceWindowId = AppDelegate.shared?.windowId(for: tabManager) + let windowMoveTargets = AppDelegate.shared?.windowMoveTargets(referenceWindowId: referenceWindowId) ?? [] + let moveMenuTitle = targetIds.count > 1 ? "Move Workspaces to Window" : "Move Workspace to Window" + Menu(moveMenuTitle) { + Button("New Window") { + moveWorkspacesToNewWindow(targetIds) + } + .disabled(targetIds.isEmpty) - Button(closeLabel) { - closeTabs(targetIds, allowPinned: true) + 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("Close Other Workspaces") { closeOtherTabs(targetIds) } @@ -2685,13 +6706,49 @@ private struct TabItemView: View { } private var backgroundColor: Color { - if isActive { - return Color.accentColor + switch activeTabIndicatorStyle { + case .leftRail: + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear + case .solidFill: + if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) } + if let custom = resolvedCustomTabColor { + if isMultiSelected { return custom.opacity(0.35) } + return custom.opacity(0.7) + } + if isMultiSelected { return cmuxAccentColor().opacity(0.25) } + return Color.clear } - if isMultiSelected { - return Color.accentColor.opacity(0.25) + } + + private var railColor: Color { + explicitRailColor ?? .clear + } + + private var explicitRailColor: Color? { + guard activeTabIndicatorStyle == .leftRail, + let custom = resolvedCustomTabColor else { + return nil } - return Color.clear + 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 { @@ -2820,40 +6877,6 @@ private struct TabItemView: View { return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead } } - private func remoteErrorCopyEntries(in workspaces: [Tab]) -> [SidebarRemoteErrorCopyEntry] { - workspaces.compactMap { workspace in - if workspace.remoteConnectionState == .error, - let detail = workspace.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines), - !detail.isEmpty { - return SidebarRemoteErrorCopyEntry( - workspaceTitle: workspace.title, - target: workspace.remoteDisplayTarget ?? "remote host", - detail: detail - ) - } - - guard let statusValue = workspace.statusEntries["remote.error"]?.value, - let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail( - from: statusValue, - fallbackTarget: workspace.remoteDisplayTarget - ) else { - return nil - } - - return SidebarRemoteErrorCopyEntry( - workspaceTitle: workspace.title, - target: parsed.target, - detail: parsed.detail - ) - } - } - - private func copyTextToPasteboard(_ text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - } - private func syncSelectionAfterMutation() { let existingIds = Set(tabManager.tabs.map { $0.id }) selectedTabIds = selectedTabIds.filter { existingIds.contains($0) } @@ -2865,22 +6888,41 @@ private struct TabItemView: View { } } - private var remoteStateHelpText: String { - let target = tab.remoteDisplayTarget ?? "remote host" - let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) - switch tab.remoteConnectionState { - case .connected: - return "SSH connected to \(target)" - case .connecting: - return "SSH connecting to \(target)" - case .error: - if let detail, !detail.isEmpty { - return "SSH error for \(target): \(detail)" - } - return "SSH error for \(target)" - case .disconnected: - return "SSH disconnected from \(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() } private var latestNotificationText: String? { @@ -2894,9 +6936,8 @@ private struct TabItemView: View { var parts: [String] = [] // Git branch (if enabled and available) - if sidebarShowGitBranch, let git = tab.gitBranch { - let dirty = git.isDirty ? "*" : "" - parts.append("\(git.branch)\(dirty)") + if sidebarShowGitBranch, let gitSummary = gitBranchSummaryText { + parts.append(gitSummary) } // Directory summary @@ -2908,12 +6949,64 @@ private struct TabItemView: View { return result.isEmpty ? nil : result } + private var gitBranchSummaryText: String? { + let lines = gitBranchSummaryLines + guard !lines.isEmpty else { return nil } + return lines.joined(separator: " | ") + } + + private var gitBranchSummaryLines: [String] { + tab.sidebarGitBranchesInDisplayOrder().map { branch in + "\(branch.branch)\(branch.isDirty ? "*" : "")" + } + } + + private var verticalBranchDirectoryEntries: [SidebarBranchOrdering.BranchDirectoryEntry] { + tab.sidebarBranchDirectoryEntriesInDisplayOrder() + } + + private var verticalRowsContainBranch: Bool { + sidebarShowGitBranch && verticalBranchDirectoryLines.contains { $0.branch != nil } + } + + private struct VerticalBranchDirectoryLine { + let branch: String? + let directory: String? + } + + private var verticalBranchDirectoryLines: [VerticalBranchDirectoryLine] { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return verticalBranchDirectoryEntries.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 = shortenPath(directory, home: 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 var directorySummaryText: String? { guard !tab.panels.isEmpty else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser.path var seen: Set = [] var entries: [String] = [] - for panelId in tab.panels.keys { + for panelId in tab.sidebarOrderedPanelIds() { let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory let shortened = shortenPath(directory, home: home) guard !shortened.isEmpty else { continue } @@ -2924,6 +7017,54 @@ private struct TabItemView: View { 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 + } + + private var pullRequestDisplays: [PullRequestDisplay] { + tab.sidebarPullRequestsInDisplayOrder().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 + ) + } + } + + private var pullRequestForegroundColor: Color { + isActive ? .white.opacity(0.75) : .secondary + } + + private func openPullRequestLink(_ url: URL) { + updateSelection() + if openSidebarPullRequestLinksInCmuxBrowser { + 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) -> String { + switch status { + case .open: return "open" + case .merged: return "merged" + case .closed: return "closed" + } + } + private func logLevelIcon(_ level: SidebarLogLevel) -> String { switch level { case .info: return "circle.fill" @@ -2937,11 +7078,16 @@ private struct TabItemView: View { private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { if isActive { switch level { - case .info: return .white.opacity(0.5) - case .progress: return .white.opacity(0.8) - case .success: return .white.opacity(0.9) - case .warning: return .white.opacity(0.9) - case .error: return .white.opacity(0.9) + 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 { @@ -2953,45 +7099,6 @@ private struct TabItemView: View { } } - private func remoteStateIcon(_ state: WorkspaceRemoteConnectionState) -> String { - switch state { - case .connected: - return "network" - case .connecting: - return "network.badge.shield.half.filled" - case .error: - return "network.slash" - case .disconnected: - return "network.slash" - } - } - - private func remoteStateColor(_ state: WorkspaceRemoteConnectionState, isActive: Bool) -> Color { - if isActive { - switch state { - case .connected: - return .white.opacity(0.9) - case .connecting: - return .white.opacity(0.85) - case .error: - return .white.opacity(0.9) - case .disconnected: - return .white.opacity(0.65) - } - } - - switch state { - case .connected: - return .green - case .connecting: - return .blue - case .error: - return .red - case .disconnected: - return .secondary - } - } - private func shortenPath(_ path: String, home: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return path } @@ -3004,6 +7111,150 @@ private struct TabItemView: View { 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 = "Custom Tab Color" + alert.informativeText = "Enter a hex color in the format #RRGGBB." + + let seed = tab.customColor ?? WorkspaceTabColorSettings.customColors().first ?? "" + 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: "Apply") + alert.addButton(withTitle: "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 = "Invalid Color" + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + alert.informativeText = "Enter a hex color in the format #RRGGBB." + } else { + alert.informativeText = "\"\(trimmed)\" is not a valid hex color. Use #RRGGBB." + } + alert.addButton(withTitle: "OK") + _ = alert.runModal() + } + private func promptRename() { let alert = NSAlert() alert.messageText = "Rename Workspace" @@ -3026,33 +7277,170 @@ private struct TabItemView: View { } } -private struct SidebarStatusPillsRow: View { +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) { - Text(statusText) - .font(.system(size: 10)) - .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) - .lineLimit(isExpanded ? nil : 3) - .truncationMode(.tail) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow(entry: entry, isActive: isActive, onFocus: onFocus) + } + + if shouldShowToggle { + Button(isExpanded ? "Show less" : "Show more") { onFocus() - guard shouldShowToggle else { return } 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) + } + } + .help(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) + .help(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 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 ? "Show less" : "Show more") { + Button(isExpanded ? "Show less details" : "Show more details") { onFocus() withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() @@ -3064,21 +7452,55 @@ private struct SidebarStatusPillsRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .help(statusText) } - private var statusText: String { - entries - .map { entry in - let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) - if !value.isEmpty { return value } - return entry.key - } - .joined(separator: "\n") + private var visibleBlocks: [SidebarMetadataBlock] { + guard !isExpanded, blocks.count > collapsedBlockLimit else { return blocks } + return Array(blocks.prefix(collapsedBlockLimit)) } private var shouldShowToggle: Bool { - entries.count > 1 || statusText.count > 120 + 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) + ) } } @@ -3366,6 +7788,111 @@ private enum SidebarTabDragPayload { } } +private enum BonsplitTabDragPayload { + static let typeIdentifier = "com.splittabbar.tabtransfer" + 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 @@ -3500,28 +8027,6 @@ private struct SidebarTabDropDelegate: DropDelegate { } } -/// AppKit-level double-click handler for the sidebar title-bar area. -/// Uses NSView hit-testing so it isn't swallowed by the SwiftUI ScrollView underneath. -private struct DoubleClickZoomView: NSViewRepresentable { - func makeNSView(context: Context) -> NSView { - DoubleClickZoomNSView() - } - - func updateNSView(_ nsView: NSView, context: Context) {} - - private final class DoubleClickZoomNSView: NSView { - override var mouseDownCanMoveWindow: Bool { true } - override func hitTest(_ point: NSPoint) -> NSView? { self } - override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { - window?.zoom(nil) - } else { - super.mouseDown(with: event) - } - } - } -} - private struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void @@ -3645,8 +8150,20 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable { } final class DraggableFolderNSView: NSView, NSDraggingSource { + private final class FolderIconImageView: NSImageView { + override var mouseDownCanMoveWindow: Bool { false } + } + var directory: String - private var imageView: NSImageView! + 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 @@ -3662,8 +8179,10 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { NSSize(width: 16, height: 16) } + override var mouseDownCanMoveWindow: Bool { false } + private func setupImageView() { - imageView = NSImageView() + imageView = FolderIconImageView() imageView.imageScaling = .scaleProportionallyDown imageView.translatesAutoresizingMaskIntoConstraints = false addSubview(imageView) @@ -3688,9 +8207,39 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { return context == .outsideApplication ? [.copy, .link] : .copy } - override func mouseDown(with event: NSEvent) { + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + hasActiveDragSession = false + restoreWindowMovableStateIfNeeded() #if DEBUG - dlog("folder.dragStart dir=\(directory)") + 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) @@ -3699,7 +8248,19 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { iconImage.size = NSSize(width: 32, height: 32) draggingItem.setDraggingFrame(bounds, contents: iconImage) - beginDraggingSession(with: [draggingItem], event: event, source: self) + 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) { @@ -3768,6 +8329,45 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { // 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? { @@ -4158,27 +8758,3 @@ extension NSColor { ) } } - -extension ContentView { - static func commandPaletteScrollPositionAnchor( - selectedIndex: Int, - resultCount: Int - ) -> UnitPoint? { - guard resultCount > 0 else { return nil } - if selectedIndex <= 0 { - return UnitPoint.top - } - if selectedIndex >= resultCount - 1 { - return UnitPoint.bottom - } - return nil - } - - static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss( - focusedPanelIsBrowser: Bool, - focusedBrowserAddressBarPanelId: UUID?, - focusedPanelId: UUID - ) -> Bool { - focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f7087a21..27dba919 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2137,6 +2137,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { return ghostty_surface_has_selection(surface) case #selector(paste(_:)), #selector(pasteAsPlainText(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) + case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)): + return canSplitCurrentSurface() default: return true } @@ -2763,9 +2765,63 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") pasteItem.target = self + menu.addItem(.separator()) + let splitHorizontallyItem = menu.addItem( + withTitle: "Split Horizontally", + action: #selector(splitHorizontally(_:)), + keyEquivalent: "d" + ) + splitHorizontallyItem.target = self + splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] + splitHorizontallyItem.image = NSImage( + systemSymbolName: "rectangle.bottomhalf.inset.filled", + accessibilityDescription: nil + ) + + let splitVerticallyItem = menu.addItem( + withTitle: "Split Vertically", + action: #selector(splitVertically(_:)), + keyEquivalent: "d" + ) + splitVerticallyItem.target = self + splitVerticallyItem.keyEquivalentModifierMask = [.command] + splitVerticallyItem.image = NSImage( + systemSymbolName: "rectangle.righthalf.inset.filled", + accessibilityDescription: nil + ) return menu } + private func canSplitCurrentSurface() -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, + let workspace = manager.tabs.first(where: { $0.id == tabId }) else { + return false + } + return workspace.panels[surfaceId] != nil + } + + @objc private func splitHorizontally(_ sender: Any?) { + _ = splitCurrentSurface(direction: .down) + } + + @objc private func splitVertically(_ sender: Any?) { + _ = splitCurrentSurface(direction: .right) + } + + @discardableResult + private func splitCurrentSurface(direction: SplitDirection) -> Bool { + guard let tabId, + let surfaceId = terminalSurface?.id, + let app = AppDelegate.shared, + let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + } + @objc private func triggerFlash(_ sender: Any?) { onTriggerFlash?() } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 71f297bb..07f1fd45 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let openSidebarPullRequestLinksInCmuxBrowserKey = "browserOpenSidebarPullRequestLinksInCmuxBrowser" + static let defaultOpenSidebarPullRequestLinksInCmuxBrowser: Bool = true + static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser" static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true @@ -140,6 +143,13 @@ enum BrowserLinkOpenSettings { return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + static func openSidebarPullRequestLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) == nil { + return defaultOpenSidebarPullRequestLinksInCmuxBrowser + } + return defaults.bool(forKey: openSidebarPullRequestLinksInCmuxBrowserKey) + } + static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil { return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 699b856a..ea282f33 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -156,13 +156,10 @@ private struct OmnibarAddressButtonStyleBody: View { } private extension View { - @ViewBuilder func cmuxFlatSymbolColorRendering() -> some View { - if #available(macOS 26.0, *) { - self.symbolColorRenderingMode(.flat) - } else { - self - } + // `symbolColorRenderingMode(.flat)` is not available in the current SDK + // used by CI/local builds. Keep this modifier as a compatibility no-op. + self } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index bcd77ed2..c330f9ea 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import ObjectiveC +import UniformTypeIdentifiers import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -258,11 +259,179 @@ final class CmuxWebView: WKWebView { private var fallbackDownloadLinkedFileTarget: AnyObject? private var fallbackDownloadLinkedFileAction: Selector? + private static func makeContextDownloadTraceID(prefix: String) -> String { +#if DEBUG + return "\(prefix)-\(UUID().uuidString.prefix(8))" +#else + return prefix +#endif + } + + private func debugContextDownload(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private static func selectorName(_ selector: Selector?) -> String { + guard let selector else { return "nil" } + return NSStringFromSelector(selector) + } + + private func debugLogContextMenuDownloadCandidate(_ item: NSMenuItem, index: Int) { + let identifier = item.identifier?.rawValue ?? "nil" + let title = item.title + let actionName = Self.selectorName(item.action) + let idToken = Self.normalizedContextMenuToken(identifier) + let titleToken = Self.normalizedContextMenuToken(title) + let actionToken = Self.normalizedContextMenuToken(actionName) + guard idToken.contains("download") + || titleToken.contains("download") + || actionToken.contains("download") else { + return + } + debugContextDownload( + "browser.ctxdl.menu item index=\(index) id=\(identifier) title=\(title) action=\(actionName)" + ) + } + + private struct ParsedDataURL { + let data: Data + let mimeType: String? + } + + private static func parseDataURL(_ url: URL) -> ParsedDataURL? { + let absolute = url.absoluteString + guard absolute.hasPrefix("data:"), + let commaIndex = absolute.firstIndex(of: ",") else { + return nil + } + + let headerStart = absolute.index(absolute.startIndex, offsetBy: 5) + let header = String(absolute[headerStart.. String? { + guard let mimeType, !mimeType.isEmpty else { return nil } + if #available(macOS 11.0, *) { + if let preferred = UTType(mimeType: mimeType)?.preferredFilenameExtension, !preferred.isEmpty { + return preferred + } + } + switch mimeType.lowercased() { + case "image/jpeg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "image/gif": + return "gif" + case "text/html": + return "html" + case "text/plain": + return "txt" + default: + return nil + } + } + + private static func suggestedFilenameForDataURL( + mimeType: String?, + suggestedFilename: String? + ) -> String { + if let suggested = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines), + !suggested.isEmpty { + return suggested + } + let ext = filenameExtension(forMIMEType: mimeType) ?? "bin" + let base = (mimeType?.lowercased().hasPrefix("image/") ?? false) ? "image" : "download" + return "\(base).\(ext)" + } + + private static func normalizedContextMenuToken(_ value: String?) -> String { + guard let value else { return "" } + let lowered = value.lowercased() + let alphanumerics = CharacterSet.alphanumerics + let scalars = lowered.unicodeScalars.filter { alphanumerics.contains($0) } + return String(String.UnicodeScalarView(scalars)) + } + + private func isDownloadImageMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadimage") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadimage") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadimage") { + return true + } + } + + return false + } + + private func isDownloadLinkedFileMenuItem(_ item: NSMenuItem) -> Bool { + let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue) + if identifier.contains("downloadlinkedfile") + || identifier.contains("downloadlinktodisk") { + return true + } + + let title = Self.normalizedContextMenuToken(item.title) + if title.contains("downloadlinkedfile") + || title.contains("downloadlinktodisk") { + return true + } + + if let action = item.action { + let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action)) + if actionName.contains("downloadlinkedfile") + || actionName.contains("downloadlinktodisk") { + return true + } + } + + return false + } + private func isDownloadableScheme(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" return scheme == "http" || scheme == "https" || scheme == "file" } + private func isDataURLScheme(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "data" + } + + private func isDownloadSupportedScheme(_ url: URL) -> Bool { + return isDownloadableScheme(url) || isDataURLScheme(url) + } + private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { guard target === self else { return false } return action == #selector(contextMenuDownloadImage(_:)) @@ -271,7 +440,7 @@ final class CmuxWebView: WKWebView { private func resolveGoogleRedirectURL(_ url: URL) -> URL? { guard let host = url.host?.lowercased(), host.contains("google.") else { return nil } - guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + guard let comps = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = comps.queryItems else { return nil } let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") }) let candidates = ["imgurl", "mediaurl", "url", "q"] @@ -299,6 +468,43 @@ final class CmuxWebView: WKWebView { resolveGoogleRedirectURL(url) ?? url } + private func isLikelyFaviconURL(_ url: URL) -> Bool { + let lower = url.absoluteString.lowercased() + if lower.contains("favicon") { return true } + let name = url.lastPathComponent.lowercased() + return name.hasPrefix("favicon") + } + + private func isLikelyImageURL(_ url: URL) -> Bool { + if isDataURLScheme(url) { + guard let parsed = Self.parseDataURL(url), + let mime = parsed.mimeType?.lowercased() else { + return false + } + return mime.hasPrefix("image/") + } + guard isDownloadableScheme(url) else { return false } + let ext = url.pathExtension.lowercased() + if [ + "jpg", "jpeg", "png", "webp", "gif", "bmp", + "svg", "avif", "heic", "heif", "tif", "tiff", "ico" + ].contains(ext) { + return true + } + let lower = url.absoluteString.lowercased() + if lower.contains("imgurl=") + || lower.contains("mediaurl=") + || lower.contains("encrypted-tbn") + || lower.contains("format=jpg") + || lower.contains("format=jpeg") + || lower.contains("format=png") + || lower.contains("format=webp") + || lower.contains("format=gif") { + return true + } + return false + } + private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { let target = item.target as AnyObject? let action = item.action @@ -331,49 +537,121 @@ final class CmuxWebView: WKWebView { let flippedY = bounds.height - point.y let js = """ (() => { - const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); - for (const start of nodes) { - let elChain = []; - let seen = new Set(); - let walk = (node) => { - let chain = []; - let localSeen = new Set(); - let visit = (n) => { - while (n && !localSeen.has(n)) { - localSeen.add(n); - chain.push(n); - n = n.parentElement; - } - }; - visit(node); - if (node && node.tagName === 'PICTURE') { - const img = node.querySelector('img'); - if (img) visit(img); + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const firstSrcsetURL = (srcset) => { + if (!srcset || typeof srcset !== 'string') return ''; + const first = srcset.split(',').map((part) => part.trim()).find(Boolean); + if (!first) return ''; + const urlPart = first.split(/\\s+/)[0]; + return normalize(urlPart); + }; + const firstBackgroundURL = (value) => { + if (!value || value === 'none') return ''; + const match = /url\\((['"]?)(.*?)\\1\\)/.exec(value); + if (!match || !match[2]) return ''; + return normalize(match[2]); + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + const pushParents = (node) => { + while (node && !seen.has(node)) { + seen.add(node); + out.push(node); + node = node.parentElement; } - return chain; }; - for (const el of walk(start)) { - if (!seen.has(el)) { - seen.add(el); - elChain.push(el); + pushParents(start); + if (start && start.tagName === 'PICTURE' && start.querySelector) { + const img = start.querySelector('img'); + if (img) pushParents(img); + } + return out; + }; + const candidateFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.tagName === 'IMG') { + const imageCandidates = [ + normalize(el.currentSrc || ''), + attr('src'), + firstSrcsetURL(attr('srcset')), + attr('data-src'), + attr('data-iurl'), + attr('data-lazy-src'), + attr('data-original'), + ]; + const foundImage = imageCandidates.find(Boolean); + if (foundImage) return foundImage; + } + const genericAttrs = [ + 'src', 'data-src', 'data-iurl', 'data-lazy-src', + 'data-original', 'data-image', 'data-image-url', + 'data-thumb', 'data-thumbnail-url', 'content' + ]; + for (const name of genericAttrs) { + const v = attr(name); + if (v) return v; + } + const inlineBg = firstBackgroundURL(el.style && el.style.backgroundImage ? el.style.backgroundImage : ''); + if (inlineBg) return inlineBg; + try { + const computed = window.getComputedStyle(el); + const computedBg = firstBackgroundURL(computed ? computed.backgroundImage : ''); + if (computedBg) return computedBg; + } catch (_) {} + if (el.querySelector) { + const nestedImg = el.querySelector('img[src],img[srcset],img[data-src],img[data-iurl],source[srcset]'); + if (nestedImg) { + const nestedCandidates = [ + normalize(nestedImg.currentSrc || ''), + normalize(nestedImg.getAttribute ? nestedImg.getAttribute('src') : ''), + firstSrcsetURL(nestedImg.getAttribute ? nestedImg.getAttribute('srcset') : ''), + normalize(nestedImg.getAttribute ? (nestedImg.getAttribute('data-src') || nestedImg.getAttribute('data-iurl') || '') : '') + ]; + const foundNested = nestedCandidates.find(Boolean); + if (foundNested) return foundNested; + } + const nestedBg = el.querySelector('[style*="background-image"]'); + if (nestedBg) { + const styleValue = nestedBg.getAttribute ? nestedBg.getAttribute('style') : ''; + const bgURL = firstBackgroundURL(styleValue || ''); + if (bgURL) return bgURL; } } - - for (const el of elChain) { - if (el.tagName === 'IMG') { - if (el.currentSrc) return el.currentSrc; - if (el.src) return el.src; + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const el of collectChain(start)) { + const found = candidateFromElement(el); + if (found) return found; } - if (el.tagName === 'PICTURE') { - const img = el.querySelector('img'); - if (img) { - if (img.currentSrc) return img.currentSrc; - if (img.src) return img.src; + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const el of collectChain(inner)) { + const found = candidateFromElement(el); + if (found) return found; + } } } } - } - return ''; + return ''; + }; + const all = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const foundFromAll = tryNodes(all); + if (foundFromAll) return foundFromAll; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return candidateFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -391,28 +669,69 @@ final class CmuxWebView: WKWebView { let flippedY = bounds.height - point.y let js = """ (() => { - const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); - for (const start of nodes) { - let el = start; - let seen = new Set(); - let cur = (() => { - let n = start; - return n; - })(); - let walk = (node) => { - let chain = []; - while (node && !seen.has(node)) { - seen.add(node); - chain.push(node); - node = node.parentElement; - } - return chain; - }; - for (const n of walk(cur)) { - if (n.tagName === 'A' && n.href) return n.href; + const x = \(point.x); + const y = \(flippedY); + const normalize = (raw) => { + if (!raw || typeof raw !== 'string') return ''; + const trimmed = raw.trim(); + if (!trimmed) return ''; + if (trimmed.startsWith('//')) return window.location.protocol + trimmed; + return trimmed; + }; + const collectChain = (start) => { + const out = []; + const seen = new Set(); + while (start && !seen.has(start)) { + seen.add(start); + out.push(start); + start = start.parentElement; } - } - return ''; + return out; + }; + const linkFromElement = (el) => { + if (!el) return ''; + const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : ''); + if (el.closest) { + const closestLink = el.closest('a[href],area[href]'); + if (closestLink && closestLink.href) return normalize(closestLink.href); + } + if ((el.tagName === 'A' || el.tagName === 'AREA') && el.href) { + return normalize(el.href); + } + const attrCandidates = ['href', 'data-href', 'data-url', 'data-link', 'data-link-url']; + for (const name of attrCandidates) { + const v = attr(name); + if (v) return v; + } + if (el.querySelector) { + const nestedLink = el.querySelector('a[href],area[href]'); + if (nestedLink && nestedLink.href) return normalize(nestedLink.href); + } + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const node of collectChain(start)) { + const found = linkFromElement(node); + if (found) return found; + } + if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) { + const inner = start.shadowRoot.elementFromPoint(x, y); + if (inner) { + for (const node of collectChain(inner)) { + const found = linkFromElement(node); + if (found) return found; + } + } + } + } + return ''; + }; + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const found = tryNodes(nodes); + if (found) return found; + const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null; + return linkFromElement(single) || ''; })(); """ evaluateJavaScript(js) { result, _ in @@ -425,6 +744,49 @@ final class CmuxWebView: WKWebView { } } + private func debugInspectElementsAtPoint(_ point: NSPoint, traceID: String, kind: String) { +#if DEBUG + let flippedY = bounds.height - point.y + let js = """ + (() => { + const clip = (value, max = 180) => { + if (value == null) return ''; + const s = String(value); + return s.length > max ? s.slice(0, max) + '…' : s; + }; + const x = \(point.x); + const y = \(flippedY); + const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : []; + const entries = []; + const limit = Math.min(nodes.length, 8); + for (let i = 0; i < limit; i++) { + const el = nodes[i]; + if (!el) continue; + entries.push({ + tag: clip((el.tagName || '').toLowerCase()), + id: clip(el.id || ''), + cls: clip(typeof el.className === 'string' ? el.className : ''), + href: clip(el.href || ''), + src: clip(el.src || ''), + currentSrc: clip(el.currentSrc || ''), + dataHref: clip(el.getAttribute ? el.getAttribute('data-href') : ''), + dataSrc: clip(el.getAttribute ? el.getAttribute('data-src') : '') + }); + } + return JSON.stringify({count: nodes.length, entries}); + })(); + """ + evaluateJavaScript(js) { [weak self] result, _ in + guard let self, + let payload = result as? String, + !payload.isEmpty else { return } + self.debugContextDownload( + "browser.ctxdl.inspect trace=\(traceID) kind=\(kind) payload=\(payload)" + ) + } +#endif + } + private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) { if let contextMenuLinkURLProvider { contextMenuLinkURLProvider(self, point, completion) @@ -446,16 +808,33 @@ final class CmuxWebView: WKWebView { _ = NSWorkspace.shared.open(url) } - private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { - guard let action else { return } + private func runContextMenuFallback( + action: Selector?, + target: AnyObject?, + sender: Any?, + traceID: String? = nil, + reason: String? = nil + ) { + let trace = traceID ?? "unknown" + guard let action else { + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") action=nil target=\(String(describing: target))" + ) + return + } // Guard against accidental self-recursion if fallback gets overwritten. if target === self, action == #selector(contextMenuDownloadImage(_:)) || action == #selector(contextMenuDownloadLinkedFile(_:)) { - NSLog("CmuxWebView context fallback skipped (recursive self action)") + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" + ) return } - _ = NSApp.sendAction(action, to: target, from: sender) + let dispatched = NSApp.sendAction(action, to: target, from: sender) + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") dispatched=\(dispatched ? 1 : 0) action=\(Self.selectorName(action)) target=\(String(describing: target))" + ) } private func notifyContextMenuDownloadState(_ downloading: Bool) { @@ -473,19 +852,98 @@ final class CmuxWebView: WKWebView { suggestedFilename: String?, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - guard isDownloadableScheme(url) else { - runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + guard isDownloadSupportedScheme(url) else { + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=rejectUnsupportedScheme url=\(url.absoluteString)" + ) + runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "unsupported_scheme" + ) return } let scheme = url.scheme?.lowercased() ?? "" + debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=start scheme=\(scheme) url=\(url.absoluteString)" + ) notifyContextMenuDownloadState(true) + debugContextDownload("browser.ctxdl.state trace=\(traceID) downloading=1") + + if scheme == "data" { + DispatchQueue.main.async { + guard let parsed = Self.parseDataURL(url) else { + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseFailure urlLength=\(url.absoluteString.count)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_url_parse_error" + ) + return + } + + let saveName = Self.suggestedFilenameForDataURL( + mimeType: parsed.mimeType, + suggestedFilename: suggestedFilename + ) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=parseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)" + ) + + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try parsed.data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.data trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "data_save_write_error" + ) + } + } + } + return + } if scheme == "file" { DispatchQueue.main.async { do { let data = try Data(contentsOf: url) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=\(url.path)" + ) let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) let savePanel = NSSavePanel() @@ -494,13 +952,39 @@ final class CmuxWebView: WKWebView { savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first // Download is already complete; we're now waiting for user save choice. self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } - try? data.write(to: destURL, options: .atomic) + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + do { + try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) + } catch { + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + } } } catch { self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.file trace=\(traceID) stage=readFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "file_read_error" + ) } } return @@ -520,14 +1004,35 @@ final class CmuxWebView: WKWebView { if let ua = self.customUserAgent, !ua.isEmpty { request.setValue(ua, forHTTPHeaderField: "User-Agent") } + self.debugContextDownload( + "browser.ctxdl.request trace=\(traceID) stage=dispatch method=\(request.httpMethod ?? "GET") cookies=\(cookies.count) referer=\(request.value(forHTTPHeaderField: "Referer") ?? "nil") uaSet=\(request.value(forHTTPHeaderField: "User-Agent") == nil ? 0 : 1)" + ) URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { guard let data, error == nil else { + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let hasResponse = response == nil ? 0 : 1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=failure hasResponse=\(hasResponse) status=\(statusCode) mime=\(mime) error=\(error?.localizedDescription ?? "unknown")" + ) self.notifyContextMenuDownloadState(false) - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "network_error" + ) return } + let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 + let mime = response?.mimeType ?? "nil" + let expectedLength = response?.expectedContentLength ?? -1 + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)" + ) let filenameCandidate = suggestedFilename ?? response?.suggestedFilename ?? url.lastPathComponent @@ -539,12 +1044,32 @@ final class CmuxWebView: WKWebView { savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first // Download is already complete; we're now waiting for user save choice. self.notifyContextMenuDownloadState(false) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { return } + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } do { try data.write(to: destURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveSuccess path=\(destURL.path)" + ) } catch { - self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + self.debugContextDownload( + "browser.ctxdl.response trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: "save_write_error" + ) } } } @@ -556,15 +1081,17 @@ final class CmuxWebView: WKWebView { _ url: URL, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - NSLog("CmuxWebView context download start: %@", url.absoluteString) + debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, suggestedFilename: nil, sender: sender, fallbackAction: fallbackAction, - fallbackTarget: fallbackTarget + fallbackTarget: fallbackTarget, + traceID: traceID ) } @@ -596,10 +1123,14 @@ final class CmuxWebView: WKWebView { override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) lastContextMenuPoint = convert(event.locationInWindow, from: nil) + debugContextDownload( + "browser.ctxdl.menu open itemCount=\(menu.items.count) point=(\(Int(lastContextMenuPoint.x)),\(Int(lastContextMenuPoint.y)))" + ) var openLinkInsertionIndex: Int? var hasDefaultBrowserOpenLinkItem = false for (index, item) in menu.items.enumerated() { + debugLogContextMenuDownloadCandidate(item, index: index) if !hasDefaultBrowserOpenLinkItem, (item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:)) || item.title == "Open Link in Default Browser") { @@ -620,9 +1151,10 @@ final class CmuxWebView: WKWebView { item.title = "Open Link in New Tab" } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" - || item.title == "Download Image" { - NSLog("CmuxWebView context menu hook: download image") + if isDownloadImageMenuItem(item) { + debugContextDownload( + "browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -636,9 +1168,10 @@ final class CmuxWebView: WKWebView { item.action = #selector(contextMenuDownloadImage(_:)) } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" - || item.title == "Download Linked File" { - NSLog("CmuxWebView context menu hook: download linked file") + if isDownloadLinkedFileMenuItem(item) { + debugContextDownload( + "browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" + ) captureFallbackForMenuItemIfNeeded(item) // Keep global fallback as a secondary safety net. if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { @@ -674,80 +1207,174 @@ final class CmuxWebView: WKWebView { } @objc private func contextMenuDownloadImage(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=image point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadImageAction, defaultTarget: fallbackDownloadImageTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findImageURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image imageURL=\(url?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? + var weakImageURL: URL? if let url { let scheme = url.scheme?.lowercased() ?? "" - if scheme == "http" || scheme == "https" || scheme == "file" { - NSLog("CmuxWebView context download image URL: %@", url.absoluteString) - self.startContextMenuDownload( - url, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + if scheme == "data" { + dataImageURL = url + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image dataURLDetected length=\(url.absoluteString.count)" + ) + } else if scheme == "http" || scheme == "https" || scheme == "file" { + let normalized = self.normalizedLinkedDownloadURL(url) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)" + ) + if self.isLikelyImageURL(normalized) { + if !self.isLikelyFaviconURL(normalized) { + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=favicon_or_low_confidence" + ) + } else if self.isDownloadableScheme(normalized), !self.isLikelyFaviconURL(normalized) { + // Some image CDNs use extensionless URLs; keep as last-resort candidate. + weakImageURL = normalized + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=unclassified_direct_image_src" + ) + } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" ) - return } } // Google Images and similar sites often expose blob:/data: image URLs. // If image URL is not directly downloadable, fall back to the nearby link URL. self.findLinkURLAtPoint(point) { linkURL in - guard let linkURL else { - NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackLinkURL=\(linkURL?.absoluteString ?? "nil")" + ) + if let linkURL { + let normalizedLink = self.normalizedLinkedDownloadURL(linkURL) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedFallbackLinkURL=\(normalizedLink.absoluteString)" ) - return + if self.isDownloadableScheme(normalizedLink), + self.isLikelyImageURL(normalizedLink), + !self.isLikelyFaviconURL(normalizedLink) { + self.startContextMenuDownload( + normalizedLink, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } } - let linkScheme = linkURL.scheme?.lowercased() ?? "" - guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else { - NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString) - self.runContextMenuFallback( - action: fallback.action, - target: fallback.target, - sender: sender + + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID ) return } - NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) - self.startContextMenuDownload( - linkURL, + if let weakImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToWeakCandidate=1" + ) + self.startContextMenuDownload( + weakImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + + if let linkURL { + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender, + traceID: traceID, + reason: "fallback_link_not_image" + ) + return + } + + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + traceID: traceID, + reason: "no_image_or_link_url" ) } } } @objc private func contextMenuDownloadLinkedFile(_ sender: Any?) { + let traceID = Self.makeContextDownloadTraceID(prefix: "lnk") let point = lastContextMenuPoint + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) kind=linked point=(\(Int(point.x)),\(Int(point.y)))" + ) let fallback = fallbackFromSender( sender, defaultAction: fallbackDownloadLinkedFileAction, defaultTarget: fallbackDownloadLinkedFileTarget ) + debugContextDownload( + "browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))" + ) findLinkURLAtPoint(point) { [weak self] url in guard let self else { return } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked linkURL=\(url?.absoluteString ?? "nil")" + ) if let url { let normalized = self.normalizedLinkedDownloadURL(url) - if self.isDownloadableScheme(normalized) { - NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" + ) + if self.isDownloadSupportedScheme(normalized) { self.startContextMenuDownload( normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } @@ -755,44 +1382,90 @@ final class CmuxWebView: WKWebView { // Fallback 1: image URL under cursor (useful on image-heavy result pages). self.findImageURLAtPoint(point) { imageURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackImageURL=\(imageURL?.absoluteString ?? "nil")" + ) + var dataImageURL: URL? if let imageURL, self.isDownloadableScheme(imageURL) { - NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) self.startContextMenuDownload( imageURL, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } + if let imageURL, self.isDataURLScheme(imageURL) { + dataImageURL = imageURL + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackDataURLDetected length=\(imageURL.absoluteString.count)" + ) + } // Fallback 2: simpler nearest-anchor lookup. self.findLinkAtPoint(point) { fallbackURL in + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked nearestAnchorURL=\(fallbackURL?.absoluteString ?? "nil")" + ) guard let fallbackURL else { - NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "no_link_or_image_url" ) return } let normalized = self.normalizedLinkedDownloadURL(fallbackURL) - guard self.isDownloadableScheme(normalized) else { - NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedNearestAnchorURL=\(normalized.absoluteString)" + ) + guard self.isDownloadSupportedScheme(normalized) else { + if let dataImageURL { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1" + ) + self.startContextMenuDownload( + dataImageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "nearest_anchor_unsupported_scheme" ) return } - NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) } } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 1ac07f7b..7ede0590 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -139,8 +139,11 @@ final class TerminalPanel: Panel, ObservableObject { func close() { // The surface will be cleaned up by its deinit - // Just unfocus before closing + // Detach from the window portal on real close so stale hosted views + // cannot remain above browser panes after split close. unfocus() + hostedView.setVisibleInUI(false) + TerminalWindowPortalRegistry.detach(hostedView: hostedView) } func requestViewReattach() { diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index a2586136..b9705095 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -163,6 +163,8 @@ struct SocketControlSettings { static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" + static let launchTagEnvKey = "CMUX_TAG" + static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" private static func normalizeMode(_ raw: String) -> String { raw @@ -211,6 +213,58 @@ struct SocketControlSettings { #endif } + static func launchTag( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + guard let raw = environment[launchTagEnvKey] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func shouldBlockUntaggedDebugLaunch( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isDebugBuild: Bool = SocketControlSettings.isDebugBuild + ) -> Bool { + guard isDebugBuild else { return false } + if isRunningUnderXCTest(environment: environment) { + return false + } + // XCUITest launches the app as a separate process without XCTest env vars, + // so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var. + if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) { + return false + } + + guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty else { + return false + } + + if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") { + return false + } + + guard bundleIdentifier == baseDebugBundleIdentifier else { + return false + } + + return launchTag(environment: environment) == nil + } + + static func isRunningUnderXCTest(environment: [String: String]) -> Bool { + let indicators = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "XCInjectBundleInto", + ] + return indicators.contains { key in + guard let value = environment[key] else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 243eca8d..29681f6f 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1160,10 +1160,28 @@ class TabManager: ObservableObject { } private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { + let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in + partial + tab.bonsplitController.tabs(inPane: paneId).count + } + let panelKind: String = { + guard let panel = tab.panels[panelId] else { return "missing" } + if panel is TerminalPanel { return "terminal" } + if panel is BrowserPanel { return "browser" } + return String(describing: type(of: panel)) + }() +#if DEBUG + dlog( + "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + + "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)" + ) +#endif + // Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has // a single tab left, closing it should close the workspace (and possibly the window), // rather than creating a replacement terminal. - let isLastTabInWorkspace = tab.panels.count <= 1 + let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount) + let isLastTabInWorkspace = effectiveSurfaceCount <= 1 if isLastTabInWorkspace { let willCloseWindow = tabs.count <= 1 let needsConfirm = workspaceNeedsConfirmClose(tab) @@ -1171,11 +1189,25 @@ class TabManager: ObservableObject { let message = willCloseWindow ? "This will close the last tab and close the window." : "This will close the last tab and close its workspace." +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTab" + ) +#endif guard confirmClose( title: "Close tab?", message: message, acceptCmdD: willCloseWindow - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed" + ) +#endif + return + } } AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id) @@ -1189,15 +1221,36 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { +#if DEBUG + dlog( + "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm" + ) +#endif guard confirmClose( title: "Close tab?", message: "This will close the current tab.", acceptCmdD: false - ) else { return } + ) else { +#if DEBUG + dlog( + "surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed" + ) +#endif + return + } } // We already confirmed (if needed); bypass Bonsplit's delegate gating. - tab.closePanel(panelId, force: true) + let closed = tab.closePanel(panelId, force: true) +#if DEBUG + dlog( + "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " + + "panelsAfterCall=\(tab.panels.count)" + ) +#endif } func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { @@ -1931,19 +1984,81 @@ class TabManager: ObservableObject { return tab.browserPanel(for: panelId) } + /// Open a browser in a specific workspace, optionally preferring a split-right layout. + @discardableResult + func openBrowser( + inWorkspace tabId: UUID, + url: URL? = nil, + preferSplitRight: Bool = false, + insertAtEnd: Bool = false + ) -> UUID? { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } + if selectedTabId != tabId { + selectedTabId = tabId + } + + if preferSplitRight { + if let targetPaneId = workspace.topRightBrowserReusePane(), + let browserPanel = workspace.newBrowserSurface( + inPane: targetPaneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + + let splitSourcePanelId: UUID? = { + if let focusedPanelId = workspace.focusedPanelId, + workspace.panels[focusedPanelId] != nil { + return focusedPanelId + } + if let rememberedPanelId = lastFocusedPanelByTab[tabId], + workspace.panels[rememberedPanelId] != nil { + return rememberedPanelId + } + if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) { + return orderedPanelId + } + return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first + }() + + if let splitSourcePanelId, + let browserPanel = workspace.newBrowserSplit( + from: splitSourcePanelId, + orientation: .horizontal, + url: url, + focus: true + ) { + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + } + + guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + insertAtEnd: insertAtEnd + ) else { + return nil + } + rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) + return browserPanel.id + } + /// Open a browser in the currently focused pane (as a new surface) @discardableResult func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? { - guard let tabId = selectedTabId, - let tab = tabs.first(where: { $0.id == tabId }), - let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil } - let panel = tab.newBrowserSurface( - inPane: focusedPaneId, + guard let tabId = selectedTabId else { return nil } + return openBrowser( + inWorkspace: tabId, url: url, - focus: true, + preferSplitRight: false, insertAtEnd: insertAtEnd ) - return panel?.id } @discardableResult diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 54efb366..e30567f1 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -187,10 +187,29 @@ class TerminalController { key: String, value: String, icon: String?, - color: String? + color: String?, + url: URL?, + priority: Int, + format: SidebarMetadataFormat ) -> Bool { guard let current else { return true } - return current.key != key || current.value != value || current.icon != icon || current.color != color + return current.key != key || + current.value != value || + current.icon != icon || + current.color != color || + current.url != url || + current.priority != priority || + current.format != format + } + + nonisolated static func shouldReplaceMetadataBlock( + current: SidebarMetadataBlock?, + key: String, + markdown: String, + priority: Int + ) -> Bool { + guard let current else { return true } + return current.key != key || current.markdown != markdown || current.priority != priority } nonisolated static func shouldReplaceProgress( @@ -211,6 +230,17 @@ class TerminalController { return current.branch != branch || current.isDirty != isDirty } + nonisolated static func shouldReplacePullRequest( + current: SidebarPullRequestState?, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) -> Bool { + guard let current else { return true } + return current.number != number || current.label != label || current.url != url || current.status != status + } + nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool { let currentSorted = Array(Set(current ?? [])).sorted() let nextSorted = Array(Set(next)).sorted() @@ -720,12 +750,30 @@ class TerminalController { case "set_status": return setStatus(args) + case "report_meta": + return reportMeta(args) + + case "report_meta_block": + return reportMetaBlock(args) + case "clear_status": return clearStatus(args) + case "clear_meta": + return clearMeta(args) + + case "clear_meta_block": + return clearMetaBlock(args) + case "list_status": return listStatus(args) + case "list_meta": + return listMeta(args) + + case "list_meta_blocks": + return listMetaBlocks(args) + case "log": return appendLog(args) @@ -747,6 +795,15 @@ class TerminalController { case "clear_git_branch": return clearGitBranch(args) + case "report_pr": + return reportPullRequest(args) + + case "report_review": + return reportPullRequest(args) + + case "clear_pr": + return clearPullRequest(args) + case "report_ports": return reportPorts(args) @@ -8432,16 +8489,25 @@ class TerminalController { clear_notifications - Clear all notifications set_app_focus - Override app focus state simulate_app_active - Trigger app active handler - set_status [--icon=X] [--color=#hex] [--tab=X] - Set a status entry + set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry + report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry + report_meta_block [--priority=N] [--tab=X] -- - Set freeform sidebar markdown block clear_status [--tab=X] - Remove a status entry + clear_meta [--tab=X] - Remove sidebar metadata entry + clear_meta_block [--tab=X] - Remove sidebar markdown block list_status [--tab=X] - List all status entries + list_meta [--tab=X] - List sidebar metadata entries + list_meta_blocks [--tab=X] - List sidebar markdown blocks log [--level=X] [--source=X] [--tab=X] -- - Append a log entry clear_log [--tab=X] - Clear log entries list_log [--limit=N] [--tab=X] - List log entries set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar clear_progress [--tab=X] - Clear progress bar - report_git_branch [--status=dirty] [--tab=X] - Report git branch - clear_git_branch [--tab=X] - Clear git branch + report_git_branch [--status=dirty] [--tab=X] [--panel=Y] - Report git branch + clear_git_branch [--tab=X] [--panel=Y] - Clear git branch + report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item + report_review [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item + clear_pr [--tab=X] [--panel=Y] - Clear pull request report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel @@ -11329,21 +11395,104 @@ class TerminalController { return tabManager.tabs.first(where: { $0.id == selectedId }) } - private func setStatus(_ args: String) -> String { + private func resolveTabIdForSidebarMutation( + reportArgs: String, + options: [String: String] + ) -> (tabId: UUID?, error: String?) { + var tabId: UUID? + DispatchQueue.main.sync { + if let tab = resolveTabForReport(reportArgs) { + tabId = tab.id + } + } + if let tabId { + return (tabId, nil) + } + let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return (nil, error) + } + + private func tabForSidebarMutation(id: UUID) -> Tab? { + if let tab = tabManager?.tabs.first(where: { $0.id == id }) { + return tab + } + if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) { + return otherManager.tabs.first(where: { $0.id == id }) + } + return nil + } + + private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? { + switch raw.lowercased() { + case "plain": + return .plain + case "markdown", "md": + return .markdown + default: + return nil + } + } + + private func normalizedOptionValue(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func upsertSidebarMetadata(_ args: String, missingError: String) -> String { guard tabManager != nil else { return "ERROR: TabManager not available" } let parsed = parseOptionsNoStop(args) - guard parsed.positional.count >= 2 else { - return "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--tab=X]" - } + guard parsed.positional.count >= 2 else { return missingError } + let key = parsed.positional[0] let value = parsed.positional[1...].joined(separator: " ") - let icon = parsed.options["icon"] - let color = parsed.options["color"] + let icon = normalizedOptionValue(parsed.options["icon"]) + let color = normalizedOptionValue(parsed.options["color"]) - var result = "OK" - DispatchQueue.main.sync { - guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue + guard let format = parseSidebarMetadataFormat(formatRaw) else { + return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown" + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer" + } + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let parsedURL: URL? + if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) { + guard let candidate = URL(string: rawURL), + let scheme = candidate.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL" + } + parsedURL = candidate + } else { + parsedURL = nil + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + guard Self.shouldReplaceStatusEntry( + current: tab.statusEntries[key], + key: key, + value: value, + icon: icon, + color: color, + url: parsedURL, + priority: priority, + format: format + ) else { return } tab.statusEntries[key] = SidebarStatusEntry( @@ -11351,15 +11500,19 @@ class TerminalController { value: value, icon: icon, color: color, - timestamp: Date()) + url: parsedURL, + priority: priority, + format: format, + timestamp: Date() + ) } - return result + return "OK" } - private func clearStatus(_ args: String) -> String { + private func clearSidebarMetadata(_ args: String, usage: String) -> String { let parsed = parseOptions(args) guard let key = parsed.positional.first, parsed.positional.count == 1 else { - return "ERROR: Missing status key — usage: clear_status [--tab=X]" + return "ERROR: Missing metadata key — usage: \(usage)" } var result = "OK" @@ -11375,24 +11528,173 @@ class TerminalController { return result } - private func listStatus(_ args: String) -> String { + private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String { + var line = "\(entry.key)=\(entry.value)" + if let icon = entry.icon { line += " icon=\(icon)" } + if let color = entry.color { line += " color=\(color)" } + if let url = entry.url { line += " url=\(url.absoluteString)" } + if entry.priority != 0 { line += " priority=\(entry.priority)" } + if entry.format != .plain { line += " format=\(entry.format.rawValue)" } + return line + } + + private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String { var result = "" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { result = "ERROR: Tab not found" return } - if tab.statusEntries.isEmpty { - result = "No status entries" + let entries = tab.sidebarStatusEntriesInDisplayOrder() + if entries.isEmpty { + result = emptyMessage return } - let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in - var line = "\(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - return line + result = entries.map(sidebarMetadataLine).joined(separator: "\n") + } + return result + } + + private func setStatus(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func reportMeta(_ args: String) -> String { + upsertSidebarMetadata( + args, + missingError: "ERROR: Missing metadata key or value — usage: report_meta [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]" + ) + } + + private func clearStatus(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_status [--tab=X]") + } + + private func clearMeta(_ args: String) -> String { + clearSidebarMetadata(args, usage: "clear_meta [--tab=X]") + } + + private func listStatus(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No status entries") + } + + private func listMeta(_ args: String) -> String { + listSidebarMetadata(args, emptyMessage: "No metadata entries") + } + + private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) { + guard let separatorRange = args.range(of: " -- ") else { + return (args, nil) + } + let optionsPart = String(args[.. String { + var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))" + if block.priority != 0 { line += " priority=\(block.priority)" } + return line + } + + private func reportMetaBlock(_ args: String) -> String { + guard tabManager != nil else { return "ERROR: TabManager not available" } + + let parts = splitMetadataBlockArgs(args) + let parsed = parseOptionsNoStop(parts.optionsPart) + guard let key = parsed.positional.first, !key.isEmpty else { + return "ERROR: Missing metadata block key — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let markdown: String + if let raw = parts.markdownPart { + markdown = raw + } else if parsed.positional.count >= 2 { + markdown = parsed.positional.dropFirst().joined(separator: " ") + } else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let normalizedMarkdown = markdown + .replacingOccurrences(of: "\\r\\n", with: "\n") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + + let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedMarkdown.isEmpty else { + return "ERROR: Missing metadata markdown — usage: report_meta_block [--priority=N] [--tab=X] -- " + } + + let priority: Int + if let rawPriority = normalizedOptionValue(parsed.options["priority"]) { + guard let parsedPriority = Int(rawPriority) else { + return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer" } - result = lines.joined(separator: "\n") + priority = max(-9999, min(9999, parsedPriority)) + } else { + priority = 0 + } + + let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options) + guard let targetTabId = tabResolution.tabId else { + return tabResolution.error ?? "ERROR: No tab selected" + } + + DispatchQueue.main.async { [weak self] in + guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return } + guard Self.shouldReplaceMetadataBlock( + current: tab.metadataBlocks[key], + key: key, + markdown: normalizedMarkdown, + priority: priority + ) else { + return + } + tab.metadataBlocks[key] = SidebarMetadataBlock( + key: key, + markdown: normalizedMarkdown, + priority: priority, + timestamp: Date() + ) + } + return "OK" + } + + private func clearMetaBlock(_ args: String) -> String { + let parsed = parseOptions(args) + guard let key = parsed.positional.first, parsed.positional.count == 1 else { + return "ERROR: Missing metadata block key — usage: clear_meta_block [--tab=X]" + } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + if tab.metadataBlocks.removeValue(forKey: key) == nil { + result = "OK (key not found)" + } + } + return result + } + + private func listMetaBlocks(_ args: String) -> String { + var result = "" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = "ERROR: Tab not found" + return + } + let blocks = tab.sidebarMetadataBlocksInDisplayOrder() + if blocks.isEmpty { + result = "No metadata blocks" + return + } + result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n") } return result } @@ -11541,6 +11843,132 @@ class TerminalController { return result } + private func reportPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + guard parsed.positional.count >= 2 else { + return "ERROR: Missing pull request number or URL — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + + let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines) + let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber + guard let number = Int(numberToken), number > 0 else { + return "ERROR: Invalid pull request number '\(rawNumber)'" + } + + let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return "ERROR: Invalid pull request URL '\(rawURL)'" + } + + let statusRaw = (parsed.options["state"] ?? "open").lowercased() + guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else { + return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed" + } + + let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR" + guard !labelRaw.isEmpty else { + return "ERROR: Invalid review label — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + } + let label = String(labelRaw.prefix(16)) + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_pr [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + guard Self.shouldReplacePullRequest( + current: tab.panelPullRequests[surfaceId], + number: number, + label: label, + url: url, + status: status + ) else { + return + } + + tab.updatePanelPullRequest( + panelId: surfaceId, + number: number, + label: label, + url: url, + status: status + ) + } + return result + } + + private func clearPullRequest(_ args: String) -> String { + let parsed = parseOptions(args) + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.clearPanelPullRequest(panelId: surfaceId) + } + return result + } + private func reportPorts(_ args: String) -> String { let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { @@ -11785,6 +12213,14 @@ class TerminalController { lines.append("git_branch=none") } + if let pr = tab.pullRequest { + lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)") + lines.append("pr_label=\(pr.label)") + } else { + lines.append("pr=none") + lines.append("pr_label=none") + } + if tab.listeningPorts.isEmpty { lines.append("ports=none") } else { @@ -11798,12 +12234,16 @@ class TerminalController { lines.append("progress=none") } - lines.append("status_count=\(tab.statusEntries.count)") - for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) { - var line = " \(entry.key)=\(entry.value)" - if let icon = entry.icon { line += " icon=\(icon)" } - if let color = entry.color { line += " color=\(color)" } - lines.append(line) + let statusEntries = tab.sidebarStatusEntriesInDisplayOrder() + lines.append("status_count=\(statusEntries.count)") + for entry in statusEntries { + lines.append(" \(sidebarMetadataLine(entry))") + } + + let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() + lines.append("meta_block_count=\(metadataBlocks.count)") + for block in metadataBlocks { + lines.append(" \(sidebarMetadataBlockLine(block))") } lines.append("log_count=\(tab.logEntries.count)") @@ -11827,8 +12267,12 @@ class TerminalController { tab.logEntries.removeAll() tab.progress = nil tab.gitBranch = nil + tab.panelGitBranches.removeAll() + tab.pullRequest = nil + tab.panelPullRequests.removeAll() tab.surfaceListeningPorts.removeAll() tab.listeningPorts.removeAll() + tab.metadataBlocks.removeAll() } return result } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 7dda1b50..605b04c7 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1463,6 +1463,14 @@ enum TerminalWindowPortalRegistry { portal.hideEntry(forHostedId: hostedId) } + /// Permanently detach a hosted terminal view from the window-level portal. + /// Use this when a terminal panel is actually closing (not transient SwiftUI dismantle). + static func detach(hostedView: GhosttySurfaceScrollView) { + let hostedId = ObjectIdentifier(hostedView) + guard let windowId = hostedToWindowId.removeValue(forKey: hostedId) else { return } + portalsByWindowId[windowId]?.detachHostedView(withId: hostedId) + } + /// Update the visibleInUI flag on an existing portal entry without rebinding. /// Called when a bind is deferred (host not yet in window) to prevent stale /// portal syncs from hiding a view that is about to become visible. diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 164b64dc..b889e5a7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10,7 +10,42 @@ struct SidebarStatusEntry { let value: String let icon: String? let color: String? + let url: URL? + let priority: Int + let format: SidebarMetadataFormat let timestamp: Date + + init( + key: String, + value: String, + icon: String? = nil, + color: String? = nil, + url: URL? = nil, + priority: Int = 0, + format: SidebarMetadataFormat = .plain, + timestamp: Date = Date() + ) { + self.key = key + self.value = value + self.icon = icon + self.color = color + self.url = url + self.priority = priority + self.format = format + self.timestamp = timestamp + } +} + +struct SidebarMetadataBlock { + let key: String + let markdown: String + let priority: Int + let timestamp: Date +} + +enum SidebarMetadataFormat: String { + case plain + case markdown } private struct SessionPaneRestoreEntry { @@ -1251,6 +1286,19 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum SidebarPullRequestStatus: String { + case open + case merged + case closed +} + +struct SidebarPullRequestState: Equatable { + let number: Int + let label: String + let url: URL + let status: SidebarPullRequestStatus +} + enum SidebarBranchOrdering { struct BranchEntry: Equatable { let name: String @@ -1330,6 +1378,63 @@ enum SidebarBranchOrdering { } } + static func orderedUniquePullRequests( + orderedPanelIds: [UUID], + panelPullRequests: [UUID: SidebarPullRequestState], + fallbackPullRequest: SidebarPullRequestState? + ) -> [SidebarPullRequestState] { + func statusPriority(_ status: SidebarPullRequestStatus) -> Int { + switch status { + case .merged: return 3 + case .open: return 2 + case .closed: return 1 + } + } + + func normalizedReviewURLKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.query = nil + components.fragment = nil + let scheme = components.scheme?.lowercased() ?? "" + let host = components.host?.lowercased() ?? "" + let port = components.port.map { ":\($0)" } ?? "" + var path = components.path + if path.hasSuffix("/"), path.count > 1 { + path.removeLast() + } + return "\(scheme)://\(host)\(port)\(path)" + } + + func reviewKey(for state: SidebarPullRequestState) -> String { + "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" + } + + var orderedKeys: [String] = [] + var pullRequestsByKey: [String: SidebarPullRequestState] = [:] + + for panelId in orderedPanelIds { + guard let state = panelPullRequests[panelId] else { continue } + let key = reviewKey(for: state) + if pullRequestsByKey[key] == nil { + orderedKeys.append(key) + pullRequestsByKey[key] = state + continue + } + guard let existing = pullRequestsByKey[key] else { continue } + if statusPriority(state.status) > statusPriority(existing.status) { + pullRequestsByKey[key] = state + } + } + + if orderedKeys.isEmpty, let fallbackPullRequest { + return [fallbackPullRequest] + } + + return orderedKeys.compactMap { pullRequestsByKey[$0] } + } + static func orderedUniqueBranchDirectoryEntries( orderedPanelIds: [UUID], panelBranches: [UUID: SidebarGitBranchState], @@ -2023,11 +2128,14 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] - @Published private(set) var panelGitBranches: [UUID: SidebarGitBranchState] = [:] + @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @Published var statusEntries: [String: SidebarStatusEntry] = [:] + @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? + @Published var pullRequest: SidebarPullRequestState? + @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var remoteConfiguration: WorkspaceRemoteConfiguration? @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected @@ -2459,6 +2567,12 @@ final class Workspace: Identifiable, ObservableObject { syncUnreadBadgeStateForPanel(panelId) } + func markPanelRead(_ panelId: UUID) { + guard panels[panelId] != nil else { return } + AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) + clearManualUnread(panelId: panelId) + } + func clearManualUnread(panelId: UUID) { guard manualUnreadPanelIds.remove(panelId) != nil else { return } syncUnreadBadgeStateForPanel(panelId) @@ -2528,6 +2642,30 @@ final class Workspace: Identifiable, ObservableObject { } } + func updatePanelPullRequest( + panelId: UUID, + number: Int, + label: String, + url: URL, + status: SidebarPullRequestStatus + ) { + let state = SidebarPullRequestState(number: number, label: label, url: url, status: status) + let existing = panelPullRequests[panelId] + if existing != state { + panelPullRequests[panelId] = state + } + if panelId == focusedPanelId { + pullRequest = state + } + } + + func clearPanelPullRequest(panelId: UUID) { + panelPullRequests.removeValue(forKey: panelId) + if panelId == focusedPanelId { + pullRequest = nil + } + } + @discardableResult func updatePanelTitle(panelId: UUID, title: String) -> Bool { let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -2575,6 +2713,7 @@ final class Workspace: Identifiable, ObservableObject { panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } + panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -2626,6 +2765,32 @@ final class Workspace: Identifiable, ObservableObject { remoteConfiguration != nil } + func sidebarPullRequestsInDisplayOrder() -> [SidebarPullRequestState] { + SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: sidebarOrderedPanelIds(), + panelPullRequests: panelPullRequests, + fallbackPullRequest: pullRequest + ) + } + + func sidebarStatusEntriesInDisplayOrder() -> [SidebarStatusEntry] { + statusEntries.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + + func sidebarMetadataBlocksInDisplayOrder() -> [SidebarMetadataBlock] { + metadataBlocks.values.sorted { lhs, rhs in + if lhs.priority != rhs.priority { return lhs.priority > rhs.priority } + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + } + } + + // MARK: - Panel Operations + var remoteDisplayTarget: String? { remoteConfiguration?.displayTarget } @@ -3163,17 +3328,38 @@ final class Workspace: Identifiable, ObservableObject { } // Mapping can transiently drift during split-tree mutations. If the target panel is - // currently focused, close whichever tab bonsplit marks selected in that focused pane. - guard focusedPanelId == panelId, + // currently focused (or is the active terminal first responder), close whichever tab + // bonsplit marks selected in that focused pane. + let firstResponderPanelId = cmuxOwningGhosttyView( + for: NSApp.keyWindow?.firstResponder ?? NSApp.mainWindow?.firstResponder + )?.terminalSurface?.id + let targetIsActive = focusedPanelId == panelId || firstResponderPanelId == panelId + guard targetIsActive, let focusedPane = bonsplitController.focusedPaneId, let selected = bonsplitController.selectedTab(inPane: focusedPane) else { +#if DEBUG + dlog( + "surface.close.fallback.skip panel=\(panelId.uuidString.prefix(5)) " + + "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + + "firstResponderPanel=\(firstResponderPanelId?.uuidString.prefix(5) ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil")" + ) +#endif return false } if force { forceCloseTabIds.insert(selected.id) } - return bonsplitController.closeTab(selected.id) + let closed = bonsplitController.closeTab(selected.id) +#if DEBUG + dlog( + "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + + "selectedTab=\(String(describing: selected.id).prefix(5)) " + + "closed=\(closed ? 1 : 0)" + ) +#endif + return closed } func paneId(forPanelId panelId: UUID) -> PaneID? { @@ -3236,6 +3422,49 @@ final class Workspace: Identifiable, ObservableObject { return nil } + /// Returns the top-right pane in the current split tree. + /// When a workspace is already split, sidebar PR opens should reuse an existing pane + /// instead of creating additional right splits. + func topRightBrowserReusePane() -> PaneID? { + let paneIds = bonsplitController.allPaneIds + guard paneIds.count > 1 else { return nil } + + let paneById = Dictionary(uniqueKeysWithValues: paneIds.map { ($0.id.uuidString, $0) }) + var paneBounds: [String: CGRect] = [:] + browserCollectNormalizedPaneBounds( + node: bonsplitController.treeSnapshot(), + availableRect: CGRect(x: 0, y: 0, width: 1, height: 1), + into: &paneBounds + ) + + guard !paneBounds.isEmpty else { + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + + let epsilon = 0.000_1 + let rightMostX = paneBounds.values.map(\.maxX).max() ?? 0 + + let sortedCandidates = paneBounds + .filter { _, rect in abs(rect.maxX - rightMostX) <= epsilon } + .sorted { lhs, rhs in + if abs(lhs.value.minY - rhs.value.minY) > epsilon { + return lhs.value.minY < rhs.value.minY + } + if abs(lhs.value.minX - rhs.value.minX) > epsilon { + return lhs.value.minX > rhs.value.minX + } + return lhs.key < rhs.key + } + + for candidate in sortedCandidates { + if let pane = paneById[candidate.key] { + return pane + } + } + + return paneIds.sorted { $0.id.uuidString < $1.id.uuidString }.first + } + private enum BrowserPaneBranch { case first case second @@ -3273,6 +3502,54 @@ final class Workspace: Identifiable, ObservableObject { } } + private func browserCollectNormalizedPaneBounds( + node: ExternalTreeNode, + availableRect: CGRect, + into output: inout [String: CGRect] + ) { + switch node { + case .pane(let paneNode): + output[paneNode.id] = availableRect + case .split(let splitNode): + let divider = min(max(splitNode.dividerPosition, 0), 1) + let firstRect: CGRect + let secondRect: CGRect + + if splitNode.orientation.lowercased() == "vertical" { + // Stacked split: first = top, second = bottom + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width, + height: availableRect.height * divider + ) + secondRect = CGRect( + x: availableRect.minX, + y: availableRect.minY + (availableRect.height * divider), + width: availableRect.width, + height: availableRect.height * (1 - divider) + ) + } else { + // Side-by-side split: first = left, second = right + firstRect = CGRect( + x: availableRect.minX, + y: availableRect.minY, + width: availableRect.width * divider, + height: availableRect.height + ) + secondRect = CGRect( + x: availableRect.minX + (availableRect.width * divider), + y: availableRect.minY, + width: availableRect.width * (1 - divider), + height: availableRect.height + ) + } + + browserCollectNormalizedPaneBounds(node: splitNode.first, availableRect: firstRect, into: &output) + browserCollectNormalizedPaneBounds(node: splitNode.second, availableRect: secondRect, into: &output) + } + } + private struct BrowserCloseFallbackPlan { let orientation: SplitOrientation let insertFirst: Bool @@ -3894,6 +4171,7 @@ final class Workspace: Identifiable, ObservableObject { currentDirectory = dir } gitBranch = panelGitBranches[targetPanelId] + pullRequest = panelPullRequests[targetPanelId] } /// Reconcile focus/first-responder convergence. @@ -4117,8 +4395,10 @@ extension Workspace: BonsplitDelegate { private func refreshFocusedGitBranchState() { if let focusedPanelId { gitBranch = panelGitBranches[focusedPanelId] + pullRequest = panelPullRequests[focusedPanelId] } else { gitBranch = nil + pullRequest = nil } } @@ -4249,6 +4529,7 @@ extension Workspace: BonsplitDelegate { panels.removeValue(forKey: panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) + panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) @@ -4350,10 +4631,16 @@ extension Workspace: BonsplitDelegate { pinnedPanelIds.remove(stalePanelId) manualUnreadPanelIds.remove(stalePanelId) panelGitBranches.removeValue(forKey: stalePanelId) + panelPullRequests.removeValue(forKey: stalePanelId) panelSubscriptions.removeValue(forKey: stalePanelId) surfaceTTYNames.removeValue(forKey: stalePanelId) + surfaceListeningPorts.removeValue(forKey: stalePanelId) + restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId) } + if !staleMappings.isEmpty { + recomputeListeningPorts() + } refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f857ffa0..2cad848f 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import Darwin +import Bonsplit @main struct cmuxApp: App { @@ -35,6 +36,10 @@ struct cmuxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + Self.terminateForMissingLaunchTag() + } + Self.configureGhosttyEnvironment() let startupAppearance = AppearanceSettings.resolvedMode() @@ -58,6 +63,14 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) } + private static func terminateForMissingLaunchTag() -> Never { + let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag (or set CMUX_TAG for test harnesses)" + fputs("\(message)\n", stderr) + fflush(stderr) + NSLog("%@", message) + Darwin.exit(64) + } + private static func configureGhosttyEnvironment() { let fileManager = FileManager.default let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" @@ -183,7 +196,7 @@ struct cmuxApp: App { applyAppearance() if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { DispatchQueue.main.async { - showSettingsPanel() + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } } @@ -198,7 +211,7 @@ struct cmuxApp: App { .commands { CommandGroup(replacing: .appSettings) { Button("Settings…") { - showSettingsPanel() + appDelegate.openPreferencesWindow(debugSource: "menu.cmdComma") } .keyboardShortcut(",", modifiers: .command) } @@ -571,11 +584,6 @@ struct cmuxApp: App { NSApp.activate(ignoringOtherApps: true) } - private func showSettingsPanel() { - SettingsWindowController.shared.show() - NSApp.activate(ignoringOtherApps: true) - } - private func applyAppearance() { let mode = AppearanceSettings.mode(for: appearanceMode) if appearanceMode != mode.rawValue { @@ -1689,7 +1697,7 @@ private struct AcknowledgmentsView: View { } } -private final class SettingsWindowController: NSWindowController, NSWindowDelegate { +final class SettingsWindowController: NSWindowController, NSWindowDelegate { static let shared = SettingsWindowController() private init() { @@ -1716,11 +1724,17 @@ private final class SettingsWindowController: NSWindowController, NSWindowDelega func show() { guard let window else { return } +#if DEBUG + dlog("settings.window.show requested isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings) if !window.isVisible { window.center() } window.makeKeyAndOrderFront(nil) +#if DEBUG + dlog("settings.window.show completed isVisible=\(window.isVisible ? 1 : 0) isKey=\(window.isKeyWindow ? 1 : 0)") +#endif } } @@ -2617,6 +2631,14 @@ struct SettingsView: View { @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarActiveTabIndicatorSettings.styleKey) private var sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + @AppStorage("sidebarShowBranchDirectory") private var sidebarShowBranchDirectory = true + @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true + @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true + @AppStorage("sidebarShowLog") private var sidebarShowLog = true + @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true @State private var shortcutResetToken = UUID() @State private var topBlurOpacity: Double = 0 @State private var topBlurBaselineOffset: CGFloat? @@ -2835,6 +2857,84 @@ struct SettingsView: View { .pickerStyle(.menu) } + SettingsCardDivider() + + SettingsCardRow( + "Show Branch + Directory in Sidebar", + subtitle: "Display the built-in git branch and working-directory row." + ) { + Toggle("", isOn: $sidebarShowBranchDirectory) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Pull Requests in Sidebar", + subtitle: "Display review items (PR/MR/etc.) with status, number, and clickable link." + ) { + Toggle("", isOn: $sidebarShowPullRequest) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Open Sidebar PR Links in cmux Browser", + subtitle: openSidebarPullRequestLinksInCmuxBrowser + ? "Clicks open inside cmux browser." + : "Clicks open in your default browser." + ) { + Toggle("", isOn: $openSidebarPullRequestLinksInCmuxBrowser) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Listening Ports in Sidebar", + subtitle: "Display detected listening ports for the active workspace." + ) { + Toggle("", isOn: $sidebarShowPorts) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Latest Log in Sidebar", + subtitle: "Display the latest imperative log/status message." + ) { + Toggle("", isOn: $sidebarShowLog) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Progress in Sidebar", + subtitle: "Display the built-in progress bar from set_progress." + ) { + Toggle("", isOn: $sidebarShowProgress) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + + SettingsCardRow( + "Show Custom Metadata in Sidebar", + subtitle: "Display custom metadata from report_meta/set_status and report_meta_block." + ) { + Toggle("", isOn: $sidebarShowMetadata) + .labelsHidden() + .controlSize(.small) + } } SettingsSectionHeader(title: "Workspace Colors") @@ -3375,6 +3475,13 @@ struct SettingsView: View { workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarActiveTabIndicatorStyle = SidebarActiveTabIndicatorSettings.defaultStyle.rawValue + sidebarShowBranchDirectory = true + sidebarShowPullRequest = true + openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + sidebarShowPorts = true + sidebarShowLog = true + sidebarShowProgress = true + sidebarShowMetadata = true showOpenAccessConfirmation = false pendingOpenAccessMode = nil socketPasswordDraft = "" diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 59796d6d..c5a9435a 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -417,6 +417,49 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager") } + func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() { + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(showFallbackSettingsWindowCallCount, 1) + XCTAssertEqual(activateApplicationCallCount, 1) + } + + func testPresentPreferencesWindowSupportsRepeatedCalls() { + var showFallbackSettingsWindowCallCount = 0 + var activateApplicationCallCount = 0 + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + AppDelegate.presentPreferencesWindow( + showFallbackSettingsWindow: { + showFallbackSettingsWindowCallCount += 1 + }, + activateApplication: { + activateApplicationCallCount += 1 + } + ) + + XCTAssertEqual(showFallbackSettingsWindowCallCount, 2) + XCTAssertEqual(activateApplicationCallCount, 2) + } + private func makeKeyDownEvent( key: String, modifiers: NSEvent.ModifierFlags, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0e72f575..88299057 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -641,6 +641,40 @@ final class CmuxWebViewContextMenuTests: XCTestCase { XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) } + + func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadImageToDisk:") + let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } + + func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadLinkToDisk:") + let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } } final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { @@ -2936,6 +2970,101 @@ final class TabManagerSurfaceCreationTests: XCTestCase { ) XCTAssertEqual(workspace.focusedPanelId, browserPanelId, "Expected opened browser surface to be focused") } + + func testOpenBrowserInWorkspaceSplitRightSelectsTargetWorkspaceAndCreatesSplit() { + let manager = TabManager() + guard let initialWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial selected workspace") + return + } + guard let url = URL(string: "https://example.com/pull/123") else { + XCTFail("Expected test URL to be valid") + return + } + + let targetWorkspace = manager.addWorkspace(select: false) + manager.selectWorkspace(initialWorkspace) + let initialPaneCount = targetWorkspace.bonsplitController.allPaneIds.count + let initialPanelCount = targetWorkspace.panels.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: targetWorkspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created in target workspace") + return + } + + XCTAssertEqual(manager.selectedTabId, targetWorkspace.id, "Expected target workspace to become selected") + XCTAssertEqual( + targetWorkspace.bonsplitController.allPaneIds.count, + initialPaneCount + 1, + "Expected split-right browser open to create a new pane" + ) + XCTAssertEqual( + targetWorkspace.panels.count, + initialPanelCount + 1, + "Expected browser panel count to increase by one" + ) + XCTAssertEqual( + targetWorkspace.focusedPanelId, + browserPanelId, + "Expected created browser panel to be focused in target workspace" + ) + XCTAssertTrue( + targetWorkspace.panels[browserPanelId] is BrowserPanel, + "Expected created panel to be a browser panel" + ) + } + + func testOpenBrowserInWorkspaceSplitRightReusesTopRightPaneWhenAlreadySplit() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let topRightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + workspace.newTerminalSplit(from: topRightPanel.id, orientation: .vertical) != nil, + let topRightPaneId = workspace.paneId(forPanelId: topRightPanel.id), + let url = URL(string: "https://example.com/pull/456") else { + XCTFail("Expected split setup to succeed") + return + } + + let initialPaneCount = workspace.bonsplitController.allPaneIds.count + + guard let browserPanelId = manager.openBrowser( + inWorkspace: workspace.id, + url: url, + preferSplitRight: true, + insertAtEnd: true + ) else { + XCTFail("Expected browser panel to be created") + return + } + + XCTAssertEqual( + workspace.bonsplitController.allPaneIds.count, + initialPaneCount, + "Expected split-right browser open to reuse existing panes" + ) + XCTAssertEqual( + workspace.paneId(forPanelId: browserPanelId), + topRightPaneId, + "Expected browser to open in the top-right pane when multiple splits already exist" + ) + + let targetPaneTabs = workspace.bonsplitController.tabs(inPane: topRightPaneId) + guard let lastSurfaceId = targetPaneTabs.last?.id else { + XCTFail("Expected top-right pane to contain tabs") + return + } + XCTAssertEqual( + workspace.panelIdFromSurfaceId(lastSurfaceId), + browserPanelId, + "Expected browser surface to be appended at end in the reused top-right pane" + ) + } } @MainActor @@ -3714,6 +3843,149 @@ final class SidebarBranchOrderingTests: XCTestCase { [SidebarBranchOrdering.BranchDirectoryEntry(branch: "main", isDirty: false, directory: "/repo/default")] ) } + + func testOrderedUniquePullRequestsFollowsPanelOrderAcrossSplitsAndTabs() { + let first = UUID() + let second = UUID() + let third = UUID() + let fourth = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second, third, fourth], + panelPullRequests: [ + first: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .open + ), + second: pullRequestState( + number: 18, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/18", + status: .open + ), + third: pullRequestState( + number: 337, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/337", + status: .merged + ), + fourth: pullRequestState( + number: 92, + label: "PR", + url: "https://bitbucket.org/manaflow/cmux/pull-requests/92", + status: .closed + ) + ], + fallbackPullRequest: pullRequestState( + number: 1, + label: "PR", + url: "https://example.invalid/fallback/1", + status: .open + ) + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#337", "MR#18", "PR#92"] + ) + XCTAssertEqual( + pullRequests.map(\.status), + [.merged, .open, .closed] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberDifferentLabelsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "MR", + url: "https://gitlab.com/manaflow/cmux/-/merge_requests/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map { "\($0.label)#\($0.number)" }, + ["PR#42", "MR#42"] + ) + } + + func testOrderedUniquePullRequestsTreatsSameNumberAndLabelDifferentUrlsAsDistinct() { + let first = UUID() + let second = UUID() + + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [first, second], + panelPullRequests: [ + first: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/42", + status: .open + ), + second: pullRequestState( + number: 42, + label: "PR", + url: "https://github.com/manaflow-ai/other-repo/pull/42", + status: .open + ) + ], + fallbackPullRequest: nil + ) + + XCTAssertEqual( + pullRequests.map(\.url.absoluteString), + [ + "https://github.com/manaflow-ai/cmux/pull/42", + "https://github.com/manaflow-ai/other-repo/pull/42" + ] + ) + } + + func testOrderedUniquePullRequestsUsesFallbackWhenNoPanelPullRequestsExist() { + let fallback = pullRequestState( + number: 11, + label: "PR", + url: "https://github.com/manaflow-ai/cmux/pull/11", + status: .open + ) + let pullRequests = SidebarBranchOrdering.orderedUniquePullRequests( + orderedPanelIds: [], + panelPullRequests: [:], + fallbackPullRequest: fallback + ) + + XCTAssertEqual(pullRequests, [fallback]) + } + + private func pullRequestState( + number: Int, + label: String, + url: String, + status: SidebarPullRequestStatus + ) -> SidebarPullRequestState { + SidebarPullRequestState( + number: number, + label: label, + url: URL(string: url)!, + status: status + ) + } } @MainActor @@ -6309,6 +6581,18 @@ final class BrowserLinkOpenSettingsTests: XCTestCase { XCTAssertTrue(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser(defaults: defaults)) } + func testSidebarPullRequestLinksDefaultToCmuxBrowser() { + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + + func testSidebarPullRequestLinksPreferenceUsesStoredValue() { + defaults.set(false, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertFalse(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + + defaults.set(true, forKey: BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) + XCTAssertTrue(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowser(defaults: defaults)) + } + func testOpenCommandInterceptionDefaultsToCmuxBrowser() { XCTAssertTrue(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)) } @@ -6547,7 +6831,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "idle", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } @@ -6566,7 +6853,10 @@ final class TerminalControllerSidebarDedupeTests: XCTestCase { key: "agent", value: "running", icon: "bolt", - color: "#ffffff" + color: "#ffffff", + url: nil, + priority: 0, + format: .plain ) ) } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 220767ba..0d912bb7 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -691,6 +691,68 @@ final class SocketControlSettingsTests: XCTestCase { "/tmp/cmux-staging.sock" ) } + + func testUntaggedDebugBundleBlockedWithoutLaunchTag() { + XCTAssertTrue( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testUntaggedDebugBundleAllowedWithLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_TAG": "tests-v1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testTaggedDebugBundleAllowedWithoutLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug.tests-v1", + isDebugBuild: true + ) + ) + } + + func testReleaseBuildIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: false + ) + ) + } + + func testXCTestLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() { + // XCUITest launches the app as a separate process without XCTest env vars. + // The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment. + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_UI_TEST_MODE": "1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } } final class PostHogAnalyticsPropertiesTests: XCTestCase { diff --git a/scripts/run-tests-v1.sh b/scripts/run-tests-v1.sh index 317d19cf..12592e70 100755 --- a/scripts/run-tests-v1.sh +++ b/scripts/run-tests-v1.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v1" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/scripts/run-tests-v2.sh b/scripts/run-tests-v2.sh index 4be4e854..e17cc6c2 100755 --- a/scripts/run-tests-v2.sh +++ b/scripts/run-tests-v2.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v2" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/tests/cmux.py b/tests/cmux.py index 23c1f4b7..c4f95904 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -500,7 +500,17 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) - def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None: + def set_status( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: """Set a sidebar status entry.""" # Put options before `--` so value can contain arbitrary tokens like `--tab`. cmd = f"set_status {key}" @@ -508,6 +518,12 @@ class cmux: cmd += f" --icon={icon}" if color: cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" if tab: cmd += f" --tab={tab}" cmd += f" -- {_quote_option_value(value)}" @@ -524,6 +540,86 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_meta( + self, + key: str, + value: str, + icon: str = None, + color: str = None, + url: str = None, + priority: int = None, + format: str = None, + tab: str = None, + ) -> None: + """Report a sidebar metadata entry.""" + cmd = f"report_meta {key}" + if icon: + cmd += f" --icon={icon}" + if color: + cmd += f" --color={color}" + if url: + cmd += f" --url={_quote_option_value(url)}" + if priority is not None: + cmd += f" --priority={priority}" + if format: + cmd += f" --format={format}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(value)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta(self, key: str, tab: str = None) -> None: + """Remove a sidebar metadata entry.""" + cmd = f"clear_meta {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta(self, tab: str = None) -> str: + """List sidebar metadata entries.""" + cmd = "list_meta" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + + def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None: + """Report a freeform sidebar markdown metadata block.""" + cmd = f"report_meta_block {key}" + if priority is not None: + cmd += f" --priority={priority}" + if tab: + cmd += f" --tab={tab}" + cmd += f" -- {_quote_option_value(markdown)}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_meta_block(self, key: str, tab: str = None) -> None: + """Remove a sidebar markdown metadata block.""" + cmd = f"clear_meta_block {key}" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def list_meta_blocks(self, tab: str = None) -> str: + """List sidebar markdown metadata blocks.""" + cmd = "list_meta_blocks" + if tab: + cmd += f" --tab={tab}" + response = self._send_command(cmd) + if response.startswith("ERROR"): + raise cmuxError(response) + return response + def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None: """Append a sidebar log entry.""" # TerminalController.parseOptions treats any --* token as an option until @@ -572,6 +668,63 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_pr( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report pull-request metadata for sidebar display.""" + cmd = f"report_pr {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def report_review( + self, + number: int, + url: str, + label: str = None, + state: str = None, + tab: str = None, + panel: str = None, + ) -> None: + """Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.).""" + cmd = f"report_review {number} {url}" + if label: + cmd += f" --label={_quote_option_value(label)}" + if state: + cmd += f" --state={state}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def clear_pr(self, tab: str = None, panel: str = None) -> None: + """Clear pull-request metadata for sidebar display.""" + cmd = "clear_pr" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + def report_ports(self, *ports: int, tab: str = None) -> None: """Report listening ports for sidebar display.""" port_str = " ".join(str(p) for p in ports) diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py new file mode 100644 index 00000000..d20c7c22 --- /dev/null +++ b/tests/test_claude_hook_missing_socket_error.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing. +""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import tempfile + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock") + try: + if os.path.exists(missing_socket): + os.remove(missing_socket) + except OSError: + pass + + env = os.environ.copy() + env["CMUX_CLI_SENTRY_DISABLED"] = "1" + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + env.pop("CMUX_SOCKET_PATH", None) + + proc = subprocess.run( + [cli_path, "--socket", missing_socket, "claude-hook", "stop"], + input="{}", + text=True, + capture_output=True, + env=env, + check=False, + ) + + if proc.returncode == 0: + print("FAIL: expected non-zero exit when socket is missing") + print(f"stdout={proc.stdout}") + print(f"stderr={proc.stderr}") + return 1 + + expected_prefixes = [ + f"Error: Socket not found at {missing_socket}", + f"Error: Failed to connect to socket at {missing_socket}", + ] + if not any(prefix in proc.stderr for prefix in expected_prefixes): + print("FAIL: missing expected socket error text") + print(f"expected one of: {expected_prefixes!r}") + print(f"stderr: {proc.stderr!r}") + return 1 + + print("PASS: claude-hook stop missing-socket error is explicit") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_socket_sentry_scope.py b/tests/test_cli_socket_sentry_scope.py new file mode 100644 index 00000000..46deeee3 --- /dev/null +++ b/tests/test_cli_socket_sentry_scope.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Regression test: CLI socket Sentry telemetry must apply to all commands.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private final class CLISocketSentryTelemetry {", + "Missing CLISocketSentryTelemetry definition", + failures, + ) + require( + content, + 'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||', + "Missing CMUX_CLI_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + 'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"', + "Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + "private var shouldEmit: Bool {\n !disabledByEnv\n }", + "Telemetry scope should be command-agnostic (only disabled by env kill switch)", + failures, + ) + require( + content, + 'let crumb = Breadcrumb(level: .info, category: "cmux.cli")', + "Telemetry breadcrumb category should be cmux.cli", + failures, + ) + require( + content, + '"command": command,', + "Base telemetry context must include command name", + failures, + ) + require( + content, + "let cliTelemetry = CLISocketSentryTelemetry(", + "CLI should initialize generic socket telemetry", + failures, + ) + require( + content, + 'cliTelemetry.breadcrumb(\n "socket.connect.attempt",', + "CLI should emit socket.connect.attempt breadcrumb for commands", + failures, + ) + + reject( + content, + "self.enabled = command == \"claude-hook\"", + "Telemetry regressed to claude-hook-only scope", + failures, + ) + reject( + content, + "enabled && !disabledByEnv", + "Telemetry still depends on legacy enabled flag", + failures, + ) + + if failures: + print("FAIL: CLI socket telemetry scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI socket telemetry scope is command-agnostic") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli_version_commit_metadata.py b/tests/test_cli_version_commit_metadata.py new file mode 100644 index 00000000..3029fe0d --- /dev/null +++ b/tests/test_cli_version_commit_metadata.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Regression test: CLI version output wiring keeps commit metadata support.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + 'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }', + "versionSummary no longer reads CMUXCommit metadata", + failures, + ) + require( + content, + 'return "\\(baseSummary) [\\(commit)]"', + "versionSummary no longer appends commit metadata", + failures, + ) + require( + content, + 'if let commit = dictionary["CMUXCommit"] as? String,', + "Info.plist parsing no longer reads CMUXCommit", + failures, + ) + require( + content, + "if let commit = gitCommitHash(at: current) {", + "Project fallback no longer probes git commit hash", + failures, + ) + require( + content, + '["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]', + "Git commit probe command changed unexpectedly", + failures, + ) + require( + content, + 'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])', + "Environment commit fallback (CMUX_COMMIT) is missing", + failures, + ) + + if failures: + print("FAIL: CLI version commit metadata regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI version commit metadata wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_command_palette_socket_restart_command.py b/tests/test_command_palette_socket_restart_command.py new file mode 100644 index 00000000..6904c5a4 --- /dev/null +++ b/tests/test_command_palette_socket_restart_command.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression test for command-palette socket-listener restart command wiring.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + + missing_paths = [ + str(path) + for path in [content_view_path, app_delegate_path] + if not path.exists() + ] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + content_view = read_text(content_view_path) + app_delegate = read_text(app_delegate_path) + + failures: list[str] = [] + + require( + content_view, + 'commandId: "palette.restartSocketListener"', + "Missing `palette.restartSocketListener` command contribution", + failures, + ) + require( + content_view, + 'title: constant("Restart CLI Listener")', + "Missing `Restart CLI Listener` command title", + failures, + ) + require( + content_view, + 'registry.register(commandId: "palette.restartSocketListener") {', + "Missing command handler registration for `palette.restartSocketListener`", + failures, + ) + require( + content_view, + "AppDelegate.shared?.restartSocketListener(nil)", + "Socket restart command handler does not call `AppDelegate.restartSocketListener`", + failures, + ) + + require( + app_delegate, + "@objc func restartSocketListener(_ sender: Any?) {", + "Missing `AppDelegate.restartSocketListener` action", + failures, + ) + require( + app_delegate, + "let mode = SocketControlSettings.effectiveMode(userMode: userMode)", + "`restartSocketListener` no longer uses effective socket control mode", + failures, + ) + require( + app_delegate, + "let socketPath = SocketControlSettings.socketPath()", + "`restartSocketListener` no longer uses configured socket path", + failures, + ) + require( + app_delegate, + "TerminalController.shared.stop()", + "`restartSocketListener` no longer stops current listener before restart", + failures, + ) + require( + app_delegate, + "TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)", + "`restartSocketListener` no longer starts listener with current settings", + failures, + ) + + if failures: + print("FAIL: command-palette socket restart command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: command-palette socket restart command wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_issue_464_cmdw_close_terminal_browser_split.py b/tests/test_issue_464_cmdw_close_terminal_browser_split.py new file mode 100644 index 00000000..90a15843 --- /dev/null +++ b/tests/test_issue_464_cmdw_close_terminal_browser_split.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +Regression test for issue #464: + +Scenario: + - One workspace with exactly two panes: + left: terminal + right: browser (cnn.com) + - Focus the terminal and press Cmd+W. + +Expected: + - Terminal closes. + - Browser remains and fills the workspace (no stale terminal content/pane). + +This test uses debug socket commands (`simulate_shortcut`, `layout_debug`, +`surface_health`, `drag_hit_chain`). +Run against a Debug app socket (typically with CMUX_SOCKET_MODE=allowAll). +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return True + time.sleep(interval_s) + return False + + +def _wait_url_contains(client: cmux, panel_id: str, needle: str, timeout_s: float = 20.0) -> None: + def _matches() -> bool: + response = client._send_command(f"get_url {panel_id}").strip().lower() + return not response.startswith("error") and needle.lower() in response + + if not _wait_until(_matches, timeout_s=timeout_s, interval_s=0.1): + current = client._send_command(f"get_url {panel_id}") + raise cmuxError(f"Timed out waiting for browser URL containing '{needle}', got: {current}") + + +def _capture_screenshot(client: cmux, label: str) -> str: + response = client._send_command(f"screenshot {label}").strip() + if not response.startswith("OK "): + return f"" + parts = response.split(" ", 2) + if len(parts) < 3: + return f"" + return parts[2] + + +def _focused_terminal_ready(client: cmux, panel_id: str) -> bool: + try: + return client.is_terminal_focused(panel_id) + except Exception: + return False + + +def _drag_hit_chain(client: cmux, nx: float, ny: float) -> str: + return client._send_command(f"drag_hit_chain {nx:.3f} {ny:.3f}").strip() + + +def _top_hit_view_class(hit_chain: str) -> str: + if not hit_chain or hit_chain == "none" or hit_chain.startswith("ERROR"): + return hit_chain + first = hit_chain.split("->", 1)[0] + return first.split("@", 1)[0] + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + # Quick sanity check: fail early with actionable info if socket is not in allow mode. + ping_ok = client.ping() + if not ping_ok: + raise cmuxError( + f"Socket ping failed on {SOCKET_PATH}. " + "Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test." + ) + + workspace_id = client.new_workspace() + try: + client.select_workspace(workspace_id) + time.sleep(0.25) + client.activate_app() + time.sleep(0.15) + + browser_id = client.new_pane( + direction="right", + panel_type="browser", + url="https://cnn.com", + ) + _wait_url_contains(client, browser_id, "cnn", timeout_s=20.0) + + health_before = client.surface_health() + terminal_rows = [row for row in health_before if row.get("type") == "terminal"] + browser_rows = [row for row in health_before if row.get("type") == "browser"] + if len(terminal_rows) != 1 or len(browser_rows) != 1: + raise cmuxError( + f"Expected exactly one terminal and one browser before close; " + f"health={health_before}" + ) + + terminal_id = terminal_rows[0]["id"] + client.focus_surface(terminal_id) + if not _wait_until(lambda: _focused_terminal_ready(client, terminal_id), timeout_s=4.0): + raise cmuxError(f"Terminal did not become first responder before Cmd+W: {terminal_id}") + + before_surfaces = client.list_surfaces() + before_panes = client.list_panes() + before_layout = client.layout_debug() + before_shot = _capture_screenshot(client, "issue464_cmdw_before") + + client.simulate_shortcut("cmd+w") + + # Give close animations/routing time to settle. + _wait_until(lambda: len(client.list_surfaces()) == 1, timeout_s=4.0, interval_s=0.05) + time.sleep(0.25) + + after_surfaces = client.list_surfaces() + after_panes = client.list_panes() + after_health = client.surface_health() + after_layout = client.layout_debug() + after_shot = _capture_screenshot(client, "issue464_cmdw_after") + after_hit_chain = _drag_hit_chain(client, 0.42, 0.50) + after_top_hit_class = _top_hit_view_class(after_hit_chain) + + failures: list[str] = [] + + if len(after_surfaces) != 1: + failures.append(f"Expected 1 surface after Cmd+W, got {len(after_surfaces)}: {after_surfaces}") + + if len(after_panes) != 1: + failures.append(f"Expected 1 pane after Cmd+W, got {len(after_panes)}: {after_panes}") + + visible_terminals = [ + row for row in after_health + if row.get("type") == "terminal" and row.get("in_window") is True + ] + if visible_terminals: + failures.append(f"Terminal still visible in_window after Cmd+W: {visible_terminals}") + + remaining_browsers = [row for row in after_health if row.get("type") == "browser"] + if len(remaining_browsers) != 1: + failures.append(f"Expected one remaining browser in health, got: {remaining_browsers}") + else: + rb = remaining_browsers[0] + if str(rb.get("id", "")).lower() != browser_id.lower(): + failures.append( + f"Remaining browser id mismatch: expected {browser_id}, got {rb.get('id')}" + ) + if rb.get("in_window") is not True: + failures.append(f"Remaining browser not in window: {rb}") + + selected_panels = after_layout.get("selectedPanels") or [] + if len(selected_panels) != 1: + failures.append(f"Expected one selected panel after close, got {selected_panels}") + else: + selected_id = str(selected_panels[0].get("panelId", "")).lower() + if selected_id != browser_id.lower(): + failures.append( + f"Selected panel mismatch after close: expected browser {browser_id}, got {selected_id}" + ) + + if after_top_hit_class == "GhosttyNSView": + failures.append( + "Stale terminal overlay still hit-testable after close " + f"(top_hit={after_top_hit_class}, chain={after_hit_chain})" + ) + + if failures: + details = [ + "Cmd+W close regression reproduced (issue #464).", + f"workspace={workspace_id}", + f"browser={browser_id}", + f"terminal={terminal_id}", + f"before_screenshot={before_shot}", + f"after_screenshot={after_shot}", + f"before_surfaces={before_surfaces}", + f"before_panes={before_panes}", + f"before_layout={before_layout}", + f"after_surfaces={after_surfaces}", + f"after_panes={after_panes}", + f"after_health={after_health}", + f"after_layout={after_layout}", + f"after_hit_chain={after_hit_chain}", + f"after_top_hit_class={after_top_hit_class}", + ] + details.extend(f"failure={msg}" for msg in failures) + raise cmuxError("\n".join(details)) + + print( + "PASS: Cmd+W closed terminal in terminal+browser split and left browser as sole visible pane." + ) + print(f"before_screenshot={before_shot}") + print(f"after_screenshot={after_shot}") + return 0 + finally: + try: + client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_copy_ssh_error_context_menu.py b/tests/test_sidebar_copy_ssh_error_context_menu.py new file mode 100644 index 00000000..52b3a6f3 --- /dev/null +++ b/tests/test_sidebar_copy_ssh_error_context_menu.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + if not content_view_path.exists(): + print(f"FAIL: missing expected file: {content_view_path}") + return 1 + + content = content_view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private var copyableSidebarSSHError: String?", + "Missing sidebar SSH error extraction helper", + failures, + ) + require( + content, + 'tab.statusEntries["remote.error"]?.value', + "Missing remote.error status fallback for copyable SSH error text", + failures, + ) + require( + content, + "if let copyableSidebarSSHError {", + "Copy SSH Error menu entry is no longer conditionally gated", + failures, + ) + require( + content, + 'Button("Copy SSH Error")', + "Missing Copy SSH Error context menu button", + failures, + ) + require( + content, + "copyTextToPasteboard(copyableSidebarSSHError)", + "Copy SSH Error button no longer writes the resolved error text", + failures, + ) + + if failures: + print("FAIL: sidebar copy SSH error context-menu regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: sidebar Copy SSH Error context menu wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_meta.py b/tests/test_sidebar_meta.py new file mode 100644 index 00000000..7d5af6f0 --- /dev/null +++ b/tests/test_sidebar_meta.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +End-to-end test for generic sidebar metadata commands. + +Validates: +1) report_meta stores icon/url/priority/format metadata +2) metadata list ordering follows priority +3) set_status remains compatible as an alias-style metadata writer +4) clear_meta removes metadata entries +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_url = "https://github.com/manaflow-ai/cmux/pull/337" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + client.report_meta( + "task", + "**Review** PR 337", + icon="sf:doc.text.magnifyingglass", + url=pr_url, + priority=50, + format="markdown", + tab=tab_id, + ) + client.report_meta( + "context", + "issue-336-sidebar-pr-metadata", + icon="text:CTX", + priority=10, + tab=tab_id, + ) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata entries, got {len(listed)}: {listed}") + + if not listed[0].startswith("task="): + raise AssertionError(f"Expected first entry to be task metadata. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected task entry to include priority. Got: {listed[0]}") + if "format=markdown" not in listed[0]: + raise AssertionError(f"Expected markdown format in task entry. Got: {listed[0]}") + if f"url={pr_url}" not in listed[0]: + raise AssertionError(f"Expected URL in task entry. Got: {listed[0]}") + + client.set_status("agent", "in progress", icon="text:AI", priority=80, tab=tab_id) + _wait_for_state_field(client, "status_count", "3") + + listed = client.list_meta(tab=tab_id).splitlines() + if not listed[0].startswith("agent="): + raise AssertionError(f"Expected highest-priority agent entry first. Got: {listed[0]}") + + client.clear_meta("task", tab=tab_id) + _wait_for_state_field(client, "status_count", "2") + + listed = client.list_meta(tab=tab_id).splitlines() + if any(line.startswith("task=") for line in listed): + raise AssertionError(f"Task metadata should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_meta_block.py b/tests/test_sidebar_meta_block.py new file mode 100644 index 00000000..1ca6ade1 --- /dev/null +++ b/tests/test_sidebar_meta_block.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar markdown metadata block commands. + +Validates: +1) report_meta_block stores markdown payload and priority +2) metadata block list ordering follows priority +3) clear_meta_block removes block metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + + summary_md = "### Agent\\n- status: in progress\\n- pr: #337" + footer_md = "_last update: now_" + + client.report_meta_block("summary", summary_md, priority=50, tab=tab_id) + client.report_meta_block("footer", footer_md, priority=10, tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "2") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if len(listed) != 2: + raise AssertionError(f"Expected 2 metadata blocks, got {len(listed)}: {listed}") + if not listed[0].startswith("summary="): + raise AssertionError(f"Expected highest-priority block first. Got: {listed[0]}") + if "priority=50" not in listed[0]: + raise AssertionError(f"Expected summary block priority in listing. Got: {listed[0]}") + + client.clear_meta_block("summary", tab=tab_id) + _wait_for_state_field(client, "meta_block_count", "1") + + listed = client.list_meta_blocks(tab=tab_id).splitlines() + if any(line.startswith("summary=") for line in listed): + raise AssertionError(f"Summary block should be cleared. Got: {listed}") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar markdown metadata block test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar markdown metadata block test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_sidebar_pr.py b/tests/test_sidebar_pr.py new file mode 100644 index 00000000..39645aaa --- /dev/null +++ b/tests/test_sidebar_pr.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +End-to-end test for sidebar pull-request metadata. + +Validates: +1) report_pr writes sidebar PR state +2) state transition open -> merged is reflected +3) provider labels can be set via report_review/report_pr --label +4) clear_pr removes PR metadata +""" + +from __future__ import annotations + +import os +import sys +import time + +# Add the directory containing cmux.py to the path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from cmux import cmux, cmuxError # noqa: E402 + + +def _parse_sidebar_state(text: str) -> dict[str, str]: + data: dict[str, str] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line or line.startswith(" "): + continue + if "=" not in line: + continue + k, v = line.split("=", 1) + data[k.strip()] = v.strip() + return data + + +def _wait_for_state_field( + client: cmux, + key: str, + expected: str, + timeout: float = 8.0, + interval: float = 0.1, +) -> dict[str, str]: + start = time.time() + while time.time() - start < timeout: + state = _parse_sidebar_state(client.sidebar_state()) + if state.get(key) == expected: + return state + time.sleep(interval) + raise AssertionError(f"Timed out waiting for {key}={expected!r}") + + +def main() -> int: + tag = os.environ.get("CMUX_TAG") or "" + if not tag: + print("Tip: set CMUX_TAG= when running this test to avoid socket conflicts.") + + pr_number = 123 + pr_url = f"https://github.com/manaflow-ai/cmux/pull/{pr_number}" + + try: + with cmux() as client: + new_tab_id = client.new_tab() + client.select_tab(new_tab_id) + time.sleep(0.6) + + tab_id = client.current_workspace() + surfaces = client.list_surfaces() + if not surfaces: + raise AssertionError("No surfaces found in selected workspace") + panel_id = surfaces[0][1] + + client.report_pr(pr_number, pr_url, state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") + + client.report_review(pr_number, pr_url, label="MR", state="open", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} open {pr_url}") + _wait_for_state_field(client, "pr_label", "MR") + + client.report_pr(pr_number, pr_url, state="merged", tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", f"#{pr_number} merged {pr_url}") + _wait_for_state_field(client, "pr_label", "PR") + + client.clear_pr(tab=tab_id, panel=panel_id) + _wait_for_state_field(client, "pr", "none") + _wait_for_state_field(client, "pr_label", "none") + + try: + client.close_tab(new_tab_id) + except Exception: + pass + + print("Sidebar PR metadata test passed.") + return 0 + except (cmuxError, AssertionError) as e: + print(f"Sidebar PR metadata test failed: {e}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())